mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcd4c07ace | |||
| 982ab2b48e | |||
| 74d9409cc3 | |||
| 0a6cfe5a6a | |||
| 4a16124a94 | |||
| 294521f162 | |||
| 00481d2bfc | |||
| eace258a86 | |||
| de4b515e64 | |||
| c35c566fef | |||
| d9dc6aa2d7 | |||
| 192802d360 | |||
| cb9773ad85 | |||
| f9d9a82e47 | |||
| 383bac241e | |||
| ea28dc46eb | |||
| 2794057738 | |||
| b7b1f5e1fd | |||
| 8fdd5bf734 | |||
| 086c3ec2d8 | |||
| f370b0296b | |||
| 9b837763e6 | |||
| 3d9a8be867 | |||
| c8caeebdba | |||
| 2c7d5ac3d8 | |||
| 30190866f8 | |||
| 53a08cf307 | |||
| 1c5864deee | |||
| 865e6d048e | |||
| 5e852170f9 | |||
| 71da57773e | |||
| ec35af4bc5 | |||
| 870d9ed41e | |||
| 24170e8684 | |||
| 7ae892fe06 | |||
| 4f537c7578 |
@@ -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 }) => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 }>) =>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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,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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
) {
|
||||
|
||||
@@ -14,7 +14,9 @@ if (env.SENTRY_DSN) {
|
||||
// Validation
|
||||
"BadRequestError",
|
||||
"SequelizeValidationError",
|
||||
"SequelizeEmptyResultError",
|
||||
"ValidationError",
|
||||
"ForbiddenError",
|
||||
|
||||
// Authentication
|
||||
"UnauthorizedError",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -120,7 +120,7 @@ export default class ExportMarkdownZipTask extends BaseTask<Props> {
|
||||
public get options() {
|
||||
return {
|
||||
priority: TaskPriority.Background,
|
||||
attempts: 2,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,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];
|
||||
|
||||
@@ -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,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];
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user