Compare commits

...

36 Commits

Author SHA1 Message Date
tommoor dcd4c07ace chore: Compressed inefficient images automatically 2022-08-07 20:05:51 +00:00
Apoorv Mishra 982ab2b48e feat(editor): support google form embeds (#3930)
Fixes #3129 and #3923
2022-08-07 12:41:30 +05:30
Nan Yu 74d9409cc3 fix: refactor auth flow to explicitly pass in a host (#3909)
* fix: refactor auth flow to explicitly pass in a host

* add new error handler to all SSO providers

* refactor passport error into middleware
2022-08-04 02:00:52 -07:00
Apoorv Mishra 0a6cfe5a6a feat: Choose random color on collection creation (#3912)
Choose a random color from a shared color palette between backend
and frontend during collection creation.
2022-08-04 01:48:19 -07:00
Apoorv Mishra 4a16124a94 fix: Remove templatize action for trashed document (#3922) 2022-08-04 01:44:15 -07:00
Apoorv Mishra 294521f162 fix: Escape regex for embeds (#3907)
Fixes #3899
2022-08-02 01:40:11 -07:00
Apoorv Mishra 00481d2bfc fix: Improve document delete confirmation message (#3876)
Modify document delete confirmation message to warn
about the number of expected nested documents to be deleted.
2022-08-01 15:51:30 -07:00
Tom Moor eace258a86 Revert "chore(deps-dev): bump react-refresh from 0.9.0 to 0.14.0 (#3901)" (#3908)
This reverts commit de4b515e64.
2022-08-01 15:43:47 -07:00
dependabot[bot] de4b515e64 chore(deps-dev): bump react-refresh from 0.9.0 to 0.14.0 (#3901)
Bumps [react-refresh](https://github.com/facebook/react/tree/HEAD/packages/react) from 0.9.0 to 0.14.0.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v0.14.0/packages/react)

---
updated-dependencies:
- dependency-name: react-refresh
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-01 13:57:32 -07:00
dependabot[bot] c35c566fef chore(deps-dev): bump concurrently from 6.2.1 to 7.3.0 (#3905)
Bumps [concurrently](https://github.com/open-cli-tools/concurrently) from 6.2.1 to 7.3.0.
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v6.2.1...v7.3.0)

---
updated-dependencies:
- dependency-name: concurrently
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-01 11:56:14 -07:00
Pavlos d9dc6aa2d7 Fix URL in huntr page link (#3906) 2022-08-01 18:51:38 +01:00
Spotlight 192802d360 feat: Expand highlighted languages (#3891)
Adds Elixir, Kotlin, and Swift to the list of available languages to be highlighted.
2022-07-31 11:23:59 -07:00
Tom Moor cb9773ad85 chore: Add emailed confirmation code to account deletion (#3873)
* wip

* tests
2022-07-31 10:59:40 -07:00
Tom Moor f9d9a82e47 fix: Cannot hit enter after sentance starting with forward slash
closes #3879
2022-07-29 09:15:48 +01:00
Tom Moor 383bac241e fix: Suppress ForbiddenError in error tracker 2022-07-26 23:18:26 +01:00
Tom Moor ea28dc46eb fix: Error in WebhookProcessor when team is permanatly destroyed 2022-07-26 22:33:48 +01:00
Tom Moor 2794057738 fix: Sequelize rejectOnEmpty should result in 404 status 2022-07-26 22:06:47 +01:00
Tom Moor b7b1f5e1fd fix: Cleanup attachments uploaded to S3 when import fails (#3868) 2022-07-26 12:10:13 -07:00
Tom Moor 8fdd5bf734 fix: substitution of content when sending an image to a profile (#3869)
* fix: Limit public uploads to basic image types

* test
2022-07-26 12:10:00 -07:00
Tom Moor 086c3ec2d8 fix: Allow more flexible SMTP connection when SSL is not required. Do not fail on self-signed certs 2022-07-25 23:44:20 +01:00
Tom Moor f370b0296b fix: File operation cleanup task should also remove import data 2022-07-25 21:10:37 +01:00
Tom Moor 9b837763e6 0.65.2 2022-07-25 19:25:23 +01:00
dependabot[bot] 3d9a8be867 chore(deps-dev): bump typescript from 4.4.4 to 4.7.4 (#3866)
* chore(deps-dev): bump typescript from 4.4.4 to 4.7.4

Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.4.4 to 4.7.4.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v4.4.4...v4.7.4)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* tsc

* tsc

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-07-25 11:21:04 -07:00
dependabot[bot] c8caeebdba chore(deps): bump react-window from 1.8.6 to 1.8.7 (#3865)
Bumps [react-window](https://github.com/bvaughn/react-window) from 1.8.6 to 1.8.7.
- [Release notes](https://github.com/bvaughn/react-window/releases)
- [Changelog](https://github.com/bvaughn/react-window/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bvaughn/react-window/commits)

---
updated-dependencies:
- dependency-name: react-window
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-25 11:09:47 -07:00
dependabot[bot] 2c7d5ac3d8 chore(deps-dev): bump @types/jsonwebtoken from 8.5.5 to 8.5.8 (#3864)
Bumps [@types/jsonwebtoken](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jsonwebtoken) from 8.5.5 to 8.5.8.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jsonwebtoken)

---
updated-dependencies:
- dependency-name: "@types/jsonwebtoken"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-25 11:09:32 -07:00
Tom Moor 30190866f8 test: Flakey test 2022-07-25 08:59:30 +01:00
Tom Moor 53a08cf307 chore: Basic protection against zip bombs 2022-07-24 23:51:04 +01:00
dependabot[bot] 1c5864deee chore(deps-dev): bump eslint-config-prettier from 8.3.0 to 8.5.0 (#3807)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 8.3.0 to 8.5.0.
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v8.3.0...v8.5.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-24 13:11:11 -07:00
Tom Moor 865e6d048e fix: 'Export' option missing in collection menu for admins 2022-07-24 20:29:59 +01:00
Tom Moor 5e852170f9 perf: Read attachment buffers only when neccessary, closes #3849 2022-07-24 19:15:34 +01:00
Tom Moor 71da57773e docs 2022-07-24 14:09:43 +01:00
Tom Moor ec35af4bc5 Refactor validations 2022-07-24 13:40:04 +01:00
Nan Yu 870d9ed41e feat: allow external SSO methods to log into teams as long as emails match (#3813)
* wip

* wip

* fix comments

* better separation of conerns

* fix up tests

* fix semantics

* fixup tsc

* fix some tests

* the old semantics were easier to use

* add db:reset to scripts

* explicitly throw for unauthorized external authorization

* fix minor bug

* add additional tests for user creator and team creator

* yank the email matching logic out of teamcreator

* renaming

* fix type and test errors

* adds test to ensure that accountProvisioner works with email matching

* remove only

* fix comments

* recreate changes to allow self hosted to make teams
2022-07-24 04:55:30 -07:00
Apoorv Mishra 24170e8684 chore: Remove updatedAt column from events table (#3841) 2022-07-24 01:57:21 -07:00
Tom Moor 7ae892fe06 fix: Long collection description prevents import (#3847)
* fix: Long collection description prevents import
fix: Parallelize attachment upload during import

* fix: Improve Notion image import matching

* chore: Bump JSZIP (perf)

* fix: Allow redirect from /doc/<id> to canonical url

* fix: Importing document with only title duplicates title in body
2022-07-24 01:37:20 -07:00
Tom Moor 4f537c7578 Remove retry on export task 2022-07-23 17:00:32 +01:00
137 changed files with 1525 additions and 712 deletions
+2 -1
View File
@@ -299,7 +299,8 @@ export const createTemplate = createAction({
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate
!document?.isTemplate &&
!document?.isDeleted
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
+3 -5
View File
@@ -8,12 +8,10 @@ import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings";
import {
getDataTransferFiles,
supportedImageMimeTypes,
} from "@shared/utils/files";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -212,7 +210,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !supportedImageMimeTypes.includes(file.type)
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
);
insertFiles(view, event, pos, files, {
+3 -13
View File
@@ -40,6 +40,7 @@ import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { colorPalette } from "@shared/utils/collections";
import ContextMenu from "~/components/ContextMenu";
import Flex from "~/components/Flex";
import { LabelText } from "~/components/Input";
@@ -200,18 +201,7 @@ export const icons = {
keywords: "warning alert error",
},
};
const colors = [
"#4E5C6E",
"#0366d6",
"#9E5CF7",
"#FF825C",
"#FF5C80",
"#FFBE0B",
"#42DED1",
"#00D084",
"#FF4DFA",
"#2F362F",
];
type Props = {
onOpen?: () => void;
onClose?: () => void;
@@ -272,7 +262,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colors}
colors={colorPalette}
triangle="hide"
styles={{
default: {
@@ -6,8 +6,8 @@ import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
@@ -319,7 +319,7 @@ function InnerDocumentLink(
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
maxLength={DocumentValidation.maxTitleLength}
/>
}
isActive={(match, location: Location<{ starred?: boolean }>) =>
+7 -4
View File
@@ -11,7 +11,8 @@ import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import { depths } from "@shared/styles";
import { supportedImageMimeTypes, getEventFiles } from "@shared/utils/files";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
@@ -35,7 +36,7 @@ export type Props<T extends MenuItem = MenuItem> = {
onFileUploadStop?: () => void;
onShowToast: (message: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
onClose: (insertNewLine?: boolean) => void;
onClearSearch: () => void;
embeds?: EmbedDescriptor[];
renderMenuItem: (
@@ -122,7 +123,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
if (item) {
this.insertItem(item);
} else {
this.props.onClose();
this.props.onClose(true);
}
}
@@ -181,7 +182,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerFilePick(supportedImageMimeTypes.join(", "));
return this.triggerFilePick(
AttachmentValidation.imageContentTypes.join(", ")
);
case "attachment":
return this.triggerFilePick("*");
case "embed":
+8 -1
View File
@@ -554,7 +554,14 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
};
private handleCloseBlockMenu = () => {
private handleCloseBlockMenu = (insertNewLine?: boolean) => {
if (insertNewLine) {
const transaction = this.view.state.tr.split(
this.view.state.selection.to
);
this.view.dispatch(transaction);
this.view.focus();
}
if (!this.state.blockMenuOpen) {
return;
}
+2 -2
View File
@@ -266,7 +266,7 @@ function CollectionMenu({
{
type: "button",
title: `${t("Export")}`,
visible: !!(collection && canUserInTeam.export),
visible: !!(collection && canUserInTeam.createExport),
onClick: handleExport,
icon: <ExportIcon />,
},
@@ -296,7 +296,7 @@ function CollectionMenu({
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.export,
canUserInTeam.createExport,
handleExport,
handleDelete,
handleChangeSort,
+1 -1
View File
@@ -1,5 +1,5 @@
import { computed, observable } from "mobx";
import { Role } from "@shared/types";
import type { Role } from "@shared/types";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
+2 -2
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { CollectionValidation } from "@shared/validations";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
@@ -94,7 +94,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
type="text"
label={t("Name")}
onChange={handleNameChange}
maxLength={MAX_TITLE_LENGTH}
maxLength={CollectionValidation.maxNameLength}
value={name}
required
autoFocus
+5 -3
View File
@@ -3,7 +3,9 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { randomElement } from "@shared/random";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
@@ -30,7 +32,7 @@ class CollectionNew extends React.Component<Props> {
icon = "";
@observable
color = "#4E5C6E";
color = randomElement(colorPalette);
@observable
sharing = true;
@@ -128,7 +130,7 @@ class CollectionNew extends React.Component<Props> {
type="text"
label={t("Name")}
onChange={this.handleNameChange}
maxLength={MAX_TITLE_LENGTH}
maxLength={CollectionValidation.maxNameLength}
value={this.name}
required
autoFocus
@@ -45,7 +45,12 @@ function DataLoader({ match, children }: Props) {
const { team } = auth;
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
const document = documents.getByUrl(match.params.documentSlug);
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
const revision = revisionId ? revisions.get(revisionId) : undefined;
const sharedTree = document
? documents.getSharedTree(document.id)
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { light } from "@shared/styles/theme";
import {
getCurrentDateAsString,
getCurrentDateTimeAsString,
getCurrentTimeAsString,
} from "@shared/utils/date";
import { DocumentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import Star, { AnimatedStar } from "~/components/Star";
@@ -132,7 +132,7 @@ const EditableTitle = React.forwardRef(
$emojiWidth={emojiWidth}
$isStarred={document.isStarred}
autoFocus={!value}
maxLength={MAX_TITLE_LENGTH}
maxLength={DocumentValidation.maxTitleLength}
readOnly={readOnly}
dir="auto"
ref={ref}
+17 -2
View File
@@ -24,6 +24,9 @@ function DocumentDelete({ document, onSubmit }: Props) {
const { showToast } = useToasts();
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
@@ -94,9 +97,9 @@ function DocumentDelete({ document, onSubmit }: Props) {
em: <strong />,
}}
/>
) : (
) : nestedDocumentsCount < 1 ? (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents."
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: document.titleWithDefault,
}}
@@ -104,6 +107,18 @@ function DocumentDelete({ document, onSubmit }: Props) {
em: <strong />,
}}
/>
) : (
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
values={{
documentTitle: document.titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
}}
/>
)}
</Text>
{canArchive && (
+9 -5
View File
@@ -89,11 +89,15 @@ function AuthenticationProvider(props: Props) {
);
}
// If we're on a custom domain then the auth must point to the root
// app.getoutline.com for authentication so that the state cookie can be set
// and read.
const isCustomDomain = parseDomain(window.location.origin).custom;
const href = `${isCustomDomain ? env.URL : ""}${authUrl}`;
// If we're on a custom domain or a subdomain then the auth must point to the
// apex (env.URL) for authentication so that the state cookie can be set and read.
// We pass the host into the auth URL so that the server can redirect on error
// and keep the user on the same page.
const { custom, teamSubdomain, host } = parseDomain(window.location.origin);
const needsRedirect = custom || teamSubdomain;
const href = needsRedirect
? `${env.URL}${authUrl}?host=${encodeURI(host)}`
: authUrl;
return (
<Wrapper>
@@ -5,6 +5,7 @@ import * as React from "react";
import AvatarEditor from "react-avatar-editor";
import Dropzone from "react-dropzone";
import styled from "styled-components";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
@@ -134,7 +135,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
return (
<Dropzone
accept="image/png, image/jpeg"
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
+82 -27
View File
@@ -1,65 +1,120 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import { ReactHookWrappedInput as Input } from "~/components/Input";
import Modal from "~/components/Modal";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type FormData = {
code: string;
};
type Props = {
onRequestClose: () => void;
};
function UserDelete({ onRequestClose }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const [isWaitingCode, setWaitingCode] = React.useState(false);
const { auth } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
FormData
>();
const handleSubmit = React.useCallback(
const handleRequestDelete = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsDeleting(true);
try {
await auth.deleteUser();
auth.logout();
await auth.requestDelete();
setWaitingCode(true);
} catch (error) {
showToast(error.message, {
type: "error",
});
} finally {
setIsDeleting(false);
}
},
[auth, showToast]
);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await auth.deleteUser(data);
auth.logout();
} catch (error) {
showToast(error.message, {
type: "error",
});
}
},
[auth, showToast]
);
const inputProps = register("code", {
required: true,
});
return (
<Modal isOpen title={t("Delete Account")} onRequestClose={onRequestClose}>
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans>
Are you sure? Deleting your account will destroy identifying data
associated with your user and cannot be undone. You will be
immediately logged out of Outline and all your API tokens will be
revoked.
</Trans>
</Text>
<Text type="secondary">
<Trans
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
components={{
em: <strong />,
}}
/>
</Text>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Delete My Account")}
</Button>
<form onSubmit={formHandleSubmit(handleSubmit)}>
{isWaitingCode ? (
<>
<Text type="secondary">
<Trans>
A confirmation code has been sent to your email address,
please enter the code below to permanantly destroy your
account.
</Trans>
</Text>
<Text type="secondary">
<Trans
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
components={{
em: <strong />,
}}
/>
</Text>
<Input
placeholder="CODE"
autoComplete="off"
autoFocus
maxLength={8}
required
{...inputProps}
/>
</>
) : (
<>
<Text type="secondary">
<Trans>
Are you sure? Deleting your account will destroy identifying
data associated with your user and cannot be undone. You will
be immediately logged out of Outline and all your API tokens
will be revoked.
</Trans>
</Text>
</>
)}
{env.EMAIL_ENABLED && !isWaitingCode ? (
<Button type="submit" onClick={handleRequestDelete} neutral>
{t("Continue")}
</Button>
) : (
<Button type="submit" disabled={formState.isSubmitting} danger>
{formState.isSubmitting
? `${t("Deleting")}`
: t("Delete My Account")}
</Button>
)}
</form>
</Flex>
</Modal>
+7 -2
View File
@@ -199,8 +199,13 @@ export default class AuthStore {
};
@action
deleteUser = async () => {
await client.post(`/users.delete`);
requestDelete = () => {
return client.post(`/users.requestDelete`);
};
@action
deleteUser = async (data: { code: string }) => {
await client.post(`/users.delete`, data);
runInAction("AuthStore#updateUser", () => {
this.user = null;
this.team = null;
+2 -2
View File
@@ -2,10 +2,10 @@ import path from "path";
import invariant from "invariant";
import { find, orderBy, filter, compact, omitBy } from "lodash";
import { observable, action, computed, runInAction } from "mobx";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { DateFilter } from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import naturalSort from "@shared/utils/naturalSort";
import { DocumentValidation } from "@shared/validations";
import BaseStore from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
@@ -553,7 +553,7 @@ export default class DocumentsStore extends BaseStore<Document> {
template: document.template,
title: `${document.title.slice(
0,
MAX_TITLE_LENGTH - append.length
DocumentValidation.maxTitleLength - append.length
)}${append}`,
text: document.text,
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { action, autorun, computed, observable } from "mobx";
import { light as defaultTheme } from "@shared/styles/theme";
import Document from "~/models/Document";
import { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import Storage from "~/utils/Storage";
const UI_STORE = "UI_STORE";
+1 -1
View File
@@ -4,7 +4,7 @@
The Outline team takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
If you discover a security vulnerability in outline, please disclose it via [our huntr page](https://huntr.dev/repos/${owner}/${repo}/). Bounty eligibility, CVE assignment, response times and past reports are all there.
If you discover a security vulnerability in outline, please disclose it via [our huntr page](https://huntr.dev/repos/outline/outline/). Bounty eligibility, CVE assignment, response times and past reports are all there.
The Outline team will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
+8 -7
View File
@@ -22,6 +22,7 @@
"db:create-migration": "sequelize migration:create",
"db:migrate": "sequelize db:migrate",
"db:rollback": "sequelize db:migrate:undo",
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "yarn test:app && yarn test:server",
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",
@@ -108,7 +109,7 @@
"is-printable-key-event": "^1.0.0",
"json-loader": "0.5.4",
"jsonwebtoken": "^8.5.0",
"jszip": "^3.7.1",
"jszip": "^3.10.0",
"kbar": "0.1.0-beta.28",
"koa": "^2.13.4",
"koa-body": "^4.2.0",
@@ -178,7 +179,7 @@
"react-table": "^7.7.0",
"react-virtualized-auto-sizer": "^1.0.5",
"react-waypoint": "^10.1.0",
"react-window": "^1.8.6",
"react-window": "^1.8.7",
"reakit": "^1.3.10",
"reflect-metadata": "^0.1.13",
"refractor": "^3.5.0",
@@ -234,7 +235,7 @@
"@types/invariant": "^2.2.35",
"@types/ioredis": "^4.28.1",
"@types/jest": "^27.0.2",
"@types/jsonwebtoken": "^8.5.5",
"@types/jsonwebtoken": "^8.5.8",
"@types/koa": "^2.13.4",
"@types/koa-compress": "^4.0.3",
"@types/koa-helmet": "^6.0.4",
@@ -295,11 +296,11 @@
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"babel-plugin-transform-typescript-metadata": "^0.3.2",
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.3",
"concurrently": "^6.2.1",
"concurrently": "^7.3.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6",
"eslint": "^7.6.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.25.3",
@@ -322,7 +323,7 @@
"react-refresh": "^0.9.0",
"rimraf": "^2.5.4",
"terser-webpack-plugin": "^4.1.0",
"typescript": "^4.4.4",
"typescript": "^4.7.4",
"url-loader": "^0.6.2",
"webpack": "4.44.1",
"webpack-cli": "^3.3.12",
@@ -339,5 +340,5 @@
"js-yaml": "^3.14.1",
"jpeg-js": "0.4.4"
},
"version": "0.65.1"
"version": "0.65.2"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

@@ -108,6 +108,44 @@ describe("accountProvisioner", () => {
spy.mockRestore();
});
it("should allow authentication by email matching", async () => {
const existingTeam = await buildTeam();
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
const userWithoutAuth = await buildUser({
email: "email@example.com",
teamId: existingTeam.id,
authentications: [],
});
const { user, isNewUser, isNewTeam } = await accountProvisioner({
ip,
user: {
name: userWithoutAuth.name,
email: "email@example.com",
avatarUrl: userWithoutAuth.avatarUrl,
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: "example",
},
authenticationProvider: {
name: authenticationProvider.name,
providerId: authenticationProvider.providerId,
},
authentication: {
providerId: "anything",
accessToken: "123",
scopes: ["read"],
},
});
expect(user.id).toEqual(userWithoutAuth.id);
expect(isNewTeam).toEqual(false);
expect(isNewUser).toEqual(false);
expect(user.authentications.length).toEqual(0);
});
it("should throw an error when authentication provider is disabled", async () => {
const existingTeam = await buildTeam();
const providers = await existingTeam.$get("authenticationProviders");
+63 -8
View File
@@ -8,34 +8,58 @@ import {
AuthenticationProviderDisabledError,
} from "@server/errors";
import { APM } from "@server/logging/tracing";
import { Collection, Team, User } from "@server/models";
import teamCreator from "./teamCreator";
import userCreator from "./userCreator";
import { AuthenticationProvider, Collection, Team, User } from "@server/models";
import teamProvisioner from "./teamProvisioner";
import userProvisioner from "./userProvisioner";
type Props = {
/** The IP address of the incoming request */
ip: string;
/** Details of the user logging in from SSO provider */
user: {
/** The displayed name of the user */
name: string;
/** The email address of the user */
email: string;
/** The public url of an image representing the user */
avatarUrl?: string | null;
/** The username of the user */
username?: string;
};
/** Details of the team the user is logging into */
team: {
id?: string;
/**
* The internal ID of the team that is being logged into based on the
* subdomain that the request came from, if any.
*/
teamId?: string;
/** The displayed name of the team */
name: string;
/** The domain name from the email of the user logging in */
domain?: string;
/** The preferred subdomain to provision for the team if not yet created */
subdomain: string;
/** The public url of an image representing the team */
avatarUrl?: string | null;
};
/** Details of the authentication provider being used */
authenticationProvider: {
/** The name of the authentication provider, eg "google" */
name: string;
/** External identifier of the authentication provider */
providerId: string;
};
/** Details of the authentication from SSO provider */
authentication: {
/** External identifier of the user in the authentication provider */
providerId: string;
/** The scopes granted by the access token */
scopes: string[];
/** The token provided by the authentication provider */
accessToken?: string;
/** The refresh token provided by the authentication provider */
refreshToken?: string;
/** A number of seconds that the given access token expires in */
expiresIn?: number;
};
};
@@ -55,15 +79,45 @@ async function accountProvisioner({
authentication: authenticationParams,
}: Props): Promise<AccountProvisionerResult> {
let result;
let emailMatchOnly;
try {
result = await teamCreator({
result = await teamProvisioner({
...teamParams,
authenticationProvider: authenticationProviderParams,
ip,
});
} catch (err) {
throw InvalidAuthenticationError(err.message);
// The account could not be provisioned for the provided teamId
// check to see if we can try authentication using email matching only
if (err.id === "invalid_authentication") {
const authenticationProvider = await AuthenticationProvider.findOne({
where: {
name: authenticationProviderParams.name, // example: "google"
teamId: teamParams.teamId,
},
include: [
{
model: Team,
as: "team",
required: true,
},
],
});
if (authenticationProvider) {
emailMatchOnly = true;
result = {
authenticationProvider,
team: authenticationProvider.team,
isNewTeam: false,
};
}
}
if (!result) {
throw InvalidAuthenticationError(err.message);
}
}
invariant(result, "Team creator result must exist");
@@ -74,20 +128,21 @@ async function accountProvisioner({
}
try {
const result = await userCreator({
const result = await userProvisioner({
name: userParams.name,
email: userParams.email,
username: userParams.username,
isAdmin: isNewTeam || undefined,
avatarUrl: userParams.avatarUrl,
teamId: team.id,
emailMatchOnly,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
...authenticationParams,
expiresAt: authenticationParams.expiresIn
? new Date(Date.now() + authenticationParams.expiresIn * 1000)
: undefined,
authenticationProviderId: authenticationProvider.id,
},
});
const { isNewUser, user } = result;
+15
View File
@@ -148,6 +148,21 @@ describe("documentImporter", () => {
expect(response.title).toEqual("Heading 1");
});
it("should handle only title", async () => {
const user = await buildUser();
const fileName = "markdown.md";
const content = `# Title`;
const response = await documentImporter({
user,
mimeType: "text/plain",
fileName,
content,
ip,
});
expect(response.text).toEqual("");
expect(response.title).toEqual("Title");
});
it("should fallback to extension if mimetype unknown", async () => {
const user = await buildUser();
const fileName = "markdown.md";
+3 -3
View File
@@ -5,8 +5,8 @@ import mammoth from "mammoth";
import quotedPrintable from "quoted-printable";
import { Transaction } from "sequelize";
import utf8 from "utf8";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import parseTitle from "@shared/utils/parseTitle";
import { DocumentValidation } from "@shared/validations";
import { APM } from "@server/logging/tracing";
import { User } from "@server/models";
import dataURItoBuffer from "@server/utils/dataURItoBuffer";
@@ -190,7 +190,7 @@ async function documentImporter({
if (text.startsWith("# ")) {
const result = parseTitle(text);
title = result.title;
text = text.replace(`# ${title}\n`, "");
text = text.replace(`# ${title}`, "").trimStart();
}
// If we parsed an emoji from _above_ the title then add it back at prefixing
@@ -221,7 +221,7 @@ async function documentImporter({
}
// It's better to truncate particularly long titles than fail the import
title = truncate(title, { length: MAX_TITLE_LENGTH });
title = truncate(title, { length: DocumentValidation.maxTitleLength });
return {
text,
+5 -4
View File
@@ -1,11 +1,10 @@
import fractionalIndex from "fractional-index";
import { Sequelize, Op, WhereOptions } from "sequelize";
import { PinValidation } from "@shared/validations";
import { sequelize } from "@server/database/sequelize";
import { ValidationError } from "@server/errors";
import { Pin, User, Event } from "@server/models";
const MAX_PINS = 8;
type Props = {
/** The user creating the pin */
user: User;
@@ -40,8 +39,10 @@ export default async function pinCreator({
};
const count = await Pin.count({ where });
if (count >= MAX_PINS) {
throw ValidationError(`You cannot pin more than ${MAX_PINS} documents`);
if (count >= PinValidation.max) {
throw ValidationError(
`You cannot pin more than ${PinValidation.max} documents`
);
}
if (!index) {
+3 -3
View File
@@ -54,7 +54,7 @@ export default async function starCreator({
index = fractionalIndex(null, stars.length ? stars[0].index : null);
}
const response = await Star.findOrCreate({
const [star, isCreated] = await Star.findOrCreate({
where: documentId
? {
userId: user.id,
@@ -69,12 +69,12 @@ export default async function starCreator({
},
transaction,
});
const star = response[0];
if (response[1]) {
if (isCreated) {
await Event.create(
{
name: "stars.create",
teamId: user.teamId,
modelId: star.id,
userId: user.id,
actorId: user.id,
@@ -2,16 +2,16 @@ import env from "@server/env";
import TeamDomain from "@server/models/TeamDomain";
import { buildTeam, buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import teamCreator from "./teamCreator";
import teamProvisioner from "./teamProvisioner";
beforeEach(() => flushdb());
describe("teamCreator", () => {
describe("teamProvisioner", () => {
const ip = "127.0.0.1";
it("should create team and authentication provider", async () => {
env.DEPLOYMENT = "hosted";
const result = await teamCreator({
const result = await teamProvisioner({
name: "Test team",
subdomain: "example",
avatarUrl: "http://example.com/logo.png",
@@ -35,7 +35,7 @@ describe("teamCreator", () => {
await buildTeam({
subdomain: "myteam",
});
const result = await teamCreator({
const result = await teamProvisioner({
name: "Test team",
subdomain: "myteam",
avatarUrl: "http://example.com/logo.png",
@@ -58,7 +58,7 @@ describe("teamCreator", () => {
await buildTeam({
subdomain: "myteam1",
});
const result = await teamCreator({
const result = await teamProvisioner({
name: "Test team",
subdomain: "myteam",
avatarUrl: "http://example.com/logo.png",
@@ -72,10 +72,63 @@ describe("teamCreator", () => {
expect(result.team.subdomain).toEqual("myteam2");
});
it("should return existing team", async () => {
env.DEPLOYMENT = "hosted";
const authenticationProvider = {
name: "google",
providerId: "example.com",
};
const existing = await buildTeam({
subdomain: "example",
authenticationProviders: [authenticationProvider],
});
const result = await teamProvisioner({
name: "Updated name",
subdomain: "example",
authenticationProvider,
ip,
});
const { team, isNewTeam } = result;
expect(team.id).toEqual(existing.id);
expect(team.name).toEqual(existing.name);
expect(team.subdomain).toEqual("example");
expect(isNewTeam).toEqual(false);
});
it("should error on mismatched team and authentication provider", async () => {
env.DEPLOYMENT = "hosted";
const exampleTeam = await buildTeam({
subdomain: "example",
authenticationProviders: [
{
name: "google",
providerId: "example.com",
},
],
});
let error;
try {
await teamProvisioner({
teamId: exampleTeam.id,
name: "name",
subdomain: "other",
authenticationProvider: {
name: "google",
providerId: "other.com",
},
ip,
});
} catch (e) {
error = e;
}
expect(error.id).toEqual("invalid_authentication");
});
describe("self hosted", () => {
it("should allow creating first team", async () => {
env.DEPLOYMENT = undefined;
const { team, isNewTeam } = await teamCreator({
const { team, isNewTeam } = await teamProvisioner({
name: "Test team",
subdomain: "example",
avatarUrl: "http://example.com/logo.png",
@@ -96,7 +149,7 @@ describe("teamCreator", () => {
let error;
try {
await teamCreator({
await teamProvisioner({
name: "Test team",
subdomain: "example",
avatarUrl: "http://example.com/logo.png",
@@ -124,7 +177,7 @@ describe("teamCreator", () => {
name: "allowed-domain.com",
createdById: user.id,
});
const result = await teamCreator({
const result = await teamProvisioner({
name: "Updated name",
subdomain: "example",
domain: "allowed-domain.com",
@@ -158,7 +211,7 @@ describe("teamCreator", () => {
let error;
try {
await teamCreator({
await teamProvisioner({
name: "Updated name",
subdomain: "example",
domain: "other-domain.com",
@@ -175,7 +228,7 @@ describe("teamCreator", () => {
expect(error).toBeTruthy();
});
it("should return exising team", async () => {
it("should return existing team", async () => {
env.DEPLOYMENT = undefined;
const authenticationProvider = {
name: "google",
@@ -185,7 +238,7 @@ describe("teamCreator", () => {
subdomain: "example",
authenticationProviders: [authenticationProvider],
});
const result = await teamCreator({
const result = await teamProvisioner({
name: "Updated name",
subdomain: "example",
authenticationProvider,
@@ -1,8 +1,8 @@
import { sequelize } from "@server/database/sequelize";
import env from "@server/env";
import {
InvalidAuthenticationError,
DomainNotAllowedError,
InvalidAuthenticationError,
MaximumTeamsError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
@@ -10,37 +10,49 @@ import { APM } from "@server/logging/tracing";
import { Team, AuthenticationProvider, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars";
type TeamCreatorResult = {
type TeamProvisionerResult = {
team: Team;
authenticationProvider: AuthenticationProvider;
isNewTeam: boolean;
};
type Props = {
id?: string;
/**
* The internal ID of the team that is being logged into based on the
* subdomain that the request came from, if any.
*/
teamId?: string;
/** The displayed name of the team */
name: string;
/** The domain name from the email of the user logging in */
domain?: string;
/** The preferred subdomain to provision for the team if not yet created */
subdomain: string;
/** The public url of an image representing the team */
avatarUrl?: string | null;
/** Details of the authentication provider being used */
authenticationProvider: {
/** The name of the authentication provider, eg "google" */
name: string;
/** External identifier of the authentication provider */
providerId: string;
};
/** The IP address of the incoming request */
ip: string;
};
async function teamCreator({
id,
async function teamProvisioner({
teamId,
name,
domain,
subdomain,
avatarUrl,
authenticationProvider,
ip,
}: Props): Promise<TeamCreatorResult> {
}: Props): Promise<TeamProvisionerResult> {
let authP = await AuthenticationProvider.findOne({
where: id
? { ...authenticationProvider, teamId: id }
where: teamId
? { ...authenticationProvider, teamId }
: authenticationProvider,
include: [
{
@@ -59,11 +71,9 @@ async function teamCreator({
team: authP.team,
isNewTeam: false,
};
}
// A team id was provided but no auth provider was found matching those credentials
// The user is attempting to log into a team with an incorrect SSO - fail the login
else if (id) {
throw InvalidAuthenticationError("incorrect authentication credentials");
} else if (teamId) {
// The user is attempting to log into a team with an unfamiliar SSO provider
throw InvalidAuthenticationError();
}
// This team has never been seen before, if self hosted the logic is different
@@ -176,5 +186,5 @@ async function provisionSubdomain(team: Team, requestedSubdomain: string) {
export default APM.traceFunction({
serviceName: "command",
spanName: "teamCreator",
})(teamCreator);
spanName: "teamProvisioner",
})(teamProvisioner);
@@ -2,20 +2,20 @@ import { v4 as uuidv4 } from "uuid";
import { TeamDomain } from "@server/models";
import { buildUser, buildTeam, buildInvite } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
import userCreator from "./userCreator";
import userProvisioner from "./userProvisioner";
beforeEach(() => flushdb());
describe("userCreator", () => {
describe("userProvisioner", () => {
const ip = "127.0.0.1";
it("should update exising user and authentication", async () => {
it("should update existing user and authentication", async () => {
const existing = await buildUser();
const authentications = await existing.$get("authentications");
const existingAuth = authentications[0];
const newEmail = "test@example.com";
const newUsername = "tname";
const result = await userCreator({
const result = await userProvisioner({
name: existing.name,
email: newEmail,
username: newUsername,
@@ -30,9 +30,10 @@ describe("userCreator", () => {
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("123");
expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual(newEmail);
expect(user.username).toEqual(newUsername);
expect(isNewUser).toEqual(false);
@@ -50,7 +51,7 @@ describe("userCreator", () => {
authentications: [],
});
const result = await userCreator({
const result = await userProvisioner({
name: existing.name,
email,
username: "new-username",
@@ -65,9 +66,10 @@ describe("userCreator", () => {
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("123");
expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1);
@@ -85,7 +87,7 @@ describe("userCreator", () => {
teamId: team.id,
});
const result = await userCreator({
const result = await userProvisioner({
name: existing.name,
email,
username: "new-username",
@@ -100,9 +102,10 @@ describe("userCreator", () => {
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("123");
expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1);
@@ -115,7 +118,7 @@ describe("userCreator", () => {
const existingAuth = authentications[0];
const newEmail = "test@example.com";
await existing.destroy();
const result = await userCreator({
const result = await userProvisioner({
name: "Test Name",
email: "test@example.com",
teamId: existing.teamId,
@@ -128,9 +131,10 @@ describe("userCreator", () => {
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("123");
expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual(newEmail);
expect(isNewUser).toEqual(true);
});
@@ -142,13 +146,13 @@ describe("userCreator", () => {
let error;
try {
await userCreator({
await userProvisioner({
name: "Test Name",
email: "test@example.com",
teamId: existing.teamId,
ip,
authentication: {
authenticationProviderId: "example.org",
authenticationProviderId: uuidv4(),
providerId: existingAuth.providerId,
accessToken: "123",
scopes: ["read"],
@@ -165,7 +169,7 @@ describe("userCreator", () => {
const team = await buildTeam();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
const result = await userProvisioner({
name: "Test Name",
email: "test@example.com",
username: "tname",
@@ -179,9 +183,10 @@ describe("userCreator", () => {
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("123");
expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual("test@example.com");
expect(user.username).toEqual("tname");
expect(user.isAdmin).toEqual(false);
@@ -195,7 +200,7 @@ describe("userCreator", () => {
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
const result = await userProvisioner({
name: "Test Name",
email: "test@example.com",
username: "tname",
@@ -219,7 +224,7 @@ describe("userCreator", () => {
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
const result = await userProvisioner({
name: "Test Name",
email: "test@example.com",
username: "tname",
@@ -236,7 +241,7 @@ describe("userCreator", () => {
expect(tname.username).toEqual("tname");
expect(tname.isAdmin).toEqual(false);
expect(tname.isViewer).toEqual(true);
const tname2Result = await userCreator({
const tname2Result = await userProvisioner({
name: "Test2 Name",
email: "tes2@example.com",
username: "tname2",
@@ -264,7 +269,7 @@ describe("userCreator", () => {
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
const result = await userProvisioner({
name: invite.name,
email: "invite@ExamPle.com",
teamId: invite.teamId,
@@ -277,13 +282,46 @@ describe("userCreator", () => {
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("123");
expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual(invite.email);
expect(isNewUser).toEqual(true);
});
it("should create a user from an invited user using email match", async () => {
const externalUser = await buildUser({
email: "external@example.com",
});
const team = await buildTeam({ inviteRequired: true });
const invite = await buildInvite({
teamId: team.id,
email: externalUser.email,
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userProvisioner({
name: invite.name,
email: "external@ExamPle.com", // ensure that email is case insensistive
teamId: invite.teamId,
emailMatchOnly: true,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: "whatever",
accessToken: "123",
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication).toEqual(null);
expect(user.id).toEqual(invite.id);
expect(isNewUser).toEqual(true);
});
it("should reject an uninvited user when invites are required", async () => {
const team = await buildTeam({ inviteRequired: true });
@@ -292,7 +330,7 @@ describe("userCreator", () => {
let error;
try {
await userCreator({
await userProvisioner({
name: "Uninvited User",
email: "invite@ExamPle.com",
teamId: team.id,
@@ -323,7 +361,7 @@ describe("userCreator", () => {
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
const result = await userProvisioner({
name: "Test Name",
email: "user@example-company.com",
teamId: team.id,
@@ -336,9 +374,10 @@ describe("userCreator", () => {
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("123");
expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual("user@example-company.com");
expect(isNewUser).toEqual(true);
});
@@ -356,7 +395,7 @@ describe("userCreator", () => {
let error;
try {
await userCreator({
await userProvisioner({
name: "Bad Domain User",
email: "user@example.com",
teamId: team.id,
@@ -1,43 +1,65 @@
import { sequelize } from "@server/database/sequelize";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import { DomainNotAllowedError, InviteRequiredError } from "@server/errors";
import {
DomainNotAllowedError,
InvalidAuthenticationError,
InviteRequiredError,
} from "@server/errors";
import { Event, Team, User, UserAuthentication } from "@server/models";
type UserCreatorResult = {
type UserProvisionerResult = {
user: User;
isNewUser: boolean;
authentication: UserAuthentication;
authentication: UserAuthentication | null;
};
type Props = {
/** The displayed name of the user */
name: string;
/** The email address of the user */
email: string;
/** The username of the user */
username?: string;
/** Provision the new user as an administrator */
isAdmin?: boolean;
/** The public url of an image representing the user */
avatarUrl?: string | null;
/**
* The internal ID of the team that is being logged into based on the
* subdomain that the request came from, if any.
*/
teamId: string;
/** Only match against existing user accounts using email, do not create a new account */
emailMatchOnly?: boolean;
/** The IP address of the incoming request */
ip: string;
authentication: {
authenticationProviderId: string;
/** External identifier of the user in the authentication provider */
providerId: string;
/** The scopes granted by the access token */
scopes: string[];
/** The token provided by the authentication provider */
accessToken?: string;
/** The refresh token provided by the authentication provider */
refreshToken?: string;
/** The timestamp when the access token expires */
expiresAt?: Date;
};
};
export default async function userCreator({
export default async function userProvisioner({
name,
email,
username,
isAdmin,
emailMatchOnly,
avatarUrl,
teamId,
authentication,
ip,
}: Props): Promise<UserCreatorResult> {
const { authenticationProviderId, providerId, ...rest } = authentication;
}: Props): Promise<UserProvisionerResult> {
const { providerId, authenticationProviderId, ...rest } = authentication;
const auth = await UserAuthentication.findOne({
where: {
@@ -87,9 +109,8 @@ export default async function userCreator({
await auth.destroy();
}
// A `user` record might exist in the form of an invite even if there is no
// existing authentication record that matches. In Outline an invite is a
// shell user record.
// A `user` record may exist even if there is no existing authentication record.
// This is either an invite or a user that's external to the team
const existingUser = await User.scope([
"withAuthentications",
"withTeam",
@@ -102,12 +123,11 @@ export default async function userCreator({
},
});
// We have an existing invite for his user, so we need to update it with our
// new details, link up the authentication method, and count this as a new
// user creation.
// We have an existing user, so we need to update it with our
// new details and count this as a new user creation.
if (existingUser) {
// A `user` record might exist in the form of an invite.
// In Outline an invite is a shell user record with no authentication method
// An invite is a shell user record with no authentication method
// that's never been active before.
const isInvite = existingUser.isInvited;
@@ -145,6 +165,12 @@ export default async function userCreator({
}
);
// We don't want to associate a user auth with the auth provider
// if we're doing a simple email match, so early return here
if (emailMatchOnly) {
return null;
}
return await existingUser.$create<UserAuthentication>(
"authentication",
authentication,
@@ -171,9 +197,16 @@ export default async function userCreator({
authentication: auth,
isNewUser: isInvite,
};
} else if (emailMatchOnly) {
// There's no existing invite or user that matches the external auth email
// This is simply unauthorized
throw InvalidAuthenticationError();
}
//
// No auth, no user this is an entirely new sign in.
//
const transaction = await User.sequelize!.transaction();
try {
+9 -5
View File
@@ -103,11 +103,15 @@ export class Mailer {
pass: env.SMTP_PASSWORD,
}
: undefined,
tls: env.SMTP_TLS_CIPHERS
? {
ciphers: env.SMTP_TLS_CIPHERS,
}
: undefined,
tls: env.SMTP_SECURE
? env.SMTP_TLS_CIPHERS
? {
ciphers: env.SMTP_TLS_CIPHERS,
}
: undefined
: {
rejectUnauthorized: false,
},
};
}
@@ -0,0 +1,57 @@
import * as React from "react";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import CopyableCode from "./components/CopyableCode";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
deleteConfirmationCode: string;
};
/**
* Email sent to a user when they request to delete their account.
*/
export default class ConfirmUserDeleteEmail extends BaseEmail<Props> {
protected subject() {
return `Your account deletion request`;
}
protected preview() {
return `Your requested account deletion code`;
}
protected renderAsText({ deleteConfirmationCode }: Props): string {
return `
You requested to permanantly delete your Outline account. Please enter the code below to confirm your account deletion.
Code: ${deleteConfirmationCode}
`;
}
protected render({ deleteConfirmationCode }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your account deletion request</Heading>
<p>
You requested to permanantly delete your Outline account. Please
enter the code below to confirm your account deletion.
</p>
<EmptySpace height={5} />
<p>
<CopyableCode>{deleteConfirmationCode}</CopyableCode>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}
@@ -0,0 +1,21 @@
import * as React from "react";
const style: React.CSSProperties = {
fontFamily: "monospace",
fontSize: "20px",
display: "inline-block",
padding: "10px 20px",
color: "#111319",
background: "#F9FBFC",
fontWeight: "500",
borderRadius: "2px",
letterSpacing: "0.1em",
};
const CopyableCode: React.FC = (props) => (
<pre {...props} style={style}>
{props.children}
</pre>
);
export default CopyableCode;
-10
View File
@@ -161,16 +161,6 @@ export function GmailAccountCreationError(
});
}
export function AuthRedirectError(
message = "Redirect to the correct domain after authentication",
redirectUrl: string
) {
return httpErrors(400, message, {
id: "auth_redirect",
redirectUrl,
});
}
export function OIDCMalformedUserInfoError(
message = "User profile information malformed"
) {
+2
View File
@@ -14,7 +14,9 @@ if (env.SENTRY_DSN) {
// Validation
"BadRequestError",
"SequelizeValidationError",
"SequelizeEmptyResultError",
"ValidationError",
"ForbiddenError",
// Authentication
"UnauthorizedError",
+3 -2
View File
@@ -1,6 +1,6 @@
import { Context, Next } from "koa";
import { snakeCase } from "lodash";
import { ValidationError } from "sequelize";
import { ValidationError, EmptyResultError } from "sequelize";
export default function errorHandling() {
return async function errorHandlingMiddleware(ctx: Context, next: Next) {
@@ -20,7 +20,8 @@ export default function errorHandling() {
}
}
if (message.match(/Not found/i)) {
if (err instanceof EmptyResultError || message.match(/Not found/i)) {
message = "Resource not found";
ctx.status = 404;
error = "not_found";
}
+21 -4
View File
@@ -3,6 +3,7 @@ import { Context } from "koa";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { signIn } from "@server/utils/authentication";
import { parseState } from "@server/utils/passport";
import { AccountProvisionerResult } from "../commands/accountProvisioner";
export default function createMiddleware(providerName: string) {
@@ -18,12 +19,28 @@ export default function createMiddleware(providerName: string) {
if (err.id) {
const notice = err.id.replace(/_/g, "-");
const hasQueryString = err.redirectUrl?.includes("?");
const redirectUrl = err.redirectUrl ?? "/";
const hasQueryString = redirectUrl?.includes("?");
// Every authentication action is routed through the apex domain.
// But when there is an error, we want to redirect the user on the
// same domain or subdomain that they originated from (found in state).
// get original host
const state = ctx.cookies.get("state");
const host = state ? parseState(state).host : ctx.hostname;
// form a URL object with the err.redirectUrl and replace the host
const reqProtocol = ctx.protocol;
const requestHost = ctx.get("host");
const url = new URL(
`${reqProtocol}://${requestHost}${redirectUrl}`
);
url.host = host;
return ctx.redirect(
`${err.redirectUrl || "/"}${
hasQueryString ? "&" : "?"
}notice=${notice}`
`${url.toString()}${hasQueryString ? "&" : "?"}notice=${notice}`
);
}
@@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.removeColumn("events", "updatedAt");
},
async down (queryInterface, Sequelize) {
await queryInterface.addColumn("events", "updatedAt", {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.fn('NOW'),
});
}
};
+5 -5
View File
@@ -21,9 +21,9 @@ import {
Length as SimpleLength,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { sortNavigationNodes } from "@shared/utils/collections";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { CollectionValidation } from "@shared/validations";
import slugify from "@server/utils/slugify";
import { NavigationNode, CollectionSort } from "~/types";
import CollectionGroup from "./CollectionGroup";
@@ -143,15 +143,15 @@ class Collection extends ParanoidModel {
@NotContainsUrl
@Length({
max: MAX_TITLE_LENGTH,
msg: `name must be ${MAX_TITLE_LENGTH} characters or less`,
max: CollectionValidation.maxNameLength,
msg: `name must be ${CollectionValidation.maxNameLength} characters or less`,
})
@Column
name: string;
@Length({
max: 1000,
msg: `description must be 1000 characters or less`,
max: CollectionValidation.maxDescriptionLength,
msg: `description must be ${CollectionValidation.maxDescriptionLength} characters or less`,
})
@Column
description: string;
+3 -3
View File
@@ -35,12 +35,12 @@ import {
import MarkdownSerializer from "slate-md-serializer";
import isUUID from "validator/lib/isUUID";
import * as Y from "yjs";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { DateFilter } from "@shared/types";
import getTasks from "@shared/utils/getTasks";
import parseTitle from "@shared/utils/parseTitle";
import unescape from "@shared/utils/unescape";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { DocumentValidation } from "@shared/validations";
import { parser } from "@server/editor";
import slugify from "@server/utils/slugify";
import Backlink from "./Backlink";
@@ -196,8 +196,8 @@ class Document extends ParanoidModel {
urlId: string;
@Length({
max: MAX_TITLE_LENGTH,
msg: `Document title must be ${MAX_TITLE_LENGTH} characters or less`,
max: DocumentValidation.maxTitleLength,
msg: `Document title must be ${DocumentValidation.maxTitleLength} characters or less`,
})
@Column
title: string;
+1 -2
View File
@@ -20,7 +20,7 @@ import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
@Table({ tableName: "events", modelName: "event" })
@Table({ tableName: "events", modelName: "event", updatedAt: false })
@Fix
class Event extends IdModel {
@IsUUID(4)
@@ -106,7 +106,6 @@ class Event extends IdModel {
globalEventQueue.add(
this.build({
createdAt: now,
updatedAt: now,
...event,
})
);
+3 -3
View File
@@ -10,7 +10,7 @@ import {
Length as SimpleLength,
} from "sequelize-typescript";
import MarkdownSerializer from "slate-md-serializer";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { DocumentValidation } from "@shared/validations";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
@@ -43,8 +43,8 @@ class Revision extends IdModel {
editorVersion: string;
@Length({
max: MAX_TITLE_LENGTH,
msg: `Revision title must be ${MAX_TITLE_LENGTH} characters or less`,
max: DocumentValidation.maxTitleLength,
msg: `Revision title must be ${DocumentValidation.maxTitleLength} characters or less`,
})
@Column
title: string;
+3 -3
View File
@@ -9,7 +9,7 @@ import {
BeforeValidate,
BeforeCreate,
} from "sequelize-typescript";
import { MAX_TEAM_DOMAINS } from "@shared/constants";
import { TeamValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import Team from "./Team";
import User from "./User";
@@ -59,9 +59,9 @@ class TeamDomain extends IdModel {
const count = await this.count({
where: { teamId: model.teamId },
});
if (count >= MAX_TEAM_DOMAINS) {
if (count >= TeamValidation.maxDomains) {
throw ValidationError(
`You have reached the limit of ${MAX_TEAM_DOMAINS} domains`
`You have reached the limit of ${TeamValidation.maxDomains} domains`
);
}
}
+17 -1
View File
@@ -215,6 +215,22 @@ class User extends ParanoidModel {
return stringToColor(this.id);
}
/**
* Returns a code that can be used to delete this user account. The code will
* be rotated when the user signs out.
*
* @returns The deletion code.
*/
get deleteConfirmationCode() {
return crypto
.createHash("md5")
.update(this.jwtSecret)
.digest("hex")
.replace(/[l1IoO0]/gi, "")
.slice(0, 8)
.toUpperCase();
}
// instance methods
/**
@@ -550,7 +566,7 @@ class User extends ParanoidModel {
suspendedCount: string;
viewerCount: string;
count: string;
} = results as any;
} = results;
return {
active: parseInt(counts.activeCount),
@@ -7,6 +7,10 @@ export default class WebhookProcessor extends BaseProcessor {
static applicableEvents: ["*"] = ["*"];
async perform(event: Event) {
if (!event.teamId) {
return;
}
const webhookSubscriptions = await WebhookSubscription.findAll({
where: {
enabled: true,
@@ -11,11 +11,11 @@ import CleanupExpiredFileOperationsTask from "./CleanupExpiredFileOperationsTask
beforeEach(() => flushdb());
describe("CleanupExpiredFileOperationsTask", () => {
it("should expire exports older than 30 days ago", async () => {
it("should expire exports older than 15 days ago", async () => {
await buildFileOperation({
type: FileOperationType.Export,
state: FileOperationState.Complete,
createdAt: subDays(new Date(), 30),
createdAt: subDays(new Date(), 15),
});
await buildFileOperation({
type: FileOperationType.Export,
@@ -35,11 +35,11 @@ describe("CleanupExpiredFileOperationsTask", () => {
expect(data).toEqual(1);
});
it("should not expire exports made less than 30 days ago", async () => {
it("should not expire exports made less than 15 days ago", async () => {
await buildFileOperation({
type: FileOperationType.Export,
state: FileOperationState.Complete,
createdAt: subDays(new Date(), 29),
createdAt: subDays(new Date(), 14),
});
await buildFileOperation({
type: FileOperationType.Export,
@@ -2,10 +2,7 @@ import { subDays } from "date-fns";
import { Op } from "sequelize";
import Logger from "@server/logging/Logger";
import { FileOperation } from "@server/models";
import {
FileOperationState,
FileOperationType,
} from "@server/models/FileOperation";
import { FileOperationState } from "@server/models/FileOperation";
import BaseTask, { TaskPriority } from "./BaseTask";
type Props = {
@@ -14,12 +11,11 @@ type Props = {
export default class CleanupExpiredFileOperationsTask extends BaseTask<Props> {
public async perform({ limit }: Props) {
Logger.info("task", `Expiring export file operations older than 30 days…`);
Logger.info("task", `Expiring file operations older than 15 days…`);
const fileOperations = await FileOperation.unscoped().findAll({
where: {
type: FileOperationType.Export,
createdAt: {
[Op.lt]: subDays(new Date(), 30),
[Op.lt]: subDays(new Date(), 15),
},
state: {
[Op.ne]: FileOperationState.Expired,
+1 -1
View File
@@ -120,7 +120,7 @@ export default class ExportMarkdownZipTask extends BaseTask<Props> {
public get options() {
return {
priority: TaskPriority.Background,
attempts: 2,
attempts: 1,
};
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
name: child.name,
path: child.path,
mimeType: mime.lookup(child.path) || "application/octet-stream",
buffer: await zipObject.async("nodebuffer"),
buffer: () => zipObject.async("nodebuffer"),
});
return;
}
+5 -1
View File
@@ -74,7 +74,11 @@ describe("ImportNotionTask", () => {
// Check that the image url was replaced in the text with a redirect
const attachments = Array.from(response.attachments.values());
const attachment = attachments.find((att) =>
att.key.endsWith("Screen_Shot_2022-04-21_at_2.23.26_PM.png")
);
const documents = Array.from(response.documents.values());
expect(documents[1].text).toContain(attachments[1].redirectUrl);
expect(documents[1].text).toContain(attachment?.redirectUrl);
});
});
+12 -6
View File
@@ -82,7 +82,7 @@ export default class ImportNotionTask extends ImportTask {
name: child.name,
path: child.path,
mimeType,
buffer: await zipObject.async("nodebuffer"),
buffer: () => zipObject.async("nodebuffer"),
sourceId,
});
return;
@@ -139,13 +139,19 @@ export default class ImportNotionTask extends ImportTask {
for (const image of imagesInText) {
const name = path.basename(image.src);
const attachment = output.attachments.find((att) => att.name === name);
const attachment = output.attachments.find(
(att) =>
att.path.endsWith(image.src) ||
encodeURI(att.path).endsWith(image.src)
);
if (!attachment) {
Logger.info(
"task",
`Could not find referenced attachment with name ${name} and src ${image.src}`
);
if (!image.src.startsWith("http")) {
Logger.info(
"task",
`Could not find referenced attachment with name ${name} and src ${image.src}`
);
}
} else {
text = text.replace(
new RegExp(escapeRegExp(image.src), "g"),
+157 -140
View File
@@ -1,4 +1,5 @@
import { truncate } from "lodash";
import { CollectionValidation } from "@shared/validations";
import attachmentCreator from "@server/commands/attachmentCreator";
import documentCreator from "@server/commands/documentCreator";
import { sequelize } from "@server/database/sequelize";
@@ -65,7 +66,7 @@ export type StructuredImportData = {
name: string;
path: string;
mimeType: string;
buffer: Buffer;
buffer: () => Promise<Buffer>;
/** Optional id from import source, useful for mapping */
sourceId?: string;
}[];
@@ -197,34 +198,126 @@ export default abstract class ImportTask extends BaseTask<Props> {
const documents = new Map<string, Document>();
const attachments = new Map<string, Attachment>();
return sequelize.transaction(async (transaction) => {
const user = await User.findByPk(fileOperation.userId, {
transaction,
rejectOnEmpty: true,
});
const ip = user.lastActiveIp || undefined;
// Attachments
for (const item of data.attachments) {
const attachment = await attachmentCreator({
source: "import",
id: item.id,
name: item.name,
type: item.mimeType,
buffer: item.buffer,
user,
ip,
try {
return await sequelize.transaction(async (transaction) => {
const user = await User.findByPk(fileOperation.userId, {
transaction,
rejectOnEmpty: true,
});
attachments.set(item.id, attachment);
}
// Collections
for (const item of data.collections) {
let description = item.description;
const ip = user.lastActiveIp || undefined;
// Attachments
await Promise.all(
data.attachments.map(async (item) => {
Logger.debug("task", `ImportTask persisting attachment ${item.id}`);
const attachment = await attachmentCreator({
source: "import",
id: item.id,
name: item.name,
type: item.mimeType,
buffer: await item.buffer(),
user,
ip,
transaction,
});
attachments.set(item.id, attachment);
})
);
// Collections
for (const item of data.collections) {
Logger.debug("task", `ImportTask persisting collection ${item.id}`);
let description = item.description;
if (description) {
// Check all of the attachments we've created against urls in the text
// and replace them out with attachment redirect urls before saving.
for (const aitem of data.attachments) {
const attachment = attachments.get(aitem.id);
if (!attachment) {
continue;
}
description = description.replace(
new RegExp(`<<${attachment.id}>>`, "g"),
attachment.redirectUrl
);
}
// Check all of the document we've created against urls in the text
// and replace them out with a valid internal link. Because we are doing
// this before saving, we can't use the document slug, but we can take
// advantage of the fact that the document id will redirect in the client
for (const ditem of data.documents) {
description = description.replace(
new RegExp(`<<${ditem.id}>>`, "g"),
`/doc/${ditem.id}`
);
}
}
// check if collection with name exists
const response = await Collection.findOrCreate({
where: {
teamId: fileOperation.teamId,
name: item.name,
},
defaults: {
id: item.id,
description: truncate(description, {
length: CollectionValidation.maxDescriptionLength,
}),
createdById: fileOperation.userId,
permission: "read_write",
},
transaction,
});
let collection = response[0];
const isCreated = response[1];
// create new collection if name already exists, yes it's possible that
// there is also a "Name (Imported)" but this is a case not worth dealing
// with right now
if (!isCreated) {
const name = `${item.name} (Imported)`;
collection = await Collection.create(
{
id: item.id,
description,
teamId: fileOperation.teamId,
createdById: fileOperation.userId,
name,
permission: "read_write",
},
{ transaction }
);
}
await Event.create(
{
name: "collections.create",
collectionId: collection.id,
teamId: collection.teamId,
actorId: fileOperation.userId,
data: {
name: collection.name,
},
ip,
},
{
transaction,
}
);
collections.set(item.id, collection);
}
// Documents
for (const item of data.documents) {
Logger.debug("task", `ImportTask persisting document ${item.id}`);
let text = item.text;
if (description) {
// Check all of the attachments we've created against urls in the text
// and replace them out with attachment redirect urls before saving.
for (const aitem of data.attachments) {
@@ -232,7 +325,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
if (!attachment) {
continue;
}
description = description.replace(
text = text.replace(
new RegExp(`<<${attachment.id}>>`, "g"),
attachment.redirectUrl
);
@@ -243,132 +336,56 @@ export default abstract class ImportTask extends BaseTask<Props> {
// this before saving, we can't use the document slug, but we can take
// advantage of the fact that the document id will redirect in the client
for (const ditem of data.documents) {
description = description.replace(
text = text.replace(
new RegExp(`<<${ditem.id}>>`, "g"),
`/doc/${ditem.id}`
);
}
}
// check if collection with name exists
const response = await Collection.findOrCreate({
where: {
teamId: fileOperation.teamId,
name: item.name,
},
defaults: {
const document = await documentCreator({
source: "import",
id: item.id,
description,
createdById: fileOperation.userId,
permission: "read_write",
},
transaction,
});
let collection = response[0];
const isCreated = response[1];
// create new collection if name already exists, yes it's possible that
// there is also a "Name (Imported)" but this is a case not worth dealing
// with right now
if (!isCreated) {
const name = `${item.name} (Imported)`;
collection = await Collection.create(
{
id: item.id,
description,
teamId: fileOperation.teamId,
createdById: fileOperation.userId,
name,
permission: "read_write",
},
{ transaction }
);
}
await Event.create(
{
name: "collections.create",
collectionId: collection.id,
teamId: collection.teamId,
actorId: fileOperation.userId,
data: {
name: collection.name,
},
title: item.title,
text,
collectionId: item.collectionId,
createdAt: item.createdAt,
updatedAt: item.updatedAt ?? item.createdAt,
publishedAt: item.updatedAt ?? item.createdAt ?? new Date(),
parentDocumentId: item.parentDocumentId,
user,
ip,
},
{
transaction,
});
documents.set(item.id, document);
const collection = collections.get(item.collectionId);
if (collection) {
await collection.addDocumentToStructure(document, 0, {
transaction,
});
}
);
collections.set(item.id, collection);
}
// Documents
for (const item of data.documents) {
let text = item.text;
// Check all of the attachments we've created against urls in the text
// and replace them out with attachment redirect urls before saving.
for (const aitem of data.attachments) {
const attachment = attachments.get(aitem.id);
if (!attachment) {
continue;
}
text = text.replace(
new RegExp(`<<${attachment.id}>>`, "g"),
attachment.redirectUrl
);
}
// Check all of the document we've created against urls in the text
// and replace them out with a valid internal link. Because we are doing
// this before saving, we can't use the document slug, but we can take
// advantage of the fact that the document id will redirect in the client
for (const ditem of data.documents) {
text = text.replace(
new RegExp(`<<${ditem.id}>>`, "g"),
`/doc/${ditem.id}`
);
}
// Return value is only used for testing
return {
collections,
documents,
attachments,
};
});
} catch (err) {
Logger.info(
"task",
`Removing ${attachments.size} attachments on failure`
);
const document = await documentCreator({
source: "import",
id: item.id,
title: item.title,
text,
collectionId: item.collectionId,
createdAt: item.createdAt,
updatedAt: item.updatedAt ?? item.createdAt,
publishedAt: item.updatedAt ?? item.createdAt ?? new Date(),
parentDocumentId: item.parentDocumentId,
user,
ip,
transaction,
});
documents.set(item.id, document);
const collection = collections.get(item.collectionId);
if (collection) {
await collection.addDocumentToStructure(document, 0, { transaction });
}
}
// Return value is only used for testing
return {
collections,
documents,
attachments,
};
});
}
/**
* Optional hook to remove any temporary files that were created
*/
protected async cleanupData() {
// noop
await Promise.all(
Array.from(attachments.values()).map((model) =>
Attachment.deleteAttachmentFromS3(model)
)
);
throw err;
}
}
/**
+37
View File
@@ -13,9 +13,46 @@ import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("@server/utils/s3");
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#attachments.create", () => {
it("should require authentication", async () => {
const res = await server.post("/api/attachments.create");
expect(res.status).toEqual(401);
});
it("should allow simple image upload for public attachments", async () => {
const user = await buildUser();
const res = await server.post("/api/attachments.create", {
body: {
name: "test.png",
contentType: "image/png",
size: 1000,
public: true,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(200);
});
it("should not allow file upload for public attachments", async () => {
const user = await buildUser();
const res = await server.post("/api/attachments.create", {
body: {
name: "test.pdf",
contentType: "application/pdf",
size: 1000,
public: true,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(400);
});
});
describe("#attachments.delete", () => {
it("should require authentication", async () => {
const res = await server.post("/api/attachments.delete");
+9 -2
View File
@@ -1,6 +1,7 @@
import Router from "koa-router";
import { v4 as uuidv4 } from "uuid";
import { bytesToHumanReadable } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { sequelize } from "@server/database/sequelize";
import { AuthorizationError, ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
@@ -11,12 +12,13 @@ import {
publicS3Endpoint,
getSignedUrl,
} from "@server/utils/s3";
import { assertPresent, assertUuid } from "@server/validation";
import { assertIn, assertPresent, assertUuid } from "@server/validation";
const router = new Router();
const AWS_S3_ACL = process.env.AWS_S3_ACL || "private";
router.post("attachments.create", auth(), async (ctx) => {
const isPublic = ctx.body.public;
const {
name,
documentId,
@@ -25,9 +27,15 @@ router.post("attachments.create", auth(), async (ctx) => {
} = ctx.body;
assertPresent(name, "name is required");
assertPresent(size, "size is required");
const { user } = ctx.state;
authorize(user, "createAttachment", user.team);
// Public attachments are only used for avatars, so this is loosely coupled.
if (isPublic) {
assertIn(contentType, AttachmentValidation.avatarContentTypes);
}
if (
process.env.AWS_S3_UPLOAD_MAX_SIZE &&
size > process.env.AWS_S3_UPLOAD_MAX_SIZE
@@ -39,7 +47,6 @@ router.post("attachments.create", auth(), async (ctx) => {
);
}
const isPublic = ctx.body.public;
const s3Key = uuidv4();
const acl =
isPublic === undefined ? AWS_S3_ACL : isPublic ? "public-read" : "private";
+2
View File
@@ -1,4 +1,5 @@
import TestServer from "fetch-test-server";
import { colorPalette } from "@shared/utils/collections";
import { Document, CollectionUser, CollectionGroup } from "@server/models";
import webService from "@server/services/web";
import {
@@ -1054,6 +1055,7 @@ describe("#collections.create", () => {
expect(body.data.name).toBe("Test");
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("asc");
expect(colorPalette.includes(body.data.color)).toBeTruthy();
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy();
});
+3 -1
View File
@@ -2,6 +2,8 @@ import fractionalIndex from "fractional-index";
import invariant from "invariant";
import Router from "koa-router";
import { Sequelize, Op, WhereOptions } from "sequelize";
import { randomElement } from "@shared/random";
import { colorPalette } from "@shared/utils/collections";
import collectionExporter from "@server/commands/collectionExporter";
import teamUpdater from "@server/commands/teamUpdater";
import { sequelize } from "@server/database/sequelize";
@@ -50,7 +52,7 @@ const router = new Router();
router.post("collections.create", auth(), async (ctx) => {
const {
name,
color,
color = randomElement(colorPalette),
description,
permission,
sharing,
+17 -30
View File
@@ -329,48 +329,35 @@ describe("#users.delete", () => {
expect(res.status).toEqual(400);
});
it("should allow deleting user account", async () => {
it("should require correct code", async () => {
const user = await buildAdmin();
await buildUser({
teamId: user.teamId,
isAdmin: false,
});
const res = await server.post("/api/users.delete", {
body: {
code: "123",
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(400);
});
it("should allow deleting user account with correct code", async () => {
const user = await buildUser();
await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/users.delete", {
body: {
code: user.deleteConfirmationCode,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(200);
});
it("should allow deleting user account with admin", async () => {
const admin = await buildAdmin();
const user = await buildUser({
teamId: admin.teamId,
lastActiveAt: null,
});
const res = await server.post("/api/users.delete", {
body: {
token: admin.getJwtToken(),
id: user.id,
},
});
expect(res.status).toEqual(200);
});
it("should not allow deleting another user account", async () => {
const user = await buildUser();
const user2 = await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/users.delete", {
body: {
token: user.getJwtToken(),
id: user2.id,
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.delete");
const body = await res.json();
+35 -8
View File
@@ -1,3 +1,4 @@
import crypto from "crypto";
import Router from "koa-router";
import { Op, WhereOptions } from "sequelize";
import userDemoter from "@server/commands/userDemoter";
@@ -5,6 +6,7 @@ import userDestroyer from "@server/commands/userDestroyer";
import userInviter from "@server/commands/userInviter";
import userSuspender from "@server/commands/userSuspender";
import { sequelize } from "@server/database/sequelize";
import ConfirmUserDeleteEmail from "@server/emails/templates/ConfirmUserDeleteEmail";
import InviteEmail from "@server/emails/templates/InviteEmail";
import env from "@server/env";
import { ValidationError } from "@server/errors";
@@ -23,6 +25,7 @@ import {
import pagination from "./middlewares/pagination";
const router = new Router();
const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development");
router.post("users.list", auth(), pagination(), async (ctx) => {
let { direction } = ctx.body;
@@ -367,19 +370,43 @@ router.post("users.resendInvite", auth(), async (ctx) => {
};
});
router.post("users.delete", auth(), async (ctx) => {
const { id } = ctx.body;
const actor = ctx.state.user;
let user = actor;
router.post("users.requestDelete", auth(), async (ctx) => {
const { user } = ctx.state;
authorize(user, "delete", user);
if (id) {
user = await User.findByPk(id);
if (emailEnabled) {
await ConfirmUserDeleteEmail.schedule({
to: user.email,
deleteConfirmationCode: user.deleteConfirmationCode,
});
}
ctx.body = {
success: true,
};
});
router.post("users.delete", auth(), async (ctx) => {
const { code = "" } = ctx.body;
const { user } = ctx.state;
authorize(user, "delete", user);
const deleteConfirmationCode = user.deleteConfirmationCode;
if (
emailEnabled &&
(code.length !== deleteConfirmationCode.length ||
!crypto.timingSafeEqual(
Buffer.from(code),
Buffer.from(deleteConfirmationCode)
))
) {
throw ValidationError("The confirmation code was incorrect");
}
authorize(actor, "delete", user);
await userDestroyer({
user,
actor,
actor: user,
ip: ctx.request.ip,
});
+4 -2
View File
@@ -4,6 +4,7 @@ import jwt from "jsonwebtoken";
import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner, {
AccountProvisionerResult,
} from "@server/commands/accountProvisioner";
@@ -95,12 +96,13 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const team = await getTeamFromContext(ctx);
const domain = email.split("@")[1];
const subdomain = domain.split(".")[0];
const subdomain = slugifyDomain(domain);
const teamName = organization.displayName;
const result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
teamId: team?.id,
name: teamName,
domain,
subdomain,
+54 -76
View File
@@ -11,7 +11,6 @@ import accountProvisioner, {
import env from "@server/env";
import {
GmailAccountCreationError,
InviteRequiredError,
TeamDomainRequiredError,
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
@@ -19,7 +18,7 @@ import { User } from "@server/models";
import { StateStore, getTeamFromContext } from "@server/utils/passport";
const router = new Router();
const providerName = "google";
const GOOGLE = "google";
const scopes = [
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
@@ -34,7 +33,7 @@ type GoogleProfile = Profile & {
email: string;
picture: string;
_json: {
hd: string;
hd?: string;
};
};
@@ -63,87 +62,66 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
) => void
) {
try {
let result;
// "domain" is the Google Workspaces domain
const domain = profile._json.hd;
const team = await getTeamFromContext(ctx);
// Existence of domain means this is a Google Workspaces account
// so we'll attempt to provision an account (team and user)
if (domain) {
// remove the TLD and form a subdomain from the remaining
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
const subdomain = slugifyDomain(domain);
const teamName = capitalize(subdomain);
// Request a larger size profile picture than the default by tweaking
// the query parameter.
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
// if a team can be inferred, we assume the user is only interested in signing into
// that team in particular; otherwise, we will do a best effort at finding their account
// or provisioning a new one (within AccountProvisioner)
result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
name: teamName,
domain,
subdomain,
},
user: {
email: profile.email,
name: profile.displayName,
avatarUrl,
},
authenticationProvider: {
name: providerName,
providerId: domain,
},
authentication: {
providerId: profile.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
} else {
// No domain means it's a personal Gmail account
// We only allow sign-in to existing user accounts with these
if (!team) {
// No team usually means this is the apex domain
// Throw different errors depending on whether we think the user is
// trying to create a new account, or log-in to an existing one
const userExists = await User.count({
where: { email: profile.email.toLowerCase() },
});
if (!userExists) {
throw GmailAccountCreationError();
}
throw TeamDomainRequiredError();
}
const user = await User.findOne({
where: { teamId: team.id, email: profile.email.toLowerCase() },
// No profile domain means personal gmail account
// No team implies the request came from the apex domain
// This combination is always an error
if (!domain && !team) {
const userExists = await User.count({
where: { email: profile.email.toLowerCase() },
});
if (!user) {
throw InviteRequiredError();
// Users cannot create a team with personal gmail accounts
if (!userExists) {
throw GmailAccountCreationError();
}
result = {
user,
team,
isNewUser: false,
isNewTeam: false,
};
// To log-in with a personal account, users must specify a team subdomain
throw TeamDomainRequiredError();
}
// remove the TLD and form a subdomain from the remaining
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
const subdomain = domain ? slugifyDomain(domain) : "";
const teamName = capitalize(subdomain);
// Request a larger size profile picture than the default by tweaking
// the query parameter.
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
// if a team can be inferred, we assume the user is only interested in signing into
// that team in particular; otherwise, we will do a best effort at finding their account
// or provisioning a new one (within AccountProvisioner)
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
name: teamName,
domain,
subdomain,
},
user: {
email: profile.email,
name: profile.displayName,
avatarUrl,
},
authenticationProvider: {
name: GOOGLE,
providerId: domain ?? "",
},
authentication: {
providerId: profile.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
return done(null, result.user, result);
} catch (err) {
return done(err, null);
@@ -154,13 +132,13 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
router.get(
"google",
passport.authenticate(providerName, {
passport.authenticate(GOOGLE, {
accessType: "offline",
prompt: "select_account consent",
})
);
router.get("google.callback", passportMiddleware(providerName));
router.get("google.callback", passportMiddleware(GOOGLE));
}
export default router;
+1 -1
View File
@@ -97,7 +97,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
const result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
teamId: team?.id,
// https://github.com/outline/outline/pull/2388#discussion_r681120223
name: "Wiki",
domain,
+1 -1
View File
@@ -79,7 +79,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
teamId: team?.id,
name: profile.team.name,
subdomain: profile.team.domain,
avatarUrl: profile.team.image_230,
+2
View File
@@ -5,3 +5,5 @@ export const publicS3Endpoint = jest.fn().mockReturnValue("http://mock");
export const getSignedUrl = jest.fn().mockReturnValue("http://s3mock");
export const getSignedUrlPromise = jest.fn().mockResolvedValue("http://s3mock");
export const getPresignedPost = jest.fn().mockReturnValue({});
+8 -23
View File
@@ -9,18 +9,19 @@ import {
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import env from "@server/env";
import { Team } from "@server/models";
import { AuthRedirectError, OAuthStateMismatchError } from "../errors";
import { OAuthStateMismatchError } from "../errors";
export class StateStore {
key = "state";
store = (ctx: Context, callback: StateStoreStoreCallback) => {
// token is a short lived one-time pad to prevent replay attacks
// appDomain is the domain the user originated from when attempting auth
// we expect it to be a team subdomain, custom domain, or apex domain
const token = crypto.randomBytes(8).toString("hex");
const appDomain = parseDomain(ctx.hostname);
const state = buildState(appDomain.host, token);
// We expect host to be a team subdomain, custom domain, or apex domain
// that is passed via query param from the auth provider component.
const host = ctx.query.host?.toString() || parseDomain(ctx.hostname).host;
const state = buildState(host, token);
ctx.cookies.set(this.key, state, {
httpOnly: false,
@@ -46,24 +47,7 @@ export class StateStore {
);
}
const { host, token } = parseState(state);
// Oauth callbacks are hard-coded to come to the apex domain, so we
// redirect to the original app domain before attempting authentication.
// If there is an error during auth, the user will end up on the same domain
// that they started from.
const appDomain = parseDomain(host);
if (appDomain.host !== parseDomain(ctx.hostname).host) {
const reqProtocol = ctx.protocol;
const requestHost = ctx.get("host");
const requestPath = ctx.originalUrl;
const requestUrl = `${reqProtocol}://${requestHost}${requestPath}`;
const url = new URL(requestUrl);
url.host = appDomain.host;
return callback(AuthRedirectError(``, url.toString()), false, token);
}
const { token } = parseState(state);
// Destroy the one-time pad token and ensure it matches
ctx.cookies.set(this.key, "", {
@@ -106,6 +90,7 @@ export async function getTeamFromContext(ctx: Context) {
// we use it to infer the team they intend on signing into
const state = ctx.cookies.get("state");
const host = state ? parseState(state).host : ctx.hostname;
const domain = parseDomain(host);
let team;
+14 -2
View File
@@ -3,6 +3,7 @@ import path from "path";
import JSZip, { JSZipObject } from "jszip";
import { find } from "lodash";
import tmp from "tmp";
import { ValidationError } from "@server/errors";
import Logger from "@server/logging/Logger";
import Attachment from "@server/models/Attachment";
import Collection from "@server/models/Collection";
@@ -193,8 +194,19 @@ export type FileTreeNode = {
* @param paths An array of paths to files in the zip
* @returns
*/
export function zipAsFileTree(zip: JSZip) {
const paths = Object.keys(zip.files).map((filePath) => `/${filePath}`);
export function zipAsFileTree(
zip: JSZip,
/** The maximum number of files to unzip */
maxFiles = 10000
) {
let fileCount = 0;
const paths = Object.keys(zip.files).map((filePath) => {
if (++fileCount > maxFiles) {
throw ValidationError("Too many files in zip");
}
return `/${filePath}`;
});
const tree: FileTreeNode[] = [];
paths.forEach(function (filePath) {
-4
View File
@@ -1,7 +1,3 @@
export const USER_PRESENCE_INTERVAL = 5000;
export const MAX_AVATAR_DISPLAY = 6;
export const MAX_TITLE_LENGTH = 100;
export const MAX_TEAM_DOMAINS = 10;
+4
View File
@@ -44,6 +44,10 @@ describe("Abstract", () => {
});
test("to not be enabled elsewhere", () => {
expect("https://sharedgoabstract.com/f473".match(match)).toBe(null);
expect("https://share.goabstractacom/f473".match(match)).toBe(null);
expect("https://app1goabstract.com/share/f473".match(match2)).toBe(null);
expect("https://app.goabstractacom/share/f473".match(match2)).toBe(null);
expect("https://abstract.com".match(match)).toBe(null);
expect("https://goabstract.com".match(match)).toBe(null);
expect("https://app.goabstract.com".match(match)).toBe(null);
+2 -2
View File
@@ -4,8 +4,8 @@ import { EmbedProps as Props } from ".";
export default class Abstract extends React.Component<Props> {
static ENABLED = [
new RegExp("https?://share.(?:go)?abstract.com/(.*)$"),
new RegExp("https?://app.(?:go)?abstract.com/(?:share|embed)/(.*)$"),
new RegExp("https?://share\\.(?:go)?abstract\\.com/(.*)$"),
new RegExp("https?://app\\.(?:go)?abstract\\.com/(?:share|embed)/(.*)$"),
];
render() {
+2
View File
@@ -11,6 +11,8 @@ describe("ClickUp", () => {
test("to not be enabled elsewhere", () => {
expect("https://share.clickup.com".match(match)).toBe(null);
expect("https://sharedclickup.com/a/b/c/d".match(match)).toBe(null);
expect("https://share.clickupdcom/a/b/c/d".match(match)).toBe(null);
expect("https://clickup.com/".match(match)).toBe(null);
expect("https://clickup.com/features".match(match)).toBe(null);
});
+1 -1
View File
@@ -3,7 +3,7 @@ import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https?://share.clickup.com/[a-z]/[a-z]/(.*)/(.*)$"
"^https?://share\\.clickup\\.com/[a-z]/[a-z]/(.*)/(.*)$"
);
export default class ClickUp extends React.Component<Props> {
+10
View File
@@ -0,0 +1,10 @@
import Descript from "./Descript";
describe("Descript", () => {
const match = Descript.ENABLED[0];
test("to not be enabled elsewhere", () => {
expect("https://shareddescript.com/view/c9d8".match(match)).toBe(null);
expect("https://share.descriptdcom/view/c9d8".match(match)).toBe(null);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
export default class Descript extends React.Component<Props> {
static ENABLED = [new RegExp("https?://share.descript.com/view/(\\w+)$")];
static ENABLED = [new RegExp("https?://share\\.descript\\.com/view/(\\w+)$")];
render() {
const { matches } = this.props.attrs;
+6
View File
@@ -18,5 +18,11 @@ describe("Figma", () => {
test("to not be enabled elsewhere", () => {
expect("https://www.figma.com".match(match)).toBe(null);
expect("https://www.figma.com/features".match(match)).toBe(null);
expect(
"https://wwww.figmaacom/file/LKQ4FJ4bTnCSjedbRpk931".match(match)
).toBe(null);
expect(
"https://wwwwfigma.com/file/LKQ4FJ4bTnCSjedbRpk931".match(match)
).toBe(null);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"https://([w.-]+.)?figma.com/(file|proto)/([0-9a-zA-Z]{22,128})(?:/.*)?$"
"https://([w.-]+\\.)?figma\\.com/(file|proto)/([0-9a-zA-Z]{22,128})(?:/.*)?$"
);
export default class Figma extends React.Component<Props> {
+10
View File
@@ -22,6 +22,16 @@ describe("Gist", () => {
});
test("to not be enabled elsewhere", () => {
expect(
"https://gistigithub.com/n3n/eb51ada6308b539d388c8ff97711adfa".match(
match
)
).toBe(null);
expect(
"https://gist.githubbcom/n3n/eb51ada6308b539d388c8ff97711adfa".match(
match
)
).toBe(null);
expect("https://gist.github.com/tommoor".match(match)).toBe(null);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import styled from "styled-components";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https://gist.github.com/([a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38})/(.*)$"
"^https://gist\\.github\\.com/([a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38})/(.*)$"
);
class Gist extends React.Component<Props> {
+14
View File
@@ -0,0 +1,14 @@
import Gliffy from "./Gliffy";
describe("Gliffy", () => {
const match = Gliffy.ENABLED[0];
test("to not be enabled elsewhere", () => {
expect("https://gotgliffy.com/go/share/c9d837d74182317".match(match)).toBe(
null
);
expect("https://go.gliffyycom/go/share/c9d837d74182317".match(match)).toBe(
null
);
});
});
+1 -1
View File
@@ -8,6 +8,6 @@ function Gliffy(props: Props) {
);
}
Gliffy.ENABLED = [new RegExp("https?://go.gliffy.com/go/share/(.*)$")];
Gliffy.ENABLED = [new RegExp("https?://go\\.gliffy\\.com/go/share/(.*)$")];
export default Gliffy;
@@ -15,5 +15,15 @@ describe("GoogleCalendar", () => {
expect("https://calendar.google.com/calendar".match(match)).toBe(null);
expect("https://calendar.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://calendarrgoogle.com/calendar/embed?src=tom%40outline.com&ctz=America%2FSao_Paulo".match(
match
)
).toBe(null);
expect(
"https://calendar.googleecom/calendar/embed?src=tom%40outline.com&ctz=America%2FSao_Paulo".match(
match
)
).toBe(null);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https?://calendar.google.com/calendar/embed\\?src=(.*)$"
"^https?://calendar\\.google\\.com/calendar/embed\\?src=(.*)$"
);
export default class GoogleCalendar extends React.Component<Props> {
@@ -15,5 +15,15 @@ describe("GoogleDataStudio", () => {
expect("https://datastudio.google.com/u/0/".match(match)).toBe(null);
expect("https://datastudio.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://datastudioogoogle.com/embed/reporting/aab01789-f3a2-4ff3-9cba-c4c94c4a92e8/page/7zFD".match(
match
)
).toBe(null);
expect(
"https://datastudio.googleecom/embed/reporting/aab01789-f3a2-4ff3-9cba-c4c94c4a92e8/page/7zFD".match(
match
)
).toBe(null);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
"^https?://datastudio\\.google\\.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
);
export default class GoogleDataStudio extends React.Component<Props> {
+10
View File
@@ -30,5 +30,15 @@ describe("GoogleDocs", () => {
expect("https://docs.google.com/document".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://docssgoogle.com/document/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pubhtml".match(
match
)
).toBe(null);
expect(
"https://docs.googleecom/document/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pubhtml".match(
match
)
).toBe(null);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
const URL_REGEX = new RegExp("^https?://docs\\.google\\.com/document/(.*)$");
export default class GoogleDocs extends React.Component<Props> {
static ENABLED = [URL_REGEX];
@@ -25,5 +25,15 @@ describe("GoogleDrawings", () => {
expect("https://docs.google.com/drawings".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://docssgoogle.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
match
)
).toBe(null);
expect(
"https://docs.googleecom/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
match
)
).toBe(null);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
"^https://docs\\.google\\.com/drawings/d/(.*)/(edit|preview)(.*)$"
);
export default class GoogleDrawings extends React.Component<Props> {
+10
View File
@@ -25,5 +25,15 @@ describe("GoogleDrive", () => {
expect("https://drive.google.com/file".match(match)).toBe(null);
expect("https://drive.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://driveegoogle.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
match
)
).toBe(null);
expect(
"https://drive.googleecom/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
match
)
).toBe(null);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://drive.google.com/file/d/(.*)$");
const URL_REGEX = new RegExp("^https?://drive\\.google\\.com/file/d/(.*)$");
export default class GoogleDrive extends React.Component<Props> {
static ENABLED = [URL_REGEX];
+48
View File
@@ -0,0 +1,48 @@
import GoogleForms from "./GoogleForms";
describe("GoogleForms", () => {
const match = GoogleForms.ENABLED[0];
test("to be enabled on long-form share links", () => {
expect(
"https://docs.google.com/forms/d/e/1FAIpQLSetbCGiE8DhfVQZMtLE_CU2MwpSsrkXi690hkEDREOvMu8VYQ/viewform?usp=sf_link".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/forms/d/e/1FAIpQLSetbCGiE8DhfVQZMtLE_CU2MwpSsrkXi690hkEDREOvMu8VYQ/viewform".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/forms/d/e/1FAIpQLSetbCGiE8DhfVQZMtLE_CU2MwpSsrkXi690hkEDREOvMu8VYQ/viewform?embedded=true".match(
match
)
).toBeTruthy();
});
test("to be enabled on edit links", () => {
expect(
"https://docs.google.com/forms/d/1zG75dmHQGpomQlWB6VtRhWajNer7mKMjtApM_aRAJV8/edit".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://docs.google.com/forms".match(match)).toBe(null);
expect("https://docs.google.com/forms/d/".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://docssgoogle.com/forms/d/e/1FAIpQLSetbCGiE8DhfVQZMtLE_CU2MwpSsrkXi690hkEDREOvMu8VYQ/viewform?usp=sf_link".match(
match
)
).toBe(null);
expect(
"https://docs.googleecom/forms/d/e/1FAIpQLSetbCGiE8DhfVQZMtLE_CU2MwpSsrkXi690hkEDREOvMu8VYQ/viewform".match(
match
)
).toBe(null);
});
});
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
function GoogleForms(props: Props) {
return (
<Frame
{...props}
src={props.attrs.href.replace(
/\/(edit|viewform)(\?.+)?$/,
"/viewform?embedded=true"
)}
icon={
<Image
src="/images/google-forms.png"
alt="Google Forms Icon"
width={16}
height={16}
/>
}
canonicalUrl={props.attrs.href}
title="Google Forms"
border
/>
);
}
GoogleForms.ENABLED = [
new RegExp("^https?://docs\\.google\\.com/forms/d/(.+)$"),
];
export default GoogleForms;
+10
View File
@@ -20,5 +20,15 @@ describe("GoogleSheets", () => {
expect("https://docs.google.com/spreadsheets".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://docssgoogle.com/spreadsheets/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub".match(
match
)
).toBe(null);
expect(
"https://docs.googleecom/spreadsheets/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub".match(
match
)
).toBe(null);
});
});
+3 -1
View File
@@ -3,7 +3,9 @@ import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs\\.google\\.com/spreadsheets/d/(.*)$"
);
export default class GoogleSheets extends React.Component<Props> {
static ENABLED = [URL_REGEX];
+10
View File
@@ -25,5 +25,15 @@ describe("GoogleSlides", () => {
expect("https://docs.google.com/presentation".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
expect(
"https://docssgoogle.com/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub?start=false&loop=false&delayms=3000".match(
match
)
).toBe(null);
expect(
"https://docs.googleecom/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub?start=false&loop=false&delayms=3000".match(
match
)
).toBe(null);
});
});
+3 -1
View File
@@ -3,7 +3,9 @@ import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs\\.google\\.com/presentation/d/(.*)$"
);
export default class GoogleSlides extends React.Component<Props> {
static ENABLED = [URL_REGEX];
+11
View File
@@ -0,0 +1,11 @@
import JSFiddle from "./JSFiddle";
describe("JSFiddle", () => {
const match = JSFiddle.ENABLED[0];
test("to not be enabled for invalid urls", () => {
expect("https://jsfiddleenet/go/share/c9d837d74182317".match(match)).toBe(
null
);
});
});
+1 -1
View File
@@ -20,6 +20,6 @@ function JSFiddle(props: Props) {
);
}
JSFiddle.ENABLED = [new RegExp("https?://jsfiddle.net/(.*)/(.*)$")];
JSFiddle.ENABLED = [new RegExp("https?://jsfiddle\\.net/(.*)/(.*)$")];
export default JSFiddle;
+3
View File
@@ -28,5 +28,8 @@ describe("Loom", () => {
test("to not be enabled elsewhere", () => {
expect("https://www.useloom.com".match(match)).toBe(null);
expect("https://www.useloom.com/features".match(match)).toBe(null);
expect(
"https://www.loommcom/share/55327cbb265743f39c2c442c029277e0".match(match)
).toBe(null);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/(www\.)?(use)?loom.com\/(embed|share)\/(.*)$/;
const URL_REGEX = /^https:\/\/(www\.)?(use)?loom\.com\/(embed|share)\/(.*)$/;
export default class Loom extends React.Component<Props> {
static ENABLED = [URL_REGEX];

Some files were not shown because too many files have changed in this diff Show More