chore: Move to Typescript (#2783)

This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously.

closes #1282
This commit is contained in:
Tom Moor
2021-11-29 06:40:55 -08:00
committed by GitHub
parent 25ccfb5d04
commit 15b1069bcc
1017 changed files with 17410 additions and 54942 deletions

View File

@@ -1,7 +1,7 @@
{
"presets": [
"@babel/preset-react",
"@babel/preset-flow",
"@babel/preset-typescript",
[
"@babel/preset-env",
{
@@ -18,6 +18,7 @@
],
"plugins": [
"transform-class-properties",
"tsconfig-paths-module-resolver",
[
"transform-inline-environment-variables",
{

9
server/.eslintrc Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": [
"../.eslintrc"
],
"env": {
"jest": true,
"node": true
}
}

View File

@@ -5,9 +5,13 @@
"<rootDir>/server",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"./server/test/setup.js"
"./server/test/setup.ts"
],
"testEnvironment": "node"
}

View File

@@ -1,4 +0,0 @@
// @flow
export default {
add: () => {},
};

View File

@@ -0,0 +1,5 @@
export default {
add: () => {
// empty
},
};

View File

@@ -1,8 +1,8 @@
// @flow
import { onAuthenticatePayload } from "@hocuspocus/server";
import { Document } from "@server/models";
import { getUserForJWT } from "@server/utils/jwt";
import { AuthenticationError } from "../errors";
import { Document } from "../models";
import policy from "../policies";
import { getUserForJWT } from "../utils/jwt";
const { can } = policy;
@@ -11,26 +11,26 @@ export default class Authentication {
connection,
token,
documentName,
}: {
connection: { readOnly: boolean },
token: string,
documentName: string,
}) {
}: onAuthenticatePayload) {
// allows for different entity types to use this multiplayer provider later
const [, documentId] = documentName.split(".");
if (!token) {
throw new AuthenticationError("Authentication required");
throw AuthenticationError("Authentication required");
}
const user = await getUserForJWT(token);
if (user.isSuspended) {
throw new AuthenticationError("Account suspended");
throw AuthenticationError("Account suspended");
}
const document = await Document.findByPk(documentId, { userId: user.id });
const document = await Document.findByPk(documentId, {
userId: user.id,
});
if (!can(user, "read", document)) {
throw new AuthenticationError("Authorization required");
throw AuthenticationError("Authorization required");
}
// set document to read only for the current user, thus changes will not be

View File

@@ -1,22 +0,0 @@
// @flow
import Logger from "../logging/logger";
import { User } from "../models";
export default class CollaborationLogger {
async onLoadDocument(data: {
documentName: string,
context: { user: User },
}) {
Logger.info("hocuspocus", `Loaded document "${data.documentName}"`, {
userId: data.context.user.id,
});
}
async onConnect(data: { documentName: string, context: { user: User } }) {
Logger.info("hocuspocus", `New connection to "${data.documentName}"`);
}
async onDisconnect(data: { documentName: string, context: { user: User } }) {
Logger.info("hocuspocus", `Connection to "${data.documentName}" closed `);
}
}

View File

@@ -0,0 +1,22 @@
import {
onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
} from "@hocuspocus/server";
import Logger from "@server/logging/logger";
export default class CollaborationLogger {
async onLoadDocument(data: onLoadDocumentPayload) {
Logger.info("hocuspocus", `Loaded document "${data.documentName}"`, {
userId: data.context.user.id,
});
}
async onConnect(data: onConnectPayload) {
Logger.info("hocuspocus", `New connection to "${data.documentName}"`);
}
async onDisconnect(data: onDisconnectPayload) {
Logger.info("hocuspocus", `Connection to "${data.documentName}" closed `);
}
}

View File

@@ -1,21 +1,15 @@
// @flow
import { onChangePayload, onLoadDocumentPayload } from "@hocuspocus/server";
import { debounce } from "lodash";
import * as Y from "yjs";
import Logger from "@server/logging/logger";
import { Document, User } from "@server/models";
import documentUpdater from "../commands/documentUpdater";
import Logger from "../logging/logger";
import { Document, User } from "../models";
import markdownToYDoc from "./utils/markdownToYDoc";
const DELAY = 3000;
export default class Persistence {
async onLoadDocument({
documentName,
...data
}: {
documentName: string,
document: Y.Doc,
}) {
async onLoadDocument({ documentName, ...data }: onLoadDocumentPayload) {
const [, documentId] = documentName.split(".");
const fieldName = "default";
@@ -40,23 +34,20 @@ export default class Persistence {
);
const ydoc = markdownToYDoc(document.text, fieldName);
const state = Y.encodeStateAsUpdate(ydoc);
await document.update({ state: Buffer.from(state) }, { hooks: false });
await document.update(
{
state: Buffer.from(state),
},
{
hooks: false,
}
);
return ydoc;
}
onChange = debounce(
async ({
document,
context,
documentName,
}: {
document: Y.Doc,
context: { user: ?User },
documentName: string,
}) => {
async ({ document, context, documentName }: onChangePayload) => {
const [, documentId] = documentName.split(".");
Logger.info("database", `Persisting ${documentId}`);
try {

View File

@@ -1,66 +0,0 @@
// @flow
import Metrics from "../logging/metrics";
export default class Tracing {
onLoadDocument({
documentName,
instance,
}: {
documentName: string,
instance: any,
}) {
Metrics.increment("collaboration.load_document", { documentName });
Metrics.gaugePerInstance(
"collaboration.documents_count",
instance.getDocumentsCount()
);
}
onAuthenticationFailed({ documentName }: { documentName: string }) {
Metrics.increment("collaboration.authentication_failed", { documentName });
}
onConnect({
documentName,
instance,
}: {
documentName: string,
instance: any,
}) {
Metrics.increment("collaboration.connect", { documentName });
Metrics.gaugePerInstance(
"collaboration.connections_count",
instance.getConnectionsCount()
);
}
onDisconnect({
documentName,
instance,
}: {
documentName: string,
instance: any,
}) {
Metrics.increment("collaboration.disconnect", { documentName });
Metrics.gaugePerInstance(
"collaboration.connections_count",
instance.getConnectionsCount()
);
Metrics.gaugePerInstance(
"collaboration.documents_count",
// -1 adjustment because hook is called before document is removed
instance.getDocumentsCount() - 1
);
}
onChange({ documentName }: { documentName: string }) {
Metrics.increment("collaboration.change", { documentName });
}
onDestroy() {
Metrics.gaugePerInstance("collaboration.connections_count", 0);
Metrics.gaugePerInstance("collaboration.documents_count", 0);
}
}

View File

@@ -0,0 +1,60 @@
import {
onChangePayload,
onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
} from "@hocuspocus/server";
import Metrics from "@server/logging/metrics";
export default class Tracing {
onLoadDocument({ documentName, instance }: onLoadDocumentPayload) {
Metrics.increment("collaboration.load_document", {
documentName,
});
Metrics.gaugePerInstance(
"collaboration.documents_count",
instance.getDocumentsCount()
);
}
onAuthenticationFailed({ documentName }: { documentName: string }) {
Metrics.increment("collaboration.authentication_failed", {
documentName,
});
}
onConnect({ documentName, instance }: onConnectPayload) {
Metrics.increment("collaboration.connect", {
documentName,
});
Metrics.gaugePerInstance(
"collaboration.connections_count",
instance.getConnectionsCount()
);
}
onDisconnect({ documentName, instance }: onDisconnectPayload) {
Metrics.increment("collaboration.disconnect", {
documentName,
});
Metrics.gaugePerInstance(
"collaboration.connections_count",
instance.getConnectionsCount()
);
Metrics.gaugePerInstance(
"collaboration.documents_count", // -1 adjustment because hook is called before document is removed
instance.getDocumentsCount() - 1
);
}
onChange({ documentName }: onChangePayload) {
Metrics.increment("collaboration.change", {
documentName,
});
}
onDestroy() {
Metrics.gaugePerInstance("collaboration.connections_count", 0);
Metrics.gaugePerInstance("collaboration.documents_count", 0);
}
}

View File

@@ -1,13 +1,12 @@
// @flow
import { Node, Fragment } from "prosemirror-model";
import { parser, schema } from "rich-markdown-editor";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as Y from "yjs";
import embeds from "../../../shared/embeds";
import embeds from "@shared/embeds";
export default function markdownToYDoc(
markdown: string,
fieldName?: string = "default"
fieldName = "default"
): Y.Doc {
let node = parser.parse(markdown);
@@ -16,9 +15,11 @@ export default function markdownToYDoc(
// on the server we need to mimic this behavior.
function urlsToEmbeds(node: Node): Node {
if (node.type.name === "paragraph") {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'content' does not exist on type 'Fragmen... Remove this comment to see the full error message
for (const textNode of node.content.content) {
for (const embed of embeds) {
if (textNode.text && embed.matcher(textNode.text)) {
// @ts-expect-error ts-migrate(2322) FIXME: Type 'ProsemirrorNode<Schema<never, never>> | null... Remove this comment to see the full error message
return schema.nodes.embed.createAndFill({
href: textNode.text,
});
@@ -29,6 +30,7 @@ export default function markdownToYDoc(
if (node.content) {
const contentAsArray =
// @ts-expect-error ts-migrate(2339) FIXME: Property 'content' does not exist on type 'Fragmen... Remove this comment to see the full error message
node.content instanceof Fragment ? node.content.content : node.content;
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
}
@@ -37,6 +39,5 @@ export default function markdownToYDoc(
}
node = urlsToEmbeds(node);
return prosemirrorToYDoc(node, fieldName);
}

View File

@@ -1,27 +1,25 @@
// @flow
import { Collection, UserAuthentication } from "@server/models";
import { buildUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import mailer from "../mailer";
import { Collection, UserAuthentication } from "../models";
import { buildUser, buildTeam } from "../test/factories";
import { flushdb } from "../test/support";
import accountProvisioner from "./accountProvisioner";
jest.mock("../mailer");
jest.mock("aws-sdk", () => {
const mS3 = { putObject: jest.fn().mockReturnThis(), promise: jest.fn() };
const mS3 = {
putObject: jest.fn().mockReturnThis(),
promise: jest.fn(),
};
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => {
// $FlowFixMe
// @ts-expect-error ts-migrate(2339) FIXME: Property 'mockReset' does not exist on type '(type... Remove this comment to see the full error message
mailer.sendTemplate.mockReset();
return flushdb();
});
describe("accountProvisioner", () => {
const ip = "127.0.0.1";
@@ -49,10 +47,8 @@ describe("accountProvisioner", () => {
scopes: ["read"],
},
});
const authentications = await user.getAuthentications();
const auth = authentications[0];
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
expect(auth.scopes[0]).toEqual("read");
@@ -62,7 +58,6 @@ describe("accountProvisioner", () => {
expect(isNewUser).toEqual(true);
expect(isNewTeam).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
const collectionCount = await Collection.count();
expect(collectionCount).toEqual(1);
});
@@ -71,12 +66,13 @@ describe("accountProvisioner", () => {
const existingTeam = await buildTeam();
const providers = await existingTeam.getAuthenticationProviders();
const authenticationProvider = providers[0];
const existing = await buildUser({ teamId: existingTeam.id });
const existing = await buildUser({
teamId: existingTeam.id,
});
const authentications = await existing.getAuthentications();
const authentication = authentications[0];
const newEmail = "test@example.com";
const newUsername = "tname";
const { user, isNewUser, isNewTeam } = await accountProvisioner({
ip,
user: {
@@ -100,7 +96,6 @@ describe("accountProvisioner", () => {
scopes: ["read"],
},
});
const auth = await UserAuthentication.findByPk(authentication.id);
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
@@ -110,7 +105,6 @@ describe("accountProvisioner", () => {
expect(isNewTeam).toEqual(false);
expect(isNewUser).toEqual(false);
expect(mailer.sendTemplate).not.toHaveBeenCalled();
const collectionCount = await Collection.count();
expect(collectionCount).toEqual(0);
});
@@ -119,9 +113,12 @@ describe("accountProvisioner", () => {
const existingTeam = await buildTeam();
const providers = await existingTeam.getAuthenticationProviders();
const authenticationProvider = providers[0];
await authenticationProvider.update({ enabled: false });
const existing = await buildUser({ teamId: existingTeam.id });
await authenticationProvider.update({
enabled: false,
});
const existing = await buildUser({
teamId: existingTeam.id,
});
const authentications = await existing.getAuthentications();
const authentication = authentications[0];
let error;
@@ -160,7 +157,6 @@ describe("accountProvisioner", () => {
const team = await buildTeam();
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProvider = authenticationProviders[0];
const { user, isNewUser } = await accountProvisioner({
ip,
user: {
@@ -184,10 +180,8 @@ describe("accountProvisioner", () => {
scopes: ["read"],
},
});
const authentications = await user.getAuthentications();
const auth = authentications[0];
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
expect(auth.scopes[0]).toEqual("read");
@@ -195,7 +189,6 @@ describe("accountProvisioner", () => {
expect(user.username).toEqual("jtester");
expect(isNewUser).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
// should provision welcome collection
const collectionCount = await Collection.count();
expect(collectionCount).toEqual(1);

View File

@@ -1,48 +1,49 @@
// @flow
import invariant from "invariant";
import Sequelize from "sequelize";
import { Collection, Team, User } from "@server/models";
import {
AuthenticationError,
EmailAuthenticationRequiredError,
AuthenticationProviderDisabledError,
} from "../errors";
import mailer from "../mailer";
import { Collection, Team, User } from "../models";
import teamCreator from "./teamCreator";
import userCreator from "./userCreator";
type Props = {|
ip: string,
user: {|
name: string,
email: string,
avatarUrl?: string,
username?: string,
|},
team: {|
name: string,
domain?: string,
subdomain: string,
avatarUrl?: string,
|},
authenticationProvider: {|
name: string,
providerId: string,
|},
authentication: {|
providerId: string,
scopes: string[],
accessToken?: string,
refreshToken?: string,
|},
|};
type Props = {
ip: string;
user: {
name: string;
email: string;
avatarUrl?: string;
username?: string;
};
team: {
name: string;
domain?: string;
subdomain: string;
avatarUrl?: string;
};
authenticationProvider: {
name: string;
providerId: string;
};
authentication: {
providerId: string;
scopes: string[];
accessToken?: string;
refreshToken?: string;
};
};
export type AccountProvisionerResult = {|
user: User,
team: Team,
isNewTeam: boolean,
isNewUser: boolean,
|};
export type AccountProvisionerResult = {
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
team: Team;
isNewTeam: boolean;
isNewUser: boolean;
};
export default async function accountProvisioner({
ip,
@@ -52,6 +53,7 @@ export default async function accountProvisioner({
authentication: authenticationParams,
}: Props): Promise<AccountProvisionerResult> {
let result;
try {
result = await teamCreator({
name: teamParams.name,
@@ -61,14 +63,14 @@ export default async function accountProvisioner({
authenticationProvider: authenticationProviderParams,
});
} catch (err) {
throw new AuthenticationError(err.message);
throw AuthenticationError(err.message);
}
invariant(result, "Team creator result must exist");
const { authenticationProvider, team, isNewTeam } = result;
if (!authenticationProvider.enabled) {
throw new AuthenticationProviderDisabledError();
throw AuthenticationProviderDisabledError();
}
try {
@@ -85,7 +87,6 @@ export default async function accountProvisioner({
authenticationProviderId: authenticationProvider.id,
},
});
const { isNewUser, user } = result;
if (isNewUser) {
@@ -102,7 +103,11 @@ export default async function accountProvisioner({
// failed. In this case we have a valid previously created team but no
// onboarding collection.
if (!isNewTeam) {
const count = await Collection.count({ where: { teamId: team.id } });
const count = await Collection.count({
where: {
teamId: team.id,
},
});
provision = count === 0;
}
@@ -127,12 +132,12 @@ export default async function accountProvisioner({
});
if (exists) {
throw new EmailAuthenticationRequiredError(
throw EmailAuthenticationRequiredError(
"Email authentication required",
team.url
);
} else {
throw new AuthenticationError(err.message, team.url);
throw AuthenticationError(err.message, team.url);
}
}

View File

@@ -1,7 +1,6 @@
// @flow
import { v4 as uuidv4 } from "uuid";
import { Attachment, Event, User } from "../models";
import { uploadToS3FromBuffer } from "../utils/s3";
import { Attachment, Event, User } from "@server/models";
import { uploadToS3FromBuffer } from "@server/utils/s3";
export default async function attachmentCreator({
name,
@@ -11,17 +10,17 @@ export default async function attachmentCreator({
source,
ip,
}: {
name: string,
type: string,
buffer: Buffer,
user: User,
source?: "import",
ip: string,
name: string;
type: string;
buffer: Buffer;
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
source?: "import";
ip: string;
}) {
const key = `uploads/${user.id}/${uuidv4()}/${name}`;
const acl = process.env.AWS_S3_ACL || "private";
const url = await uploadToS3FromBuffer(buffer, type, key, acl);
const attachment = await Attachment.create({
key,
acl,
@@ -31,15 +30,16 @@ export default async function attachmentCreator({
teamId: user.teamId,
userId: user.id,
});
await Event.create({
name: "attachments.create",
data: { name, source },
data: {
name,
source,
},
modelId: attachment.id,
teamId: user.teamId,
actorId: user.id,
ip,
});
return attachment;
}

View File

@@ -1,6 +1,5 @@
// @flow
import { Collection, Event, Team, User, FileOperation } from "../models";
import { getAWSKeyForFileOp } from "../utils/s3";
import { Collection, Event, Team, User, FileOperation } from "@server/models";
import { getAWSKeyForFileOp } from "@server/utils/s3";
export default async function collectionExporter({
collection,
@@ -8,14 +7,16 @@ export default async function collectionExporter({
user,
ip,
}: {
collection?: Collection,
team: Team,
user: User,
ip: string,
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
collection?: Collection;
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
team: Team;
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
ip: string;
}) {
const collectionId = collection?.id;
const key = getAWSKeyForFileOp(user.teamId, collection?.name || team.name);
const fileOperation = await FileOperation.create({
type: "export",
state: "creating",
@@ -26,7 +27,6 @@ export default async function collectionExporter({
userId: user.id,
teamId: user.teamId,
});
// Event is consumed on worker in queues/processors/exports
await Event.create({
name: collection ? "collections.export" : "collections.export_all",
@@ -36,9 +36,7 @@ export default async function collectionExporter({
modelId: fileOperation.id,
ip,
});
fileOperation.user = user;
fileOperation.collection = collection;
return fileOperation;
}

View File

@@ -1,15 +1,13 @@
// @flow
import path from "path";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'form... Remove this comment to see the full error message
import File from "formidable/lib/file";
import { Attachment, Document, Collection } from "../models";
import { buildUser } from "../test/factories";
import { flushdb } from "../test/support";
import { Attachment, Document, Collection } from "@server/models";
import { buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import collectionImporter from "./collectionImporter";
jest.mock("../utils/s3");
beforeEach(() => flushdb());
describe("collectionImporter", () => {
const ip = "127.0.0.1";
@@ -21,18 +19,15 @@ describe("collectionImporter", () => {
type: "application/zip",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await collectionImporter({
type: "outline",
user,
file,
ip,
});
expect(response.collections.length).toEqual(1);
expect(response.documents.length).toEqual(8);
expect(response.attachments.length).toEqual(6);
expect(await Collection.count()).toEqual(1);
expect(await Document.count()).toEqual(8);
expect(await Attachment.count()).toEqual(6);
@@ -46,8 +41,8 @@ describe("collectionImporter", () => {
type: "application/zip",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
let error;
try {
await collectionImporter({
type: "outline",
@@ -70,8 +65,8 @@ describe("collectionImporter", () => {
type: "application/zip",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
let error;
try {
await collectionImporter({
type: "outline",

View File

@@ -1,15 +1,15 @@
// @flow
import fs from "fs";
import os from "os";
import path from "path";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'form... Remove this comment to see the full error message
import File from "formidable/lib/file";
import invariant from "invariant";
import { values, keys } from "lodash";
import { v4 as uuidv4 } from "uuid";
import { parseOutlineExport } from "../../shared/utils/zip";
import { parseOutlineExport } from "@shared/utils/zip";
import Logger from "@server/logging/logger";
import { Attachment, Event, Document, Collection, User } from "@server/models";
import { FileImportError } from "../errors";
import Logger from "../logging/logger";
import { Attachment, Event, Document, Collection, User } from "../models";
import attachmentCreator from "./attachmentCreator";
import documentCreator from "./documentCreator";
import documentImporter from "./documentImporter";
@@ -20,36 +20,48 @@ export default async function collectionImporter({
user,
ip,
}: {
file: File,
user: User,
type: "outline",
ip: string,
file: File;
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
type: "outline";
ip: string;
}) {
// load the zip structure into memory
const zipData = await fs.promises.readFile(file.path);
let items;
try {
items = await await parseOutlineExport(zipData);
} catch (err) {
throw new FileImportError(err.message);
throw FileImportError(err.message);
}
if (!items.filter((item) => item.type === "document").length) {
throw new FileImportError(
throw FileImportError(
"Uploaded file does not contain importable documents"
);
}
// store progress and pointers
let collections: { string: Collection } = {};
let documents: { string: Document } = {};
let attachments: { string: Attachment } = {};
// @ts-expect-error ts-migrate(2741) FIXME: Property 'string' is missing in type '{}' but requ... Remove this comment to see the full error message
const collections: {
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
string: Collection;
} = {};
// @ts-expect-error ts-migrate(2741) FIXME: Property 'string' is missing in type '{}' but requ... Remove this comment to see the full error message
const documents: {
string: Document;
} = {};
// @ts-expect-error ts-migrate(2741) FIXME: Property 'string' is missing in type '{}' but requ... Remove this comment to see the full error message
const attachments: {
// @ts-expect-error ts-migrate(2749) FIXME: 'Attachment' refers to a value, but is being used ... Remove this comment to see the full error message
string: Attachment;
} = {};
for (const item of items) {
if (item.type === "collection") {
// check if collection with name exists
let [collection, isCreated] = await Collection.findOrCreate({
const [collection, isCreated] = await Collection.findOrCreate({
where: {
teamId: user.teamId,
name: item.name,
@@ -65,6 +77,7 @@ export default async function collectionImporter({
// with right now
if (!isCreated) {
const name = `${item.name} (Imported)`;
// @ts-expect-error ts-migrate(2588) FIXME: Cannot assign to 'collection' because it is a cons... Remove this comment to see the full error message
collection = await Collection.create({
teamId: user.teamId,
createdById: user.id,
@@ -76,7 +89,9 @@ export default async function collectionImporter({
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { name },
data: {
name,
},
ip,
});
}
@@ -89,30 +104,26 @@ export default async function collectionImporter({
const collectionDir = item.dir.split("/")[0];
const collection = collections[collectionDir];
invariant(collection, `Collection must exist for document ${item.dir}`);
// we have a document
const content = await item.item.async("string");
const name = path.basename(item.name);
const tmpDir = os.tmpdir();
const tmpFilePath = `${tmpDir}/upload-${uuidv4()}`;
await fs.promises.writeFile(tmpFilePath, content);
const file = new File({
name,
type: "text/markdown",
path: tmpFilePath,
});
const { text, title } = await documentImporter({
file,
user,
ip,
});
await fs.promises.unlink(tmpFilePath);
// must be a nested document, find and reference the parent document
let parentDocumentId;
if (item.depth > 1) {
const parentDocument =
documents[`${item.dir}.md`] || documents[item.dir];
@@ -128,13 +139,14 @@ export default async function collectionImporter({
collectionId: collection.id,
createdAt: item.metadata.createdAt
? new Date(item.metadata.createdAt)
: item.date,
: // @ts-expect-error ts-migrate(2339) FIXME: Property 'date' does not exist on type 'Item'.
item.date,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'date' does not exist on type 'Item'.
updatedAt: item.date,
parentDocumentId,
user,
ip,
});
documents[item.path] = document;
continue;
}
@@ -168,14 +180,16 @@ export default async function collectionImporter({
/(.*)uploads\//,
"uploads/"
);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
document.text = document.text
.replace(attachmentPath, attachment.redirectUrl)
.replace(normalizedAttachmentPath, attachment.redirectUrl)
.replace(`/${normalizedAttachmentPath}`, attachment.redirectUrl);
// does nothing if the document text is unchanged
await document.save({ fields: ["text"] });
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
await document.save({
fields: ["text"],
});
}
}

View File

@@ -1,5 +1,4 @@
// @flow
import { Document, Event, User } from "../models";
import { Document, Event, User } from "@server/models";
export default async function documentCreator({
title = "",
@@ -8,7 +7,8 @@ export default async function documentCreator({
collectionId,
parentDocumentId,
templateDocument,
createdAt, // allows override for import
createdAt,
// allows override for import
updatedAt,
template,
index,
@@ -17,23 +17,26 @@ export default async function documentCreator({
source,
ip,
}: {
title: string,
text: string,
publish?: boolean,
collectionId: string,
parentDocumentId?: string,
templateDocument?: Document,
template?: boolean,
createdAt?: Date,
updatedAt?: Date,
index?: number,
user: User,
editorVersion?: string,
source?: "import",
ip: string,
title: string;
text: string;
publish?: boolean;
collectionId: string;
parentDocumentId?: string;
templateDocument?: Document;
template?: boolean;
createdAt?: Date;
updatedAt?: Date;
index?: number;
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
editorVersion?: string;
source?: "import";
ip: string;
// @ts-expect-error ts-migrate(1064) FIXME: The return type of an async function or method mus... Remove this comment to see the full error message
}): Document {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
const templateId = templateDocument ? templateDocument.id : undefined;
let document = await Document.create({
const document = await Document.create({
parentDocumentId,
editorVersion,
collectionId,
@@ -46,29 +49,35 @@ export default async function documentCreator({
template,
templateId,
title: templateDocument ? templateDocument.title : title,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
text: templateDocument ? templateDocument.text : text,
});
await Event.create({
name: "documents.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { source, title: document.title, templateId },
data: {
source,
title: document.title,
templateId,
},
ip,
});
if (publish) {
await document.publish(user.id);
await Event.create({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { source, title: document.title },
data: {
source,
title: document.title,
},
ip,
});
}
@@ -77,6 +86,9 @@ export default async function documentCreator({
// we need to specify publishedAt to bypass default scope that only returns
// published documents
return Document.findOne({
where: { id: document.id, publishedAt: document.publishedAt },
where: {
id: document.id,
publishedAt: document.publishedAt,
},
});
}

View File

@@ -1,15 +1,13 @@
// @flow
import path from "path";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'form... Remove this comment to see the full error message
import File from "formidable/lib/file";
import { Attachment } from "../models";
import { buildUser } from "../test/factories";
import { flushdb } from "../test/support";
import { Attachment } from "@server/models";
import { buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import documentImporter from "./documentImporter";
jest.mock("../utils/s3");
beforeEach(() => flushdb());
describe("documentImporter", () => {
const ip = "127.0.0.1";
@@ -22,16 +20,13 @@ describe("documentImporter", () => {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await documentImporter({
user,
file,
ip,
});
const attachments = await Attachment.count();
expect(attachments).toEqual(1);
expect(response.text).toContain("This is a test document for images");
expect(response.text).toContain("![](/api/attachments.redirect?id=");
expect(response.title).toEqual("images");
@@ -45,16 +40,13 @@ describe("documentImporter", () => {
type: "application/octet-stream",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await documentImporter({
user,
file,
ip,
});
const attachments = await Attachment.count();
expect(attachments).toEqual(1);
expect(response.text).toContain("This is a test document for images");
expect(response.text).toContain("![](/api/attachments.redirect?id=");
expect(response.title).toEqual("images");
@@ -68,8 +60,8 @@ describe("documentImporter", () => {
type: "application/octet-stream",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
let error;
try {
await documentImporter({
user,
@@ -91,16 +83,13 @@ describe("documentImporter", () => {
type: "application/octet-stream",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await documentImporter({
user,
file,
ip,
});
const attachments = await Attachment.count();
expect(attachments).toEqual(1);
expect(response.text).toContain("This is a test document for images");
expect(response.text).toContain("![](/api/attachments.redirect?id=");
expect(response.title).toEqual("images");
@@ -114,13 +103,11 @@ describe("documentImporter", () => {
type: "text/html",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await documentImporter({
user,
file,
ip,
});
expect(response.text).toContain("Text paragraph");
expect(response.title).toEqual("Heading 1");
});
@@ -133,13 +120,11 @@ describe("documentImporter", () => {
type: "application/msword",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await documentImporter({
user,
file,
ip,
});
expect(response.text).toContain("this is a test document");
expect(response.title).toEqual("Heading 1");
});
@@ -152,13 +137,11 @@ describe("documentImporter", () => {
type: "text/plain",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await documentImporter({
user,
file,
ip,
});
expect(response.text).toContain("This is a test paragraph");
expect(response.title).toEqual("Heading 1");
});
@@ -171,13 +154,11 @@ describe("documentImporter", () => {
type: "text/plain",
path: path.resolve(__dirname, "..", "test", "fixtures", "empty.md"),
});
const response = await documentImporter({
user,
file,
ip,
});
expect(response.text).toContain("");
expect(response.title).toEqual("this / and / this");
});
@@ -190,13 +171,11 @@ describe("documentImporter", () => {
type: "application/lol",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
const response = await documentImporter({
user,
file,
ip,
});
expect(response.text).toContain("This is a test paragraph");
expect(response.title).toEqual("Heading 1");
});
@@ -209,8 +188,8 @@ describe("documentImporter", () => {
type: "executable/zip",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
let error;
try {
await documentImporter({
user,

View File

@@ -1,18 +1,19 @@
// @flow
import fs from "fs";
import path from "path";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'form... Remove this comment to see the full error message
import File from "formidable/lib/file";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'jopl... Remove this comment to see the full error message
import { strikethrough, tables } from "joplin-turndown-plugin-gfm";
import mammoth from "mammoth";
import quotedPrintable from "quoted-printable";
import TurndownService from "turndown";
import utf8 from "utf8";
import parseTitle from "../../shared/utils/parseTitle";
import parseTitle from "@shared/utils/parseTitle";
import { User } from "@server/models";
import dataURItoBuffer from "@server/utils/dataURItoBuffer";
import { deserializeFilename } from "@server/utils/fs";
import parseImages from "@server/utils/parseImages";
import { FileImportError, InvalidRequestError } from "../errors";
import { User } from "../models";
import dataURItoBuffer from "../utils/dataURItoBuffer";
import { deserializeFilename } from "../utils/fs";
import parseImages from "../utils/parseImages";
import attachmentCreator from "./attachmentCreator";
// https://github.com/domchristie/turndown#options
@@ -21,7 +22,6 @@ const turndownService = new TurndownService({
bulletListMarker: "-",
headingStyle: "atx",
});
// Use the GitHub-flavored markdown plugin to parse
// strikethoughs and tables
turndownService
@@ -33,7 +33,6 @@ turndownService
return "\n";
},
});
interface ImportableFile {
type: string;
getMarkdown: (file: any) => Promise<string>;
@@ -67,20 +66,24 @@ const importMapping: ImportableFile[] = [
},
];
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'file' implicitly has an 'any' type.
async function fileToMarkdown(file): Promise<string> {
return fs.promises.readFile(file.path, "utf8");
}
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'file' implicitly has an 'any' type.
async function docxToMarkdown(file): Promise<string> {
const { value } = await mammoth.convertToHtml(file);
return turndownService.turndown(value);
}
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'file' implicitly has an 'any' type.
async function htmlToMarkdown(file): Promise<string> {
const value = await fs.promises.readFile(file.path, "utf8");
return turndownService.turndown(value);
}
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'file' implicitly has an 'any' type.
async function confluenceToMarkdown(file): Promise<string> {
let value = await fs.promises.readFile(file.path, "utf8");
@@ -88,13 +91,14 @@ async function confluenceToMarkdown(file): Promise<string> {
// Word documents should call into the docxToMarkdown importer.
// See: https://jira.atlassian.com/browse/CONFSERVER-38237
if (!value.includes("Content-Type: multipart/related")) {
throw new FileImportError("Unsupported Word file");
throw FileImportError("Unsupported Word file");
}
// get boundary marker
const boundaryMarker = value.match(/boundary="(.+)"/);
if (!boundaryMarker) {
throw new FileImportError("Unsupported Word file (No boundary marker)");
throw FileImportError("Unsupported Word file (No boundary marker)");
}
// get content between multipart boundaries
@@ -104,6 +108,7 @@ async function confluenceToMarkdown(file): Promise<string> {
boundaryReached++;
return false;
}
if (line.startsWith("Content-")) {
return false;
}
@@ -114,11 +119,12 @@ async function confluenceToMarkdown(file): Promise<string> {
if (boundaryReached === 2) {
return true;
}
return false;
});
if (!lines.length) {
throw new FileImportError("Unsupported Word file (No content found)");
throw FileImportError("Unsupported Word file (No content found)");
}
// Mime attachment is "quoted printable" encoded, must be decoded first
@@ -127,11 +133,10 @@ async function confluenceToMarkdown(file): Promise<string> {
// If we don't remove the title here it becomes printed in the document
// body by turndown
turndownService.remove(["style", "xml", "title"]);
turndownService.remove(["style", "title"]);
// Now we should have something that looks like HTML
const html = turndownService.turndown(value);
return html.replace(/<br>/g, " \\n ");
}
@@ -140,10 +145,14 @@ export default async function documentImporter({
user,
ip,
}: {
user: User,
file: File,
ip: string,
}): Promise<{ text: string, title: string }> {
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
file: File;
ip: string;
}): Promise<{
text: string;
title: string;
}> {
const fileInfo = importMapping.filter((item) => {
if (item.type === file.type) {
if (
@@ -152,17 +161,21 @@ export default async function documentImporter({
) {
return false;
}
return true;
}
if (item.type === "text/markdown" && path.extname(file.name) === ".md") {
return true;
}
return false;
})[0];
if (!fileInfo) {
throw new InvalidRequestError(`File type ${file.type} not supported`);
throw InvalidRequestError(`File type ${file.type} not supported`);
}
let title = deserializeFilename(file.name.replace(/\.[^/.]+$/, ""));
let text = await fileInfo.getMarkdown(file);
@@ -181,7 +194,6 @@ export default async function documentImporter({
for (const uri of dataURIs) {
const name = "imported";
const { buffer, type } = dataURItoBuffer(uri);
const attachment = await attachmentCreator({
name,
type,
@@ -189,9 +201,11 @@ export default async function documentImporter({
user,
ip,
});
text = text.replace(uri, attachment.redirectUrl);
}
return { text, title };
return {
text,
title,
};
}

View File

@@ -1,180 +0,0 @@
// @flow
import { Document, Attachment, Collection, User, Event } from "../models";
import { sequelize } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
async function copyAttachments(document: Document, options) {
let text = document.text;
const documentId = document.id;
// find any image attachments that are in this documents text
const attachmentIds = parseAttachmentIds(text);
for (const id of attachmentIds) {
const existing = await Attachment.findOne({
where: {
teamId: document.teamId,
id,
},
});
// if the image attachment was originally uploaded to another document
// (this can happen in various ways, copy/paste, or duplicate for example)
// then create a new attachment pointed to this doc and update the reference
// in the text so that it gets the moved documents permissions
if (existing && existing.documentId !== documentId) {
const { id, ...rest } = existing.dataValues;
const attachment = await Attachment.create(
{
...rest,
documentId,
},
options
);
text = text.replace(existing.redirectUrl, attachment.redirectUrl);
}
}
return text;
}
export default async function documentMover({
user,
document,
collectionId,
parentDocumentId = null, // convert undefined to null so parentId comparison treats them as equal
index,
ip,
}: {
user: User,
document: Document,
collectionId: string,
parentDocumentId?: string,
index?: number,
ip: string,
}) {
let transaction;
const collectionChanged = collectionId !== document.collectionId;
const result = { collections: [], documents: [], collectionChanged };
if (document.template) {
if (!collectionChanged) {
return result;
}
document.collectionId = collectionId;
document.parentDocumentId = null;
document.lastModifiedById = user.id;
document.updatedBy = user;
await document.save();
result.documents.push(document);
} else {
try {
transaction = await sequelize.transaction();
// remove from original collection
const collection = await Collection.findByPk(document.collectionId, {
transaction,
paranoid: false,
});
const [
documentJson,
fromIndex,
] = (await collection.removeDocumentInStructure(document, {
save: false,
})) || [undefined, index];
// if we're reordering from within the same parent
// the original and destination collection are the same,
// so when the initial item is removed above, the list will reduce by 1.
// We need to compensate for this when reordering
const toIndex =
index !== undefined &&
document.parentDocumentId === parentDocumentId &&
document.collectionId === collectionId &&
fromIndex < index
? index - 1
: index;
// if the collection is the same then it will get saved below, this
// line prevents a pointless intermediate save from occurring.
if (collectionChanged) {
await collection.save({ transaction });
document.text = await copyAttachments(document, { transaction });
}
// add to new collection (may be the same)
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
document.lastModifiedById = user.id;
document.updatedBy = user;
const newCollection: Collection = collectionChanged
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction })
: collection;
await newCollection.addDocumentToStructure(document, toIndex, {
documentJson,
});
result.collections.push(collection);
// if collection does not remain the same loop through children and change their
// collectionId and move any attachments they may have too. This includes
// archived children, otherwise their collection would be wrong once restored.
if (collectionChanged) {
result.collections.push(newCollection);
const loopChildren = async (documentId) => {
const childDocuments = await Document.findAll({
where: { parentDocumentId: documentId },
});
await Promise.all(
childDocuments.map(async (child) => {
await loopChildren(child.id);
child.text = await copyAttachments(child, { transaction });
child.collectionId = collectionId;
await child.save();
child.collection = newCollection;
result.documents.push(child);
})
);
};
await loopChildren(document.id);
}
await document.save({ transaction });
document.collection = newCollection;
result.documents.push(document);
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
}
await Event.create({
name: "documents.move",
actorId: user.id,
documentId: document.id,
collectionId,
teamId: document.teamId,
data: {
title: document.title,
collectionIds: result.collections.map((c) => c.id),
documentIds: result.documents.map((d) => d.id),
},
ip,
});
// we need to send all updated models back to the client
return result;
}

View File

@@ -1,47 +1,45 @@
// @flow
import { Attachment } from "../models";
import { Attachment } from "@server/models";
import {
buildDocument,
buildAttachment,
buildCollection,
buildUser,
} from "../test/factories";
import { flushdb, seed } from "../test/support";
import parseAttachmentIds from "../utils/parseAttachmentIds";
} from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import documentMover from "./documentMover";
beforeEach(() => flushdb());
describe("documentMover", () => {
const ip = "127.0.0.1";
it("should move within a collection", async () => {
const { document, user, collection } = await seed();
const response = await documentMover({
user,
document,
collectionId: collection.id,
ip,
});
expect(response.collections.length).toEqual(1);
expect(response.documents.length).toEqual(1);
});
it("should not error when not in source collection documentStructure", async () => {
const user = await buildUser();
const collection = await buildCollection({ teamId: user.teamId });
const document = await buildDocument({ collectionId: collection.id });
const collection = await buildCollection({
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
});
await document.archive();
const response = await documentMover({
user,
document,
collectionId: collection.id,
ip,
});
expect(response.collections.length).toEqual(1);
expect(response.documents.length).toEqual(1);
});
@@ -57,7 +55,6 @@ describe("documentMover", () => {
text: "content",
});
await collection.addDocumentToStructure(newDocument);
const response = await documentMover({
user,
document,
@@ -66,12 +63,13 @@ describe("documentMover", () => {
index: 0,
ip,
});
// @ts-expect-error ts-migrate(2339) FIXME: Property 'documentStructure' does not exist on typ... Remove this comment to see the full error message
expect(response.collections[0].documentStructure[0].children[0].id).toBe(
newDocument.id
);
expect(response.collections.length).toEqual(1);
expect(response.documents.length).toEqual(1);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'neve... Remove this comment to see the full error message
expect(response.documents[0].collection.id).toEqual(collection.id);
});
@@ -89,7 +87,6 @@ describe("documentMover", () => {
text: "content",
});
await collection.addDocumentToStructure(newDocument);
const response = await documentMover({
user,
document,
@@ -98,23 +95,25 @@ describe("documentMover", () => {
index: 0,
ip,
});
// check document ids where updated
await newDocument.reload();
expect(newDocument.collectionId).toBe(newCollection.id);
await document.reload();
expect(document.collectionId).toBe(newCollection.id);
// check collection structure updated
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
expect(response.collections[0].id).toBe(collection.id);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
expect(response.collections[1].id).toBe(newCollection.id);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'documentStructure' does not exist on typ... Remove this comment to see the full error message
expect(response.collections[1].documentStructure[0].children[0].id).toBe(
newDocument.id
);
expect(response.collections.length).toEqual(2);
expect(response.documents.length).toEqual(2);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'neve... Remove this comment to see the full error message
expect(response.documents[0].collection.id).toEqual(newCollection.id);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'neve... Remove this comment to see the full error message
expect(response.documents[1].collection.id).toEqual(newCollection.id);
});
@@ -136,7 +135,6 @@ describe("documentMover", () => {
text: `content ![attachment](${attachment.redirectUrl})`,
});
await collection.addDocumentToStructure(newDocument);
await documentMover({
user,
document,
@@ -145,17 +143,14 @@ describe("documentMover", () => {
index: 0,
ip,
});
// check document ids where updated
await newDocument.reload();
expect(newDocument.collectionId).toBe(newCollection.id);
// check new attachment was created pointint to same key
const attachmentIds = parseAttachmentIds(newDocument.text);
const newAttachment = await Attachment.findByPk(attachmentIds[0]);
expect(newAttachment.documentId).toBe(newDocument.id);
expect(newAttachment.key).toBe(attachment.key);
await document.reload();
expect(document.collectionId).toBe(newCollection.id);
});

View File

@@ -0,0 +1,222 @@
import { Document, Attachment, Collection, User, Event } from "@server/models";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { sequelize } from "../sequelize";
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'options' implicitly has an 'any' type.
async function copyAttachments(document: Document, options) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
let text = document.text;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
const documentId = document.id;
// find any image attachments that are in this documents text
const attachmentIds = parseAttachmentIds(text);
for (const id of attachmentIds) {
const existing = await Attachment.findOne({
where: {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
teamId: document.teamId,
id,
},
});
// if the image attachment was originally uploaded to another document
// (this can happen in various ways, copy/paste, or duplicate for example)
// then create a new attachment pointed to this doc and update the reference
// in the text so that it gets the moved documents permissions
if (existing && existing.documentId !== documentId) {
const { id, ...rest } = existing.dataValues;
const attachment = await Attachment.create(
{ ...rest, documentId },
options
);
text = text.replace(existing.redirectUrl, attachment.redirectUrl);
}
}
return text;
}
export default async function documentMover({
user,
document,
collectionId,
// @ts-expect-error ts-migrate(2322) FIXME: Type 'null' is not assignable to type 'string'.
parentDocumentId = null,
// convert undefined to null so parentId comparison treats them as equal
index,
ip,
}: {
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
document: Document;
collectionId: string;
parentDocumentId?: string;
index?: number;
ip: string;
}) {
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'transaction' implicitly has type 'any' i... Remove this comment to see the full error message
let transaction;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
const collectionChanged = collectionId !== document.collectionId;
const result = {
collections: [],
documents: [],
collectionChanged,
};
// @ts-expect-error ts-migrate(2339) FIXME: Property 'template' does not exist on type 'Docume... Remove this comment to see the full error message
if (document.template) {
if (!collectionChanged) {
return result;
}
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
document.collectionId = collectionId;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
document.parentDocumentId = null;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message
document.lastModifiedById = user.id;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message
document.updatedBy = user;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
await document.save();
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message
result.documents.push(document);
} else {
try {
transaction = await sequelize.transaction();
// remove from original collection
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
const collection = await Collection.findByPk(document.collectionId, {
transaction,
paranoid: false,
});
const [
documentJson,
fromIndex,
] = (await collection.removeDocumentInStructure(document, {
save: false,
})) || [undefined, index];
// if we're reordering from within the same parent
// the original and destination collection are the same,
// so when the initial item is removed above, the list will reduce by 1.
// We need to compensate for this when reordering
const toIndex =
index !== undefined &&
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
document.parentDocumentId === parentDocumentId &&
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
document.collectionId === collectionId &&
fromIndex < index
? index - 1
: index;
// if the collection is the same then it will get saved below, this
// line prevents a pointless intermediate save from occurring.
if (collectionChanged) {
await collection.save({
transaction,
});
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
document.text = await copyAttachments(document, {
transaction,
});
}
// add to new collection (may be the same)
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
document.collectionId = collectionId;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
document.parentDocumentId = parentDocumentId;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message
document.lastModifiedById = user.id;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message
document.updatedBy = user;
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
const newCollection: Collection = collectionChanged
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, {
transaction,
})
: collection;
await newCollection.addDocumentToStructure(document, toIndex, {
documentJson,
});
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
result.collections.push(collection);
// if collection does not remain the same loop through children and change their
// collectionId and move any attachments they may have too. This includes
// archived children, otherwise their collection would be wrong once restored.
if (collectionChanged) {
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
result.collections.push(newCollection);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'documentId' implicitly has an 'any' typ... Remove this comment to see the full error message
const loopChildren = async (documentId) => {
const childDocuments = await Document.findAll({
where: {
parentDocumentId: documentId,
},
});
await Promise.all(
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'child' implicitly has an 'any' type.
childDocuments.map(async (child) => {
await loopChildren(child.id);
child.text = await copyAttachments(child, {
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'transaction' implicitly has an 'any' typ... Remove this comment to see the full error message
transaction,
});
child.collectionId = collectionId;
await child.save();
child.collection = newCollection;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
result.documents.push(child);
})
);
};
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
await loopChildren(document.id);
}
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
await document.save({
transaction,
});
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message
document.collection = newCollection;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message
result.documents.push(document);
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
}
await Event.create({
name: "documents.move",
actorId: user.id,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
documentId: document.id,
collectionId,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
teamId: document.teamId,
data: {
title: document.title,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
collectionIds: result.collections.map((c) => c.id),
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
documentIds: result.documents.map((d) => d.id),
},
ip,
});
// we need to send all updated models back to the client
return result;
}

View File

@@ -1,39 +1,41 @@
// @flow
import { subDays } from "date-fns";
import { Attachment, Document } from "../models";
import { buildAttachment, buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
import { Attachment, Document } from "@server/models";
import { buildAttachment, buildDocument } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import documentPermanentDeleter from "./documentPermanentDeleter";
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
const mS3 = {
deleteObject: jest.fn().mockReturnThis(),
promise: jest.fn(),
};
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(0);
});
it("should error when trying to destroy undeleted documents", async () => {
const document = await buildDocument({
publishedAt: new Date(),
});
let error;
try {
await documentPermanentDeleter([document]);
} catch (err) {
@@ -50,20 +52,20 @@ describe("documentPermanentDeleter", () => {
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(0);
});
it("should handle unknown attachment ids", async () => {
@@ -71,53 +73,51 @@ describe("documentPermanentDeleter", () => {
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
// remove attachment so it no longer exists in the database, this is also
// representative of a corrupt attachment id in the doc or the regex returning
// an incorrect string
await attachment.destroy({ force: true });
await attachment.destroy({
force: true,
});
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(0);
});
it("should not destroy attachments referenced in other documents", async () => {
const document1 = await buildDocument();
const document = await buildDocument({
teamId: document1.teamId,
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document1.teamId,
documentId: document.id,
});
document1.text = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
expect(await Attachment.count()).toEqual(1);
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(1);
});
});

View File

@@ -1,14 +1,15 @@
// @flow
import Logger from "../logging/logger";
import { Document, Attachment } from "../models";
import Logger from "@server/logging/logger";
import { Document, Attachment } from "@server/models";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { sequelize } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
export default async function documentPermanentDeleter(documents: Document[]) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'deletedAt' does not exist on type 'Docum... Remove this comment to see the full error message
const activeDocument = documents.find((doc) => !doc.deletedAt);
if (activeDocument) {
throw new Error(
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
`Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.`
);
}
@@ -22,13 +23,16 @@ export default async function documentPermanentDeleter(documents: Document[]) {
`;
for (const document of documents) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
const attachmentIds = parseAttachmentIds(document.text);
for (const attachmentId of attachmentIds) {
const [{ count }] = await sequelize.query(query, {
type: sequelize.QueryTypes.SELECT,
replacements: {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
documentId: document.id,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
teamId: document.teamId,
query: attachmentId,
},
@@ -37,6 +41,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
if (parseInt(count) === 0) {
const attachment = await Attachment.findOne({
where: {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
teamId: document.teamId,
id: attachmentId,
},
@@ -44,7 +49,6 @@ export default async function documentPermanentDeleter(documents: Document[]) {
if (attachment) {
await attachment.destroy();
Logger.info("commands", `Attachment ${attachmentId} deleted`);
} else {
Logger.info("commands", `Unknown attachment ${attachmentId} ignored`);
@@ -55,6 +59,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
return Document.scope("withUnpublished").destroy({
where: {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
id: documents.map((document) => document.id),
},
force: true,

View File

@@ -1,25 +1,24 @@
// @flow
import { uniq } from "lodash";
import { Node } from "prosemirror-model";
import { schema, serializer } from "rich-markdown-editor";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import * as Y from "yjs";
import { Document, Event } from "../models";
import { Document, Event } from "@server/models";
export default async function documentUpdater({
documentId,
ydoc,
userId,
}: {
documentId: string,
ydoc: Y.Doc,
userId?: string,
documentId: string;
ydoc: Y.Doc;
userId?: string;
}) {
const document = await Document.findByPk(documentId);
const state = Y.encodeStateAsUpdate(ydoc);
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
const text = serializer.serialize(node);
const isUnchanged = document.text === text;
const hasMultiplayerState = !!document.state;
@@ -32,7 +31,6 @@ export default async function documentUpdater({
const pudIds = Array.from(pud.clients.values());
const existingIds = document.collaboratorIds;
const collaboratorIds = uniq([...pudIds, ...existingIds]);
await Document.scope("withUnpublished").update(
{
text,

View File

@@ -1,31 +0,0 @@
// @flow
import { FileOperation, Event, User } from "../models";
import { sequelize } from "../sequelize";
export default async function fileOperationDeleter(
fileOp: FileOperation,
user: User,
ip: string
) {
let transaction = await sequelize.transaction();
try {
await fileOp.destroy({ transaction });
await Event.create(
{
name: "fileOperations.delete",
teamId: user.teamId,
actorId: user.id,
data: fileOp.dataValues,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}

View File

@@ -1,19 +1,19 @@
// @flow
import { FileOperation } from "../models";
import { buildAdmin, buildFileOperation } from "../test/factories";
import { flushdb } from "../test/support";
import { FileOperation } from "@server/models";
import { buildAdmin, buildFileOperation } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import fileOperationDeleter from "./fileOperationDeleter";
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
const mS3 = {
deleteObject: jest.fn().mockReturnThis(),
promise: jest.fn(),
};
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
describe("fileOperationDeleter", () => {
const ip = "127.0.0.1";
@@ -23,9 +23,7 @@ describe("fileOperationDeleter", () => {
userId: admin.id,
teamId: admin.teamId,
});
await fileOperationDeleter(fileOp, admin, ip);
expect(await FileOperation.count()).toEqual(0);
});
});

View File

@@ -0,0 +1,34 @@
import { FileOperation, Event, User } from "@server/models";
import { sequelize } from "../sequelize";
export default async function fileOperationDeleter(
// @ts-expect-error ts-migrate(2749) FIXME: 'FileOperation' refers to a value, but is being us... Remove this comment to see the full error message
fileOp: FileOperation,
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User,
ip: string
) {
const transaction = await sequelize.transaction();
try {
await fileOp.destroy({
transaction,
});
await Event.create(
{
name: "fileOperations.delete",
teamId: user.teamId,
actorId: user.id,
data: fileOp.dataValues,
ip,
},
{
transaction,
}
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}

View File

@@ -1,11 +1,9 @@
// @flow
import { Event } from "../models";
import { buildDocument, buildUser } from "../test/factories";
import { flushdb } from "../test/support";
import { Event } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import revisionCreator from "./revisionCreator";
beforeEach(() => flushdb());
describe("revisionCreator", () => {
const ip = "127.0.0.1";
@@ -15,10 +13,12 @@ describe("revisionCreator", () => {
userId: user.id,
teamId: user.teamId,
});
const revision = await revisionCreator({ document, user, ip });
const revision = await revisionCreator({
document,
user,
ip,
});
const event = await Event.findOne();
expect(revision.documentId).toEqual(document.id);
expect(revision.userId).toEqual(user.id);
expect(event.name).toEqual("revisions.create");

View File

@@ -1,5 +1,4 @@
// @flow
import { Document, User, Event, Revision } from "../models";
import { Document, User, Event, Revision } from "@server/models";
import { sequelize } from "../sequelize";
export default async function revisionCreator({
@@ -7,38 +6,42 @@ export default async function revisionCreator({
user,
ip,
}: {
document: Document,
user: User,
ip?: string,
document: Document;
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
ip?: string;
}) {
let transaction;
try {
transaction = await sequelize.transaction();
const revision = await Revision.createFromDocument(document, {
transaction,
});
await Event.create(
{
name: "revisions.create",
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
documentId: document.id,
modelId: revision.id,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
teamId: document.teamId,
actorId: user.id,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type 'Docum... Remove this comment to see the full error message
createdAt: document.updatedAt,
ip: ip || user.lastActiveIp,
},
{ transaction }
{
transaction,
}
);
await transaction.commit();
return revision;
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
}

View File

@@ -1,18 +1,18 @@
// @flow
import { buildTeam } from "../test/factories";
import { flushdb } from "../test/support";
import { buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import teamCreator from "./teamCreator";
jest.mock("aws-sdk", () => {
const mS3 = { putObject: jest.fn().mockReturnThis(), promise: jest.fn() };
const mS3 = {
putObject: jest.fn().mockReturnThis(),
promise: jest.fn(),
};
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
describe("teamCreator", () => {
it("should create team and authentication provider", async () => {
const result = await teamCreator({
@@ -24,9 +24,7 @@ describe("teamCreator", () => {
providerId: "example.com",
},
});
const { team, authenticationProvider, isNewTeam } = result;
expect(authenticationProvider.name).toEqual("google");
expect(authenticationProvider.providerId).toEqual("example.com");
expect(team.name).toEqual("Test team");
@@ -59,7 +57,6 @@ describe("teamCreator", () => {
it("should return existing team when within allowed domains", async () => {
delete process.env.DEPLOYMENT;
const existing = await buildTeam();
const result = await teamCreator({
name: "Updated name",
subdomain: "example",
@@ -69,40 +66,32 @@ describe("teamCreator", () => {
providerId: "allowed-domain.com",
},
});
const { team, authenticationProvider, isNewTeam } = result;
expect(team.id).toEqual(existing.id);
expect(team.name).toEqual(existing.name);
expect(authenticationProvider.name).toEqual("google");
expect(authenticationProvider.providerId).toEqual("allowed-domain.com");
expect(isNewTeam).toEqual(false);
const providers = await team.getAuthenticationProviders();
expect(providers.length).toEqual(2);
});
it("should return exising team", async () => {
delete process.env.DEPLOYMENT;
const authenticationProvider = {
name: "google",
providerId: "example.com",
};
const existing = await buildTeam({
subdomain: "example",
authenticationProviders: [authenticationProvider],
});
const result = await teamCreator({
name: "Updated name",
subdomain: "example",
authenticationProvider,
});
const { team, isNewTeam } = result;
expect(team.id).toEqual(existing.id);
expect(team.name).toEqual(existing.name);
expect(team.subdomain).toEqual("example");

View File

@@ -1,16 +1,17 @@
// @flow
import Logger from "@server/logging/logger";
import { Team, AuthenticationProvider } from "@server/models";
import { getAllowedDomains } from "@server/utils/authentication";
import { generateAvatarUrl } from "@server/utils/avatars";
import { MaximumTeamsError } from "../errors";
import Logger from "../logging/logger";
import { Team, AuthenticationProvider } from "../models";
import { sequelize } from "../sequelize";
import { getAllowedDomains } from "../utils/authentication";
import { generateAvatarUrl } from "../utils/avatars";
type TeamCreatorResult = {|
team: Team,
authenticationProvider: AuthenticationProvider,
isNewTeam: boolean,
|};
type TeamCreatorResult = {
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
team: Team;
// @ts-expect-error ts-migrate(2749) FIXME: 'AuthenticationProvider' refers to a value, but is... Remove this comment to see the full error message
authenticationProvider: AuthenticationProvider;
isNewTeam: boolean;
};
export default async function teamCreator({
name,
@@ -18,16 +19,16 @@ export default async function teamCreator({
subdomain,
avatarUrl,
authenticationProvider,
}: {|
name: string,
domain?: string,
subdomain: string,
avatarUrl?: string,
authenticationProvider: {|
name: string,
providerId: string,
|},
|}): Promise<TeamCreatorResult> {
}: {
name: string;
domain?: string;
subdomain: string;
avatarUrl?: string;
authenticationProvider: {
name: string;
providerId: string;
};
}): Promise<TeamCreatorResult> {
let authP = await AuthenticationProvider.findOne({
where: authenticationProvider,
include: [
@@ -61,7 +62,6 @@ export default async function teamCreator({
if (teamCount === 1 && domain && getAllowedDomains().includes(domain)) {
const team = await Team.findOne();
authP = await team.createAuthenticationProvider(authenticationProvider);
return {
authenticationProvider: authP,
team,
@@ -70,7 +70,7 @@ export default async function teamCreator({
}
if (teamCount >= 1) {
throw new MaximumTeamsError();
throw MaximumTeamsError();
}
}
@@ -84,7 +84,7 @@ export default async function teamCreator({
});
}
let transaction = await sequelize.transaction();
const transaction = await sequelize.transaction();
let team;
try {
@@ -99,7 +99,6 @@ export default async function teamCreator({
transaction,
}
);
await transaction.commit();
} catch (err) {
await transaction.rollback();

View File

@@ -1,25 +1,25 @@
// @flow
import { subDays } from "date-fns";
import { Attachment, User, Document, Collection, Team } from "../models";
import { Attachment, User, Document, Collection, Team } from "@server/models";
import {
buildAttachment,
buildUser,
buildTeam,
buildDocument,
} from "../test/factories";
import { flushdb } from "../test/support";
} from "@server/test/factories";
import { flushdb } from "@server/test/support";
import teamPermanentDeleter from "./teamPermanentDeleter";
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
const mS3 = {
deleteObject: jest.fn().mockReturnThis(),
promise: jest.fn(),
};
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
describe("teamPermanentDeleter", () => {
it("should destroy related data", async () => {
const team = await buildTeam({
@@ -32,30 +32,42 @@ describe("teamPermanentDeleter", () => {
teamId: team.id,
userId: user.id,
});
await teamPermanentDeleter(team);
expect(await Team.count()).toEqual(0);
expect(await User.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
expect(await Collection.unscoped().count({ paranoid: false })).toEqual(0);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(0);
expect(
await Collection.unscoped().count({
paranoid: false,
})
).toEqual(0);
});
it("should not destroy unrelated data", async () => {
const team = await buildTeam({
deletedAt: subDays(new Date(), 90),
});
await buildUser();
await buildTeam();
await buildDocument();
await teamPermanentDeleter(team);
expect(await Team.count()).toEqual(4); // each build command creates a team
expect(await User.count()).toEqual(2);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
expect(await Collection.unscoped().count({ paranoid: false })).toEqual(1);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(1);
expect(
await Collection.unscoped().count({
paranoid: false,
})
).toEqual(1);
});
it("should destroy attachments", async () => {
@@ -73,20 +85,26 @@ describe("teamPermanentDeleter", () => {
teamId: document.teamId,
documentId: document.id,
});
await teamPermanentDeleter(team);
expect(await Team.count()).toEqual(0);
expect(await User.count()).toEqual(0);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
expect(await Collection.unscoped().count({ paranoid: false })).toEqual(0);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(0);
expect(
await Collection.unscoped().count({
paranoid: false,
})
).toEqual(0);
});
it("should error when trying to destroy undeleted team", async () => {
const team = await buildTeam();
let error;
try {
await teamPermanentDeleter(team);
} catch (err) {

View File

@@ -1,5 +1,4 @@
// @flow
import Logger from "../logging/logger";
import Logger from "@server/logging/logger";
import {
ApiKey,
Attachment,
@@ -17,9 +16,10 @@ import {
IntegrationAuthentication,
SearchQuery,
Share,
} from "../models";
} from "@server/models";
import { sequelize } from "../sequelize";
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
export default async function teamPermanentDeleter(team: Team) {
if (!team.deletedAt) {
throw new Error(
@@ -31,13 +31,12 @@ export default async function teamPermanentDeleter(team: Team) {
"commands",
`Permanently deleting team ${team.name} (${team.id})`
);
const teamId = team.id;
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'transaction' implicitly has type 'any' i... Remove this comment to see the full error message
let transaction;
try {
transaction = await sequelize.transaction();
await Attachment.findAllInBatches(
{
where: {
@@ -46,6 +45,7 @@ export default async function teamPermanentDeleter(team: Team) {
limit: 100,
offset: 0,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'attachments' implicitly has an 'any' ty... Remove this comment to see the full error message
async (attachments, options) => {
Logger.info(
"commands",
@@ -53,13 +53,17 @@ export default async function teamPermanentDeleter(team: Team) {
options.offset + options.limit
}`
);
await Promise.all(
attachments.map((attachment) => attachment.destroy({ transaction }))
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'attachment' implicitly has an 'any' typ... Remove this comment to see the full error message
attachments.map((attachment) =>
attachment.destroy({
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'transaction' implicitly has an 'any' typ... Remove this comment to see the full error message
transaction,
})
)
);
}
);
// Destroy user-relation models
await User.findAllInBatches(
{
@@ -70,115 +74,133 @@ export default async function teamPermanentDeleter(team: Team) {
limit: 100,
offset: 0,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'users' implicitly has an 'any' type.
async (users) => {
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
const userIds = users.map((user) => user.id);
await UserAuthentication.destroy({
where: { userId: userIds },
where: {
userId: userIds,
},
force: true,
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'transaction' implicitly has an 'any' typ... Remove this comment to see the full error message
transaction,
});
await ApiKey.destroy({
where: { userId: userIds },
where: {
userId: userIds,
},
force: true,
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'transaction' implicitly has an 'any' typ... Remove this comment to see the full error message
transaction,
});
}
);
// Destory team-relation models
await AuthenticationProvider.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
// events must be first due to db constraints
await Event.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await Collection.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await Document.unscoped().destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await FileOperation.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await Group.unscoped().destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await Integration.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await IntegrationAuthentication.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await NotificationSetting.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await SearchQuery.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await Share.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await User.destroy({
where: { teamId },
where: {
teamId,
},
force: true,
transaction,
});
await team.destroy({
force: true,
transaction,
});
await Event.create(
{
name: "teams.destroy",
modelId: teamId,
},
{ transaction }
{
transaction,
}
);
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
}

View File

@@ -1,10 +1,8 @@
// @flow
import { buildUser, buildTeam, buildInvite } from "../test/factories";
import { flushdb } from "../test/support";
import { buildUser, buildTeam, buildInvite } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import userCreator from "./userCreator";
beforeEach(() => flushdb());
describe("userCreator", () => {
const ip = "127.0.0.1";
@@ -14,7 +12,6 @@ describe("userCreator", () => {
const existingAuth = authentications[0];
const newEmail = "test@example.com";
const newUsername = "tname";
const result = await userCreator({
name: existing.name,
email: newEmail,
@@ -29,9 +26,7 @@ describe("userCreator", () => {
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
@@ -45,9 +40,7 @@ describe("userCreator", () => {
const authentications = await existing.getAuthentications();
const existingAuth = authentications[0];
const newEmail = "test@example.com";
await existing.destroy();
const result = await userCreator({
name: "Test Name",
email: "test@example.com",
@@ -60,9 +53,7 @@ describe("userCreator", () => {
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
@@ -100,7 +91,6 @@ describe("userCreator", () => {
const team = await buildTeam();
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
email: "test@example.com",
@@ -114,9 +104,7 @@ describe("userCreator", () => {
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
@@ -128,10 +116,11 @@ describe("userCreator", () => {
});
it("should prefer isAdmin argument over defaultUserRole", async () => {
const team = await buildTeam({ defaultUserRole: "viewer" });
const team = await buildTeam({
defaultUserRole: "viewer",
});
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
email: "test@example.com",
@@ -146,17 +135,16 @@ describe("userCreator", () => {
scopes: ["read"],
},
});
const { user } = result;
expect(user.isAdmin).toEqual(true);
});
it("should prefer defaultUserRole when isAdmin is undefined or false", async () => {
const team = await buildTeam({ defaultUserRole: "viewer" });
const team = await buildTeam({
defaultUserRole: "viewer",
});
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
email: "test@example.com",
@@ -170,13 +158,10 @@ describe("userCreator", () => {
scopes: ["read"],
},
});
const { user: tname } = result;
expect(tname.username).toEqual("tname");
expect(tname.isAdmin).toEqual(false);
expect(tname.isViewer).toEqual(true);
const tname2Result = await userCreator({
name: "Test2 Name",
email: "tes2@example.com",
@@ -191,9 +176,7 @@ describe("userCreator", () => {
scopes: ["read"],
},
});
const { user: tname2 } = tname2Result;
expect(tname2.username).toEqual("tname2");
expect(tname2.isAdmin).toEqual(false);
expect(tname2.isViewer).toEqual(true);
@@ -201,10 +184,11 @@ describe("userCreator", () => {
it("should create a user from an invited user", async () => {
const team = await buildTeam();
const invite = await buildInvite({ teamId: team.id });
const invite = await buildInvite({
teamId: team.id,
});
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: invite.name,
email: invite.email,
@@ -217,9 +201,7 @@ describe("userCreator", () => {
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");

View File

@@ -1,15 +1,15 @@
// @flow
import Sequelize from "sequelize";
import { Event, Team, User, UserAuthentication } from "../models";
import { Event, Team, User, UserAuthentication } from "@server/models";
import { sequelize } from "../sequelize";
const Op = Sequelize.Op;
type UserCreatorResult = {|
user: User,
isNewUser: boolean,
authentication: UserAuthentication,
|};
type UserCreatorResult = {
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
isNewUser: boolean;
// @ts-expect-error ts-migrate(2749) FIXME: 'UserAuthentication' refers to a value, but is bei... Remove this comment to see the full error message
authentication: UserAuthentication;
};
export default async function userCreator({
name,
@@ -20,22 +20,22 @@ export default async function userCreator({
teamId,
authentication,
ip,
}: {|
name: string,
email: string,
username?: string,
isAdmin?: boolean,
avatarUrl?: string,
teamId: string,
ip: string,
authentication: {|
authenticationProviderId: string,
providerId: string,
scopes: string[],
accessToken?: string,
refreshToken?: string,
|},
|}): Promise<UserCreatorResult> {
}: {
name: string;
email: string;
username?: string;
isAdmin?: boolean;
avatarUrl?: string;
teamId: string;
ip: string;
authentication: {
authenticationProviderId: string;
providerId: string;
scopes: string[];
accessToken?: string;
refreshToken?: string;
};
}): Promise<UserCreatorResult> {
const { authenticationProviderId, providerId, ...rest } = authentication;
const auth = await UserAuthentication.findOne({
where: {
@@ -65,10 +65,16 @@ export default async function userCreator({
}
if (user) {
await user.update({ email, username });
await user.update({
email,
username,
});
await auth.update(rest);
return { user, authentication: auth, isNewUser: false };
return {
user,
authentication: auth,
isNewUser: false,
};
}
// We found an authentication record, but the associated user was deleted or
@@ -100,15 +106,18 @@ export default async function userCreator({
// We have an existing invite for his user, so we need to update it with our
// new details and link up the authentication method
if (invite && !invite.authentications.length) {
let transaction = await sequelize.transaction();
const transaction = await sequelize.transaction();
let auth;
try {
await invite.update(
{
name,
avatarUrl,
},
{ transaction }
{
transaction,
}
);
auth = await invite.createAuthentication(authentication, {
transaction,
@@ -119,18 +128,21 @@ export default async function userCreator({
throw err;
}
return { user: invite, authentication: auth, isNewUser: true };
return {
user: invite,
authentication: auth,
isNewUser: true,
};
}
// No auth, no user this is an entirely new sign in.
let transaction = await sequelize.transaction();
const transaction = await sequelize.transaction();
try {
const { defaultUserRole } = await Team.findByPk(teamId, {
attributes: ["defaultUserRole"],
transaction,
});
const user = await User.create(
{
name,

View File

@@ -1,16 +1,13 @@
// @flow
import { buildUser, buildAdmin } from "../test/factories";
import { flushdb } from "../test/support";
import { buildUser, buildAdmin } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import userDestroyer from "./userDestroyer";
beforeEach(() => flushdb());
describe("userDestroyer", () => {
const ip = "127.0.0.1";
it("should prevent last user from deleting account", async () => {
const user = await buildUser();
let error;
try {
@@ -22,13 +19,15 @@ describe("userDestroyer", () => {
} catch (err) {
error = err;
}
expect(error && error.message).toContain("Cannot delete last user");
});
it("should prevent last admin from deleting account", async () => {
const user = await buildAdmin();
await buildUser({ teamId: user.teamId });
await buildUser({
teamId: user.teamId,
});
let error;
try {
@@ -40,13 +39,15 @@ describe("userDestroyer", () => {
} catch (err) {
error = err;
}
expect(error && error.message).toContain("Cannot delete account");
});
it("should not prevent multiple admin from deleting account", async () => {
const actor = await buildAdmin();
const user = await buildAdmin({ teamId: actor.teamId });
const user = await buildAdmin({
teamId: actor.teamId,
});
let error;
try {
@@ -58,14 +59,16 @@ describe("userDestroyer", () => {
} catch (err) {
error = err;
}
expect(error).toBeFalsy();
expect(user.deletedAt).toBeTruthy();
});
it("should not prevent last non-admin from deleting account", async () => {
const user = await buildUser();
await buildUser({ teamId: user.teamId });
await buildUser({
teamId: user.teamId,
});
let error;
try {
@@ -77,6 +80,7 @@ describe("userDestroyer", () => {
} catch (err) {
error = err;
}
expect(error).toBeFalsy();
expect(user.deletedAt).toBeTruthy();
});

View File

@@ -1,6 +1,5 @@
// @flow
import { Event, User } from "@server/models";
import { ValidationError } from "../errors";
import { Event, User } from "../models";
import { Op, sequelize } from "../sequelize";
export default async function userDestroyer({
@@ -8,12 +7,13 @@ export default async function userDestroyer({
actor,
ip,
}: {
user: User,
actor: User,
ip: string,
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
actor: User;
ip: string;
}) {
const { teamId } = user;
const usersCount = await User.count({
where: {
teamId,
@@ -21,7 +21,7 @@ export default async function userDestroyer({
});
if (usersCount === 1) {
throw new ValidationError("Cannot delete last user on the team.");
throw ValidationError("Cannot delete last user on the team.");
}
if (user.isAdmin) {
@@ -29,34 +29,41 @@ export default async function userDestroyer({
where: {
isAdmin: true,
teamId,
id: { [Op.ne]: user.id },
id: {
[Op.ne]: user.id,
},
},
});
if (otherAdminsCount === 0) {
throw new ValidationError(
throw ValidationError(
"Cannot delete account as only admin. Please make another user admin and try again."
);
}
}
let transaction = await sequelize.transaction();
const transaction = await sequelize.transaction();
let response;
try {
response = await user.destroy({ transaction });
response = await user.destroy({
transaction,
});
await Event.create(
{
name: "users.delete",
actorId: actor.id,
userId: user.id,
teamId,
data: { name: user.name },
data: {
name: user.name,
},
ip,
},
{ transaction }
{
transaction,
}
);
await transaction.commit();
} catch (err) {
await transaction.rollback();

View File

@@ -1,63 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { buildUser } from "../test/factories";
import { flushdb } from "../test/support";
import userInviter from "./userInviter";
beforeEach(() => flushdb());
describe("userInviter", () => {
const ip = "127.0.0.1";
it("should return sent invites", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: "test@example.com", name: "Test" }],
user,
ip,
});
expect(response.sent.length).toEqual(1);
});
it("should filter empty invites", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: " ", name: "Test" }],
user,
ip,
});
expect(response.sent.length).toEqual(0);
});
it("should filter obviously bunk emails", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: "notanemail", name: "Test" }],
user,
ip,
});
expect(response.sent.length).toEqual(0);
});
it("should not send duplicates", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [
{ email: "the@same.com", name: "Test" },
{ email: "the@SAME.COM", name: "Test" },
],
user,
ip,
});
expect(response.sent.length).toEqual(1);
});
it("should not send invites to existing team members", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: user.email, name: user.name }],
user,
ip,
});
expect(response.sent.length).toEqual(0);
});
});

View File

@@ -0,0 +1,93 @@
import { buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import userInviter from "./userInviter";
beforeEach(() => flushdb());
describe("userInviter", () => {
const ip = "127.0.0.1";
it("should return sent invites", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
{
email: "test@example.com",
name: "Test",
},
],
user,
ip,
});
expect(response.sent.length).toEqual(1);
});
it("should filter empty invites", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
{
email: " ",
name: "Test",
},
],
user,
ip,
});
expect(response.sent.length).toEqual(0);
});
it("should filter obviously bunk emails", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
{
email: "notanemail",
name: "Test",
},
],
user,
ip,
});
expect(response.sent.length).toEqual(0);
});
it("should not send duplicates", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
{
email: "the@same.com",
name: "Test",
},
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
{
email: "the@SAME.COM",
name: "Test",
},
],
user,
ip,
});
expect(response.sent.length).toEqual(1);
});
it("should not send invites to existing team members", async () => {
const user = await buildUser();
const response = await userInviter({
invites: [
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: any; ... Remove this comment to see the full error message
{
email: user.email,
name: user.name,
},
],
user,
ip,
});
expect(response.sent.length).toEqual(0);
});
});

View File

@@ -1,13 +1,12 @@
// @flow
import { uniqBy } from "lodash";
import type { Role } from "shared/types";
import { Role } from "@shared/types";
import { User, Event, Team } from "@server/models";
import mailer from "../mailer";
import { User, Event, Team } from "../models";
type Invite = {
name: string,
email: string,
role: Role,
name: string;
email: string;
role: Role;
};
export default async function userInviter({
@@ -15,17 +14,20 @@ export default async function userInviter({
invites,
ip,
}: {
user: User,
invites: Invite[],
ip: string,
}): Promise<{ sent: Invite[], users: User[] }> {
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
invites: Invite[];
ip: string;
}): Promise<{
sent: Invite[];
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
users: User[];
}> {
const team = await Team.findByPk(user.teamId);
// filter out empties and obvious non-emails
const compactedInvites = invites.filter(
(invite) => !!invite.email.trim() && invite.email.match("@")
);
// normalize to lowercase and remove duplicates
const normalizedInvites = uniqBy(
compactedInvites.map((invite) => ({
@@ -34,7 +36,6 @@ export default async function userInviter({
})),
"email"
);
// filter out any existing users in the system
const emails = normalizedInvites.map((invite) => invite.email);
const existingUsers = await User.findAll({
@@ -43,12 +44,12 @@ export default async function userInviter({
email: emails,
},
});
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
const existingEmails = existingUsers.map((user) => user.email);
const filteredInvites = normalizedInvites.filter(
(invite) => !existingEmails.includes(invite.email)
);
let users = [];
const users = [];
// send and record remaining invites
for (const invite of filteredInvites) {
@@ -61,7 +62,6 @@ export default async function userInviter({
isViewer: invite.role === "viewer",
});
users.push(newUser);
await Event.create({
name: "users.invite",
actorId: user.id,
@@ -73,7 +73,6 @@ export default async function userInviter({
},
ip,
});
await mailer.sendTemplate("invite", {
to: invite.email,
name: invite.name,
@@ -84,5 +83,8 @@ export default async function userInviter({
});
}
return { sent: filteredInvites, users };
return {
sent: filteredInvites,
users,
};
}

View File

@@ -1,11 +1,9 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { GroupUser } from "../models";
import { buildGroup, buildAdmin, buildUser } from "../test/factories";
import { flushdb } from "../test/support";
import { GroupUser } from "@server/models";
import { buildGroup, buildAdmin, buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import userSuspender from "./userSuspender";
beforeEach(() => flushdb());
describe("userSuspender", () => {
const ip = "127.0.0.1";
@@ -28,7 +26,9 @@ describe("userSuspender", () => {
it("should suspend the user", async () => {
const admin = await buildAdmin();
const user = await buildUser({ teamId: admin.teamId });
const user = await buildUser({
teamId: admin.teamId,
});
await userSuspender({
actorId: admin.id,
user,
@@ -40,11 +40,17 @@ describe("userSuspender", () => {
it("should remove group memberships", async () => {
const admin = await buildAdmin();
const user = await buildUser({ teamId: admin.teamId });
const group = await buildGroup({ teamId: user.teamId });
await group.addUser(user, { through: { createdById: user.id } });
const user = await buildUser({
teamId: admin.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
await group.addUser(user, {
through: {
createdById: user.id,
},
});
await userSuspender({
actorId: admin.id,
user,

View File

@@ -1,7 +1,6 @@
// @flow
import { Transaction } from "sequelize";
import { User, Event, GroupUser } from "@server/models";
import { ValidationError } from "../errors";
import { User, Event, GroupUser } from "../models";
import { sequelize } from "../sequelize";
export default async function userSuspender({
@@ -9,12 +8,13 @@ export default async function userSuspender({
actorId,
ip,
}: {
user: User,
actorId: string,
ip: string,
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
user: User;
actorId: string;
ip: string;
}): Promise<void> {
if (user.id === actorId) {
throw new ValidationError("Unable to suspend the current user");
throw ValidationError("Unable to suspend the current user");
}
await sequelize.transaction(async (transaction: Transaction) => {
@@ -23,21 +23,30 @@ export default async function userSuspender({
suspendedById: actorId,
suspendedAt: new Date(),
},
{ transaction }
{
transaction,
}
);
await GroupUser.destroy({ where: { userId: user.id }, transaction });
await GroupUser.destroy({
where: {
userId: user.id,
},
transaction,
});
await Event.create(
{
name: "users.suspend",
actorId,
userId: user.id,
teamId: user.teamId,
data: { name: user.name },
data: {
name: user.name,
},
ip,
},
{ transaction }
{
transaction,
}
);
});
}

View File

@@ -1,6 +1,5 @@
// @flow
import * as React from "react";
import { User, Collection } from "../models";
import { User, Collection } from "@server/models";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -10,10 +9,12 @@ import Header from "./components/Header";
import Heading from "./components/Heading";
export type Props = {
actor: User,
collection: Collection,
eventName: string,
unsubscribeUrl: string,
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
actor: User;
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
collection: Collection;
eventName: string;
unsubscribeUrl: string;
};
export const collectionNotificationEmailText = ({

View File

@@ -1,6 +1,5 @@
// @flow
import * as React from "react";
import { User, Document, Team, Collection } from "../models";
import { User, Document, Team, Collection } from "@server/models";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -10,12 +9,15 @@ import Header from "./components/Header";
import Heading from "./components/Heading";
export type Props = {
actor: User,
team: Team,
document: Document,
collection: Collection,
eventName: string,
unsubscribeUrl: string,
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
actor: User;
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
team: Team;
document: any;
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
collection: Collection;
eventName: string;
unsubscribeUrl: string;
};
export const documentNotificationEmailText = ({

View File

@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";

View File

@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -18,8 +17,8 @@ export const ExportSuccessEmail = ({
id,
teamUrl,
}: {
id: string,
teamUrl: string,
id: string;
teamUrl: string;
}) => {
return (
<EmailTemplate>

View File

@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -9,11 +8,11 @@ import Header from "./components/Header";
import Heading from "./components/Heading";
export type Props = {
name: string,
actorName: string,
actorEmail: string,
teamName: string,
teamUrl: string,
name: string;
actorName: string;
actorEmail: string;
teamName: string;
teamUrl: string;
};
export const inviteEmailText = ({

View File

@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -9,8 +8,8 @@ import Header from "./components/Header";
import Heading from "./components/Heading";
export type Props = {
token: string,
teamUrl: string,
token: string;
teamUrl: string;
};
export const signinEmailText = ({ token, teamUrl }: Props) => `

View File

@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -9,7 +8,7 @@ import Header from "./components/Header";
import Heading from "./components/Heading";
export type Props = {
teamUrl: string,
teamUrl: string;
};
export const welcomeEmailText = ({ teamUrl }: Props) => `

View File

@@ -1,11 +1,9 @@
// @flow
import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
import EmptySpace from "./EmptySpace";
type Props = {
children: React.Node,
children: React.ReactNode;
};
export default ({ children }: Props) => {

View File

@@ -1,7 +1,9 @@
// @flow
import * as React from "react";
type Props = { href: string, children: React.Node };
type Props = {
href: string;
children: React.ReactNode;
};
export default (props: Props) => {
const style = {
@@ -14,7 +16,6 @@ export default (props: Props) => {
textDecoration: "none",
cursor: "pointer",
};
return (
<a {...props} style={style}>
{props.children}

View File

@@ -1,10 +1,9 @@
// @flow
import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
import theme from "../../../shared/theme";
import theme from "@shared/theme";
type Props = {
children: React.Node,
children: React.ReactNode;
};
export default (props: Props) => (

View File

@@ -1,4 +1,3 @@
// @flow
import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
@@ -9,7 +8,6 @@ const EmptySpace = ({ height }: { height?: number }) => {
fontSize: "1px",
msoLineHeightRule: "exactly",
};
return (
<Table width="100%">
<TBody>
@@ -18,7 +16,9 @@ const EmptySpace = ({ height }: { height?: number }) => {
width="100%"
height={`${height}px`}
style={style}
dangerouslySetInnerHTML={{ __html: "&nbsp;" }}
dangerouslySetInnerHTML={{
__html: "&nbsp;",
}}
/>
</TR>
</TBody>

View File

@@ -1,11 +1,10 @@
// @flow
import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
import theme from "../../../shared/theme";
import { twitterUrl } from "../../../shared/utils/routeHelpers";
import theme from "@shared/theme";
import { twitterUrl } from "@shared/utils/routeHelpers";
type Props = {
unsubscribeUrl?: string,
unsubscribeUrl?: string;
};
export default ({ unsubscribeUrl }: Props) => {
@@ -15,26 +14,22 @@ export default ({ unsubscribeUrl }: Props) => {
color: theme.slate,
fontSize: "14px",
};
const unsubStyle = {
padding: "0",
color: theme.slate,
fontSize: "14px",
};
const linkStyle = {
color: theme.slate,
fontWeight: 500,
textDecoration: "none",
marginRight: "10px",
};
const externalLinkStyle = {
color: theme.slate,
textDecoration: "none",
margin: "0 10px",
};
return (
<Table width="100%">
<TBody>

View File

@@ -1,4 +1,3 @@
// @flow
import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
import EmptySpace from "./EmptySpace";

View File

@@ -1,13 +1,11 @@
// @flow
import * as React from "react";
const style = {
fontWeight: 500,
fontSize: "18px",
};
type Props = {
children: React.Node,
children: React.ReactNode;
};
export default ({ children }: Props) => (

View File

@@ -1,4 +1,3 @@
// @flow
import Koa from "koa";
import Router from "koa-router";
import { NotFoundError } from "../errors";
@@ -9,8 +8,10 @@ const router = new Router();
router.get("/:type/:format", async (ctx) => {
let mailerOutput;
let mailer = new Mailer();
const mailer = new Mailer();
mailer.transporter = {
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'data' implicitly has an 'any' type.
sendMail: (data) => (mailerOutput = data),
};
@@ -20,20 +21,22 @@ router.get("/:type/:format", async (ctx) => {
// break;
default:
if (Object.getOwnPropertyNames(mailer).includes(ctx.params.type)) {
// $FlowIssue flow doesn't like this but we're ok with it
mailer[ctx.params.type]("user@example.com");
} else throw new NotFoundError("Email template could not be found");
} else {
throw NotFoundError("Email template could not be found");
}
}
if (!mailerOutput) return;
if (ctx.params.format === "text") {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'never'.
ctx.body = mailerOutput.text;
} else {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'html' does not exist on type 'never'.
ctx.body = mailerOutput.html;
}
});
emailPreviews.use(router.routes());
export default emailPreviews;

View File

@@ -1,4 +0,0 @@
// @flow
require("dotenv").config({ silent: true });
export default process.env;

6
server/env.ts Normal file
View File

@@ -0,0 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
require("dotenv").config({
silent: true,
});
export default process.env;

View File

@@ -1,117 +0,0 @@
// @flow
import httpErrors from "http-errors";
import env from "./env";
export function AuthenticationError(
message: string = "Invalid authentication",
redirectUrl: string = env.URL
) {
return httpErrors(401, message, {
redirectUrl,
id: "authentication_required",
});
}
export function AuthorizationError(
message: string = "You do not have permission to access this resource"
) {
return httpErrors(403, message, { id: "permission_required" });
}
export function AdminRequiredError(
message: string = "An admin role is required to access this resource"
) {
return httpErrors(403, message, { id: "admin_required" });
}
export function UserSuspendedError({ adminEmail }: { adminEmail: string }) {
return httpErrors(403, "Your access has been suspended by the team admin", {
id: "user_suspended",
errorData: {
adminEmail,
},
});
}
export function InvalidRequestError(message: string = "Request invalid") {
return httpErrors(400, message, { id: "invalid_request" });
}
export function NotFoundError(message: string = "Resource not found") {
return httpErrors(404, message, { id: "not_found" });
}
export function ParamRequiredError(
message: string = "Required parameter missing"
) {
return httpErrors(400, message, { id: "param_required" });
}
export function ValidationError(message: string = "Validation failed") {
return httpErrors(400, message, { id: "validation_error" });
}
export function EditorUpdateError(
message: string = "The client editor is out of date and must be reloaded"
) {
return httpErrors(400, message, { id: "editor_update_required" });
}
export function FileImportError(
message: string = "The file could not be imported"
) {
return httpErrors(400, message, { id: "import_error" });
}
export function OAuthStateMismatchError(
message: string = "State returned in OAuth flow did not match"
) {
return httpErrors(400, message, { id: "state_mismatch" });
}
export function MaximumTeamsError(
message: string = "The maximum number of teams has been reached"
) {
return httpErrors(400, message, { id: "maximum_teams" });
}
export function EmailAuthenticationRequiredError(
message: string = "User must authenticate with email",
redirectUrl: string = env.URL
) {
return httpErrors(400, message, { redirectUrl, id: "email_auth_required" });
}
export function MicrosoftGraphError(
message: string = "Microsoft Graph API did not return required fields"
) {
return httpErrors(400, message, { id: "graph_error" });
}
export function GoogleWorkspaceRequiredError(
message: string = "Google Workspace is required to authenticate"
) {
return httpErrors(400, message, { id: "google_hd" });
}
export function GoogleWorkspaceInvalidError(
message: string = "Google Workspace is invalid"
) {
return httpErrors(400, message, { id: "hd_not_allowed" });
}
export function OIDCMalformedUserInfoError(
message: string = "User profile information malformed"
) {
return httpErrors(400, message, { id: "malformed_user_info" });
}
export function AuthenticationProviderDisabledError(
message: string = "Authentication method has been disabled by an admin",
redirectUrl: string = env.URL
) {
return httpErrors(400, message, {
redirectUrl,
id: "authentication_provider_disabled",
});
}

146
server/errors.ts Normal file
View File

@@ -0,0 +1,146 @@
import httpErrors from "http-errors";
import env from "./env";
export function AuthenticationError(
message = "Invalid authentication",
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string | undefined' is not assignable to typ... Remove this comment to see the full error message
redirectUrl: string = env.URL
) {
return httpErrors(401, message, {
redirectUrl,
id: "authentication_required",
});
}
export function AuthorizationError(
message = "You do not have permission to access this resource"
) {
return httpErrors(403, message, {
id: "permission_required",
});
}
export function AdminRequiredError(
message = "An admin role is required to access this resource"
) {
return httpErrors(403, message, {
id: "admin_required",
});
}
export function UserSuspendedError({ adminEmail }: { adminEmail: string }) {
return httpErrors(403, "Your access has been suspended by the team admin", {
id: "user_suspended",
errorData: {
adminEmail,
},
});
}
export function InvalidRequestError(message = "Request invalid") {
return httpErrors(400, message, {
id: "invalid_request",
});
}
export function NotFoundError(message = "Resource not found") {
return httpErrors(404, message, {
id: "not_found",
});
}
export function ParamRequiredError(message = "Required parameter missing") {
return httpErrors(400, message, {
id: "param_required",
});
}
export function ValidationError(message = "Validation failed") {
return httpErrors(400, message, {
id: "validation_error",
});
}
export function EditorUpdateError(
message = "The client editor is out of date and must be reloaded"
) {
return httpErrors(400, message, {
id: "editor_update_required",
});
}
export function FileImportError(message = "The file could not be imported") {
return httpErrors(400, message, {
id: "import_error",
});
}
export function OAuthStateMismatchError(
message = "State returned in OAuth flow did not match"
) {
return httpErrors(400, message, {
id: "state_mismatch",
});
}
export function MaximumTeamsError(
message = "The maximum number of teams has been reached"
) {
return httpErrors(400, message, {
id: "maximum_teams",
});
}
export function EmailAuthenticationRequiredError(
message = "User must authenticate with email",
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string | undefined' is not assignable to typ... Remove this comment to see the full error message
redirectUrl: string = env.URL
) {
return httpErrors(400, message, {
redirectUrl,
id: "email_auth_required",
});
}
export function MicrosoftGraphError(
message = "Microsoft Graph API did not return required fields"
) {
return httpErrors(400, message, {
id: "graph_error",
});
}
export function GoogleWorkspaceRequiredError(
message = "Google Workspace is required to authenticate"
) {
return httpErrors(400, message, {
id: "google_hd",
});
}
export function GoogleWorkspaceInvalidError(
message = "Google Workspace is invalid"
) {
return httpErrors(400, message, {
id: "hd_not_allowed",
});
}
export function OIDCMalformedUserInfoError(
message = "User profile information malformed"
) {
return httpErrors(400, message, {
id: "malformed_user_info",
});
}
export function AuthenticationProviderDisabledError(
message = "Authentication method has been disabled by an admin",
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string | undefined' is not assignable to typ... Remove this comment to see the full error message
redirectUrl: string = env.URL
) {
return httpErrors(400, message, {
redirectUrl,
id: "authentication_provider_disabled",
});
}

View File

@@ -1,5 +1,5 @@
// @flow
import env from "./env"; // eslint-disable-line import/order
import "./tracing"; // must come before importing any instrumented module
import http from "http";
@@ -37,15 +37,18 @@ const serviceNames = uniq(
// The number of processes to run, defaults to the number of CPU's available
// for the web service, and 1 for collaboration during the beta period.
let processCount = env.WEB_CONCURRENCY || undefined;
let processCount = env.WEB_CONCURRENCY
? parseInt(env.WEB_CONCURRENCY, 10)
: undefined;
if (serviceNames.includes("collaboration")) {
if (env.WEB_CONCURRENCY !== 1) {
if (processCount !== 1) {
Logger.info(
"lifecycle",
"Note: Restricting process count to 1 due to use of collaborative service"
);
}
processCount = 1;
}
@@ -61,18 +64,18 @@ function master() {
}
// This function will only be called in each forked process
async function start(id: string, disconnect: () => void) {
async function start(id: number, disconnect: () => void) {
// If a --port flag is passed then it takes priority over the env variable
const normalizedPortFlag = getArg("port", "p");
const app = new Koa();
const server = stoppable(http.createServer(app.callback()));
const router = new Router();
// install basic middleware shared by all services
if ((env.DEBUG || "").includes("http")) {
app.use(logger((str, args) => Logger.info("http", str)));
app.use(logger((str) => Logger.info("http", str)));
}
app.use(compress());
app.use(helmet());
@@ -98,20 +101,18 @@ async function start(id: string, disconnect: () => void) {
server.on("error", (err) => {
throw err;
});
server.on("listening", () => {
const address = server.address();
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
Logger.info("lifecycle", `Listening on http://localhost:${address.port}`);
});
server.listen(normalizedPortFlag || env.PORT || "3000");
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
function shutdown() {
Logger.info("lifecycle", "Stopping server");
server.emit("shutdown");
server.stop(disconnect);
}

View File

@@ -1,12 +1,10 @@
// @flow
import chalk from "chalk";
import winston from "winston";
import env from "../env";
import Metrics from "../logging/metrics";
import Sentry from "../logging/sentry";
import env from "@server/env";
import Metrics from "@server/logging/metrics";
import Sentry from "@server/logging/sentry";
const isProduction = env.NODE_ENV === "production";
type LogCategory =
| "lifecycle"
| "hocuspocus"
@@ -17,8 +15,7 @@ type LogCategory =
| "queue"
| "database"
| "utils";
type Extra = { [key: string]: any };
type Extra = Record<string, any>;
class Logger {
output: any;
@@ -77,6 +74,7 @@ class Logger {
scope.setExtra(key, extra[key]);
scope.setLevel(Sentry.Severity.Warning);
}
Sentry.captureMessage(message);
});
}
@@ -106,14 +104,21 @@ class Logger {
scope.setExtra(key, extra[key]);
scope.setLevel(Sentry.Severity.Error);
}
Sentry.captureException(error);
});
}
if (isProduction) {
this.output.error(message, { error: error.message, stack: error.stack });
this.output.error(message, {
error: error.message,
stack: error.stack,
});
} else {
console.error(message, { error, extra });
console.error(message, {
error,
extra,
});
}
}
}

View File

@@ -1,8 +1,7 @@
// @flow
import ddMetrics from "datadog-metrics";
class Metrics {
enabled: boolean = !!process.env.DD_API_KEY;
enabled = !!process.env.DD_API_KEY;
constructor() {
if (!this.enabled) {
@@ -24,12 +23,13 @@ class Metrics {
return ddMetrics.gauge(key, value, tags);
}
gaugePerInstance(key: string, value: number, tags?: string[] = []): void {
gaugePerInstance(key: string, value: number, tags: string[] = []): void {
if (!this.enabled) {
return;
}
const instanceId = process.env.INSTANCE_ID || process.env.HEROKU_DYNO_ID;
if (!instanceId) {
throw new Error(
"INSTANCE_ID or HEROKU_DYNO_ID must be set when using DataDog"
@@ -39,12 +39,12 @@ class Metrics {
return ddMetrics.gauge(key, value, [...tags, `instance:${instanceId}`]);
}
increment(key: string, tags?: { [string]: string }): void {
increment(key: string, tags?: Record<string, string>): void {
if (!this.enabled) {
return;
}
return ddMetrics.increment(key, tags);
return ddMetrics.increment(key);
}
}

View File

@@ -1,7 +1,6 @@
// @flow
import * as Sentry from "@sentry/node";
import env from "../env";
import type { ContextWithState } from "../types";
import env from "@server/env";
import { ContextWithState } from "../types";
if (env.SENTRY_DSN) {
Sentry.init({
@@ -22,26 +21,33 @@ if (env.SENTRY_DSN) {
export function requestErrorHandler(error: any, ctx: ContextWithState) {
// we don't need to report every time a request stops to the bug tracker
if (error.code === "EPIPE" || error.code === "ECONNRESET") {
console.warn("Connection error", { error });
console.warn("Connection error", {
error,
});
return;
}
if (process.env.SENTRY_DSN) {
Sentry.withScope(function (scope) {
const requestId = ctx.headers["x-request-id"];
if (requestId) {
scope.setTag("request_id", requestId);
scope.setTag("request_id", requestId as string);
}
const authType = ctx.state ? ctx.state.authType : undefined;
if (authType) {
scope.setTag("auth_type", authType);
}
const userId =
ctx.state && ctx.state.user ? ctx.state.user.id : undefined;
if (userId) {
scope.setUser({ id: userId });
scope.setUser({
id: userId,
});
}
scope.addEventProcessor(function (event) {

View File

@@ -1,16 +1,15 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import mailer from "./mailer";
describe("Mailer", () => {
let fakeMailer = mailer;
const fakeMailer = mailer;
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'sendMailOutput' implicitly has type 'any... Remove this comment to see the full error message
let sendMailOutput;
beforeEach(() => {
process.env.URL = "http://localhost:3000";
process.env.SMTP_FROM_EMAIL = "hello@example.com";
jest.resetModules();
fakeMailer.transporter = {
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'output' implicitly has an 'any' type.
sendMail: (output) => (sendMailOutput = output),
};
});
@@ -20,6 +19,7 @@ describe("Mailer", () => {
to: "user@example.com",
teamUrl: "http://example.com",
});
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'sendMailOutput' implicitly has an 'any' ... Remove this comment to see the full error message
expect(sendMailOutput).toMatchSnapshot();
});
});

View File

@@ -1,14 +1,13 @@
// @flow
import nodemailer from "nodemailer";
import Oy from "oy-vey";
import * as React from "react";
import {
type Props as CollectionNotificationEmailT,
Props as CollectionNotificationEmailT,
CollectionNotificationEmail,
collectionNotificationEmailText,
} from "./emails/CollectionNotificationEmail";
import {
type Props as DocumentNotificationEmailT,
Props as DocumentNotificationEmailT,
DocumentNotificationEmail,
documentNotificationEmailText,
} from "./emails/DocumentNotificationEmail";
@@ -16,13 +15,12 @@ import {
ExportFailureEmail,
exportEmailFailureText,
} from "./emails/ExportFailureEmail";
import {
ExportSuccessEmail,
exportEmailSuccessText,
} from "./emails/ExportSuccessEmail";
import {
type Props as InviteEmailT,
Props as InviteEmailT,
InviteEmail,
inviteEmailText,
} from "./emails/InviteEmail";
@@ -44,13 +42,13 @@ export type EmailTypes =
| "exportSuccess";
export type EmailSendOptions = {
to: string,
properties?: any,
title: string,
previewText?: string,
text: string,
html: React.Node,
headCSS?: string,
to: string;
properties?: any;
title: string;
previewText?: string;
text: string;
html: React.ReactNode;
headCSS?: string;
};
/**
@@ -65,7 +63,7 @@ export type EmailSendOptions = {
* TEXT: http://localhost:3000/email/:email_type/text
*/
export class Mailer {
transporter: ?any;
transporter: any | null | undefined;
constructor() {
this.loadTransport();
@@ -73,7 +71,7 @@ export class Mailer {
async loadTransport() {
if (process.env.SMTP_HOST) {
let smtpConfig = {
const smtpConfig = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure:
@@ -83,17 +81,21 @@ export class Mailer {
auth: undefined,
tls:
"SMTP_TLS_CIPHERS" in process.env
? { ciphers: process.env.SMTP_TLS_CIPHERS }
? {
ciphers: process.env.SMTP_TLS_CIPHERS,
}
: undefined,
};
if (process.env.SMTP_USERNAME) {
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ user: string; pass: string | undefined; }'... Remove this comment to see the full error message
smtpConfig.auth = {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
};
}
// @ts-expect-error config
this.transporter = nodemailer.createTransport(smtpConfig);
return;
}
@@ -105,8 +107,7 @@ export class Mailer {
);
try {
let testAccount = await nodemailer.createTestAccount();
const testAccount = await nodemailer.createTestAccount();
const smtpConfig = {
host: "smtp.ethereal.email",
port: 587,
@@ -116,7 +117,6 @@ export class Mailer {
pass: testAccount.pass,
},
};
this.transporter = nodemailer.createTransport(smtpConfig);
} catch (err) {
Logger.error(
@@ -127,7 +127,10 @@ export class Mailer {
}
}
sendMail = async (data: EmailSendOptions): ?Promise<*> => {
sendMail = async (
data: EmailSendOptions
// @ts-expect-error ts-migrate(1064) FIXME: The return type of an async function or method mus... Remove this comment to see the full error message
): Promise<any> | null | undefined => {
const { transporter } = this;
if (transporter) {
@@ -161,7 +164,7 @@ export class Mailer {
}
};
welcome = async (opts: { to: string, teamUrl: string }) => {
welcome = async (opts: { to: string; teamUrl: string }) => {
this.sendMail({
to: opts.to,
title: "Welcome to Outline",
@@ -172,7 +175,7 @@ export class Mailer {
});
};
exportSuccess = async (opts: { to: string, id: string, teamUrl: string }) => {
exportSuccess = async (opts: { to: string; id: string; teamUrl: string }) => {
this.sendMail({
to: opts.to,
title: "Your requested export",
@@ -182,7 +185,7 @@ export class Mailer {
});
};
exportFailure = async (opts: { to: string, teamUrl: string }) => {
exportFailure = async (opts: { to: string; teamUrl: string }) => {
this.sendMail({
to: opts.to,
title: "Your requested export",
@@ -192,7 +195,11 @@ export class Mailer {
});
};
invite = async (opts: { to: string } & InviteEmailT) => {
invite = async (
opts: {
to: string;
} & InviteEmailT
) => {
this.sendMail({
to: opts.to,
title: `${opts.actorName} invited you to join ${opts.teamName}s knowledge base`,
@@ -203,7 +210,7 @@ export class Mailer {
});
};
signin = async (opts: { to: string, token: string, teamUrl: string }) => {
signin = async (opts: { to: string; token: string; teamUrl: string }) => {
const signInLink = signinEmailText(opts);
if (process.env.NODE_ENV === "development") {
@@ -220,7 +227,9 @@ export class Mailer {
};
documentNotification = async (
opts: { to: string } & DocumentNotificationEmailT
opts: {
to: string;
} & DocumentNotificationEmailT
) => {
this.sendMail({
to: opts.to,
@@ -232,7 +241,9 @@ export class Mailer {
};
collectionNotification = async (
opts: { to: string } & CollectionNotificationEmailT
opts: {
to: string;
} & CollectionNotificationEmailT
) => {
this.sendMail({
to: opts.to,
@@ -243,7 +254,7 @@ export class Mailer {
});
};
sendTemplate = async (type: EmailTypes, opts?: Object = {}) => {
sendTemplate = async (type: EmailTypes, opts: Record<string, any> = {}) => {
await emailsQueue.add(
{
type,
@@ -261,4 +272,5 @@ export class Mailer {
}
const mailer = new Mailer();
export default mailer;

View File

@@ -1,10 +1,9 @@
// @flow
import type { Context } from "koa";
import { Context } from "koa";
export default function apexRedirect() {
return async function apexRedirectMiddleware(
ctx: Context,
next: () => Promise<*>
next: () => Promise<any>
) {
if (ctx.headers.host === "getoutline.com") {
ctx.redirect(`https://www.${ctx.headers.host}${ctx.path}`);

View File

@@ -1,8 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import randomstring from "randomstring";
import { ApiKey } from "../models";
import { buildUser, buildTeam } from "../test/factories";
import { flushdb } from "../test/support";
import { ApiKey } from "@server/models";
import { buildUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import auth from "./authentication";
beforeEach(() => flushdb());
@@ -13,20 +12,21 @@ describe("Authentication middleware", () => {
const state = {};
const user = await buildUser();
const authMiddleware = auth();
await authMiddleware(
{
// @ts-expect-error ts-migrate(2740) FIXME: Type '{ get: Mock<string, []>; }' is missing the f... Remove this comment to see the full error message
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
jest.fn()
);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'user' does not exist on type '{}'.
expect(state.user.id).toEqual(user.id);
});
it("should return error with invalid token", async () => {
const state = {};
const user = await buildUser();
@@ -35,9 +35,11 @@ describe("Authentication middleware", () => {
try {
await authMiddleware(
{
// @ts-expect-error ts-migrate(2740) FIXME: Type '{ get: Mock<string, []>; }' is missing the f... Remove this comment to see the full error message
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}error`),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
@@ -48,7 +50,6 @@ describe("Authentication middleware", () => {
}
});
});
describe("with API key", () => {
it("should authenticate user with valid API key", async () => {
const state = {};
@@ -57,20 +58,21 @@ describe("Authentication middleware", () => {
const key = await ApiKey.create({
userId: user.id,
});
await authMiddleware(
{
// @ts-expect-error ts-migrate(2740) FIXME: Type '{ get: Mock<string, []>; }' is missing the f... Remove this comment to see the full error message
request: {
get: jest.fn(() => `Bearer ${key.secret}`),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
jest.fn()
);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'user' does not exist on type '{}'.
expect(state.user.id).toEqual(user.id);
});
it("should return error with invalid API key", async () => {
const state = {};
const authMiddleware = auth();
@@ -78,9 +80,11 @@ describe("Authentication middleware", () => {
try {
await authMiddleware(
{
// @ts-expect-error ts-migrate(2740) FIXME: Type '{ get: Mock<string, []>; }' is missing the f... Remove this comment to see the full error message
request: {
get: jest.fn(() => `Bearer ${randomstring.generate(38)}`),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
@@ -99,9 +103,11 @@ describe("Authentication middleware", () => {
try {
await authMiddleware(
{
// @ts-expect-error ts-migrate(2740) FIXME: Type '{ get: Mock<string, []>; }' is missing the f... Remove this comment to see the full error message
request: {
get: jest.fn(() => "error"),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
@@ -118,21 +124,23 @@ describe("Authentication middleware", () => {
const state = {};
const user = await buildUser();
const authMiddleware = auth();
await authMiddleware(
{
request: {
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Mock<null, []>' is not assignable to type '(... Remove this comment to see the full error message
get: jest.fn(() => null),
query: {
token: user.getJwtToken(),
},
},
body: {},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
jest.fn()
);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'user' does not exist on type '{}'.
expect(state.user.id).toEqual(user.id);
});
@@ -140,20 +148,22 @@ describe("Authentication middleware", () => {
const state = {};
const user = await buildUser();
const authMiddleware = auth();
await authMiddleware(
{
request: {
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Mock<null, []>' is not assignable to type '(... Remove this comment to see the full error message
get: jest.fn(() => null),
},
body: {
token: user.getJwtToken(),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
jest.fn()
);
// @ts-expect-error ts-migrate(2339) FIXME: Property 'user' does not exist on type '{}'.
expect(state.user.id).toEqual(user.id);
});
@@ -165,14 +175,16 @@ describe("Authentication middleware", () => {
suspendedById: admin.id,
});
const authMiddleware = auth();
let error;
try {
await authMiddleware(
{
// @ts-expect-error ts-migrate(2740) FIXME: Type '{ get: Mock<string, []>; }' is missing the f... Remove this comment to see the full error message
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
@@ -181,6 +193,7 @@ describe("Authentication middleware", () => {
} catch (err) {
error = err;
}
expect(error.message).toEqual(
"Your access has been suspended by the team admin"
);
@@ -190,18 +203,21 @@ describe("Authentication middleware", () => {
it("should return an error for deleted team", async () => {
const state = {};
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const user = await buildUser({
teamId: team.id,
});
await team.destroy();
const authMiddleware = auth();
let error;
try {
await authMiddleware(
{
// @ts-expect-error ts-migrate(2740) FIXME: Type '{ get: Mock<string, []>; }' is missing the f... Remove this comment to see the full error message
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
},
// @ts-expect-error ts-migrate(2322) FIXME: Type '{}' is not assignable to type 'DefaultState ... Remove this comment to see the full error message
state,
cache: {},
},
@@ -210,6 +226,7 @@ describe("Authentication middleware", () => {
} catch (err) {
error = err;
}
expect(error.message).toEqual("Invalid token");
});
});

View File

@@ -1,19 +1,23 @@
// @flow
import { User, Team, ApiKey } from "@server/models";
import { getUserForJWT } from "@server/utils/jwt";
import { AuthenticationError, UserSuspendedError } from "../errors";
import { User, Team, ApiKey } from "../models";
import type { ContextWithState } from "../types";
import { getUserForJWT } from "../utils/jwt";
import { ContextWithState } from "../types";
export default function auth(options?: { required?: boolean } = {}) {
export default function auth(
options: {
required?: boolean;
} = {}
) {
return async function authMiddleware(
ctx: ContextWithState,
next: () => Promise<mixed>
next: () => Promise<unknown>
) {
let token;
const authorizationHeader = ctx.request.get("authorization");
if (authorizationHeader) {
const parts = authorizationHeader.split(" ");
if (parts.length === 2) {
const scheme = parts[0];
const credentials = parts[1];
@@ -22,11 +26,13 @@ export default function auth(options?: { required?: boolean } = {}) {
token = credentials;
}
} else {
throw new AuthenticationError(
throw AuthenticationError(
`Bad Authorization header format. Format is "Authorization: Bearer <token>"`
);
}
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
} else if (ctx.body && ctx.body.token) {
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
token = ctx.body.token;
} else if (ctx.request.query.token) {
token = ctx.request.query.token;
@@ -35,15 +41,16 @@ export default function auth(options?: { required?: boolean } = {}) {
}
if (!token && options.required !== false) {
throw new AuthenticationError("Authentication required");
throw AuthenticationError("Authentication required");
}
let user;
if (token) {
if (String(token).match(/^[\w]{38}$/)) {
ctx.state.authType = "api";
let apiKey;
try {
apiKey = await ApiKey.findOne({
where: {
@@ -51,11 +58,11 @@ export default function auth(options?: { required?: boolean } = {}) {
},
});
} catch (err) {
throw new AuthenticationError("Invalid API key");
throw AuthenticationError("Invalid API key");
}
if (!apiKey) {
throw new AuthenticationError("Invalid API key");
throw AuthenticationError("Invalid API key");
}
user = await User.findByPk(apiKey.userId, {
@@ -67,26 +74,29 @@ export default function auth(options?: { required?: boolean } = {}) {
},
],
});
if (!user) {
throw new AuthenticationError("Invalid API key");
throw AuthenticationError("Invalid API key");
}
} else {
ctx.state.authType = "app";
user = await getUserForJWT(String(token));
}
if (user.isSuspended) {
const suspendingAdmin = await User.findOne({
where: { id: user.suspendedById },
where: {
id: user.suspendedById,
},
paranoid: false,
});
throw new UserSuspendedError({ adminEmail: suspendingAdmin.email });
throw UserSuspendedError({
adminEmail: suspendingAdmin.email,
});
}
// not awaiting the promise here so that the request is not blocked
user.updateActiveAt(ctx.request.ip);
ctx.state.token = String(token);
ctx.state.user = user;
}

View File

@@ -1,12 +1,11 @@
// @flow
import { type Context } from "koa";
import { Context } from "koa";
import { snakeCase } from "lodash";
import Sequelize from "sequelize";
export default function errorHandling() {
return async function errorHandlingMiddleware(
ctx: Context,
next: () => Promise<*>
next: () => Promise<any>
) {
try {
await next();
@@ -18,6 +17,7 @@ export default function errorHandling() {
if (err instanceof Sequelize.ValidationError) {
// super basic form error handling
ctx.status = 400;
if (err.errors && err.errors[0]) {
message = `${err.errors[0].message} (${err.errors[0].path})`;
}
@@ -47,7 +47,9 @@ export default function errorHandling() {
data: err.errorData,
};
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
if (!ctx.body.data) {
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
delete ctx.body.data;
}
}

View File

@@ -1,19 +1,19 @@
// @flow
import { type Context } from "koa";
import { Context } from "koa";
import queryString from "query-string";
export default function methodOverride() {
return async function methodOverrideMiddleware(
ctx: Context,
next: () => Promise<*>
next: () => Promise<any>
) {
if (ctx.method === "POST") {
// $FlowFixMe
ctx.body = ctx.request.body;
} else if (ctx.method === "GET") {
ctx.method = 'POST'; // eslint-disable-line
ctx.body = queryString.parse(ctx.querystring);
}
return next();
};
}

View File

@@ -1,15 +1,18 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import { type Context } from "koa";
import type { AccountProvisionerResult } from "../commands/accountProvisioner";
import Logger from "../logging/logger";
import { signIn } from "../utils/authentication";
import { Context } from "koa";
import Logger from "@server/logging/logger";
import { signIn } from "@server/utils/authentication";
import { AccountProvisionerResult } from "../commands/accountProvisioner";
export default function createMiddleware(providerName: string) {
return function passportMiddleware(ctx: Context) {
return passport.authorize(
providerName,
{ session: false },
{
session: false,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'err' implicitly has an 'any' type.
async (err, user, result: AccountProvisionerResult) => {
if (err) {
Logger.error("Error during authentication", err);
@@ -22,6 +25,7 @@ export default function createMiddleware(providerName: string) {
if (process.env.NODE_ENV === "development") {
throw err;
}
return ctx.redirect(`/?notice=auth-error`);
}
@@ -36,13 +40,15 @@ export default function createMiddleware(providerName: string) {
// Handle errors from Azure which come in the format: message, Trace ID,
// Correlation ID, Timestamp in these two query string parameters.
const { error, error_description } = ctx.request.query;
if (error && error_description) {
Logger.error(
"Error from Azure during authentication",
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[]' is not assign... Remove this comment to see the full error message
new Error(error_description)
);
// Display only the descriptive message to the user, log the rest
// @ts-expect-error ts-migrate(2339) FIXME: Property 'split' does not exist on type 'string | ... Remove this comment to see the full error message
const description = error_description.split("Trace ID")[0];
return ctx.redirect(`/?notice=auth-error&description=${description}`);
}

View File

@@ -1,78 +0,0 @@
// @flow
import { type Context } from "koa";
import { isArrayLike } from "lodash";
import validator from "validator";
import { validateColorHex } from "../../shared/utils/color";
import { validateIndexCharacters } from "../../shared/utils/indexCharacters";
import { ParamRequiredError, ValidationError } from "../errors";
export default function validation() {
return function validationMiddleware(ctx: Context, next: () => Promise<*>) {
ctx.assertPresent = (value, message) => {
if (value === undefined || value === null || value === "") {
throw new ParamRequiredError(message);
}
};
ctx.assertArray = (value, message) => {
if (!isArrayLike(value)) {
throw new ValidationError(message);
}
};
ctx.assertIn = (value, options, message) => {
if (!options.includes(value)) {
throw new ValidationError(message);
}
};
ctx.assertSort = (value, model, message = "Invalid sort parameter") => {
if (!Object.keys(model.rawAttributes).includes(value)) {
throw new ValidationError(message);
}
};
ctx.assertNotEmpty = (value, message) => {
if (value === "") {
throw new ValidationError(message);
}
};
ctx.assertEmail = (value = "", message) => {
if (!validator.isEmail(value)) {
throw new ValidationError(message);
}
};
ctx.assertUuid = (value = "", message) => {
if (!validator.isUUID(value)) {
throw new ValidationError(message);
}
};
ctx.assertPositiveInteger = (value, message) => {
if (!validator.isInt(String(value), { min: 0 })) {
throw new ValidationError(message);
}
};
ctx.assertHexColor = (value, message) => {
if (!validateColorHex(value)) {
throw new ValidationError(message);
}
};
ctx.assertValueInArray = (value, values, message) => {
if (!values.includes(value)) {
throw new ValidationError(message);
}
};
ctx.assertIndexCharacters = (value, message) => {
if (!validateIndexCharacters(value)) {
throw new ValidationError(message);
}
};
return next();
};
}

View File

@@ -1,179 +1,175 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('teams', {
await queryInterface.createTable("teams", {
id: {
type: 'UUID',
type: "UUID",
allowNull: false,
primaryKey: true,
},
name: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: true,
},
slackId: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: true,
unique: true,
},
slackData: {
type: 'JSONB',
type: "JSONB",
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
});
await queryInterface.createTable('atlases', {
await queryInterface.createTable("atlases", {
id: {
type: 'UUID',
type: "UUID",
allowNull: false,
primaryKey: true,
},
name: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: true,
},
description: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: true,
},
type: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: true,
},
navigationTree: {
type: 'JSONB',
type: "JSONB",
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
teamId: {
type: 'UUID',
allowNull: false
type: "UUID",
allowNull: false,
},
});
await queryInterface.createTable('users', {
await queryInterface.createTable("users", {
id: {
type: 'UUID',
type: "UUID",
allowNull: false,
primaryKey: true,
},
email: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: false,
},
username: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: false,
},
name: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: false,
},
isAdmin: {
type: 'BOOLEAN',
type: "BOOLEAN",
allowNull: true,
defaultValue: false,
},
slackAccessToken: {
type: 'bytea',
type: "bytea",
allowNull: true,
},
slackId: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
unique: true,
allowNull: false,
},
slackData: {
type: 'JSONB',
type: "JSONB",
allowNull: true,
},
jwtSecret: {
type: 'bytea',
type: "bytea",
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
teamId: {
type: 'UUID',
allowNull: true
type: "UUID",
allowNull: true,
},
});
await queryInterface.createTable('documents', {
await queryInterface.createTable("documents", {
id: {
type: 'UUID',
type: "UUID",
allowNull: false,
primaryKey: true,
},
urlId: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: false,
unique: true,
},
private: {
type: 'BOOLEAN',
type: "BOOLEAN",
allowNull: false,
defaultValue: true,
},
title: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: false,
},
text: {
type: 'TEXT',
type: "TEXT",
allowNull: true,
},
html: {
type: 'TEXT',
type: "TEXT",
allowNull: true,
},
preview: {
type: 'TEXT',
type: "TEXT",
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
userId: {
type: 'UUID',
allowNull: true
type: "UUID",
allowNull: true,
},
atlasId: {
type: 'UUID',
allowNull: true
type: "UUID",
allowNull: true,
},
teamId: {
type: 'UUID',
allowNull: true
type: "UUID",
allowNull: true,
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropAllTables();
},

View File

@@ -1,12 +1,11 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'parentDocumentId', {
await queryInterface.addColumn("documents", "parentDocumentId", {
type: Sequelize.UUID,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'parentDocumentId');
await queryInterface.removeColumn("documents", "parentDocumentId");
},
};

View File

@@ -1,27 +1,23 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addIndex('documents', ['urlId']);
await queryInterface.addIndex('documents', ['id', 'atlasId']);
await queryInterface.addIndex('documents', ['id', 'teamId']);
await queryInterface.addIndex('documents', ['parentDocumentId', 'atlasId']);
await queryInterface.addIndex('atlases', ['id', 'teamId']);
await queryInterface.addIndex('teams', ['slackId']);
await queryInterface.addIndex('users', ['slackId']);
await queryInterface.addIndex("documents", ["urlId"]);
await queryInterface.addIndex("documents", ["id", "atlasId"]);
await queryInterface.addIndex("documents", ["id", "teamId"]);
await queryInterface.addIndex("documents", ["parentDocumentId", "atlasId"]);
await queryInterface.addIndex("atlases", ["id", "teamId"]);
await queryInterface.addIndex("teams", ["slackId"]);
await queryInterface.addIndex("users", ["slackId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('documents', ['urlId']);
await queryInterface.removeIndex('documents', ['id', 'atlasId']);
await queryInterface.removeIndex('documents', ['id', 'teamId']);
await queryInterface.removeIndex('documents', ['parentDocumentId', 'atlasId']);
await queryInterface.removeIndex('atlases', ['id', 'teamId']);
await queryInterface.removeIndex('teams', ['slackId']);
await queryInterface.removeIndex('users', ['slackId']);
await queryInterface.removeIndex("documents", ["urlId"]);
await queryInterface.removeIndex("documents", ["id", "atlasId"]);
await queryInterface.removeIndex("documents", ["id", "teamId"]);
await queryInterface.removeIndex("documents", [
"parentDocumentId",
"atlasId",
]);
await queryInterface.removeIndex("atlases", ["id", "teamId"]);
await queryInterface.removeIndex("teams", ["slackId"]);
await queryInterface.removeIndex("users", ["slackId"]);
},
};

View File

@@ -1,70 +1,66 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('revisions', {
await queryInterface.createTable("revisions", {
id: {
type: 'UUID',
type: "UUID",
allowNull: false,
primaryKey: true,
},
title: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: false,
},
text: {
type: 'TEXT',
type: "TEXT",
allowNull: true,
},
html: {
type: 'TEXT',
type: "TEXT",
allowNull: true,
},
preview: {
type: 'TEXT',
type: "TEXT",
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
userId: {
type: 'UUID',
type: "UUID",
allowNull: false,
references: {
model: 'users',
model: "users",
},
},
documentId: {
type: 'UUID',
type: "UUID",
allowNull: false,
references: {
model: 'documents',
onDelete: 'CASCADE',
model: "documents",
onDelete: "CASCADE",
},
},
});
await queryInterface.addColumn('documents', 'lastModifiedById', {
type: 'UUID',
await queryInterface.addColumn("documents", "lastModifiedById", {
type: "UUID",
allowNull: false,
references: {
model: 'users',
model: "users",
},
});
await queryInterface.addColumn('documents', 'revisionCount', {
type: 'INTEGER',
await queryInterface.addColumn("documents", "revisionCount", {
type: "INTEGER",
defaultValue: 0,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('revisions');
await queryInterface.removeColumn('documents', 'lastModifiedById');
await queryInterface.removeColumn('documents', 'revisionCount');
await queryInterface.dropTable("revisions");
await queryInterface.removeColumn("documents", "lastModifiedById");
await queryInterface.removeColumn("documents", "revisionCount");
},
};

View File

@@ -16,7 +16,6 @@ $$ LANGUAGE plpgsql;
CREATE TRIGGER documents_tsvectorupdate BEFORE INSERT OR UPDATE
ON documents FOR EACH ROW EXECUTE PROCEDURE documents_search_trigger();
`;
const searchCollection = `
ALTER TABLE atlases ADD COLUMN "searchVector" tsvector;
CREATE INDEX atlases_tsv_idx ON atlases USING gin("searchVector");
@@ -33,11 +32,9 @@ $$ LANGUAGE plpgsql;
CREATE TRIGGER atlases_tsvectorupdate BEFORE INSERT OR UPDATE
ON atlases FOR EACH ROW EXECUTE PROCEDURE atlases_search_trigger();
`;
await queryInterface.sequelize.query(searchDocument);
await queryInterface.sequelize.query(searchCollection);
},
down: async (queryInterface, Sequelize) => {
// TODO?
},

View File

@@ -1,12 +1,11 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('atlases', 'creatorId', {
await queryInterface.addColumn("atlases", "creatorId", {
type: Sequelize.UUID,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('atlases', 'creatorId');
await queryInterface.removeColumn("atlases", "creatorId");
},
};

View File

@@ -1,18 +1,16 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('atlases', 'deletedAt', {
await queryInterface.addColumn("atlases", "deletedAt", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn('documents', 'deletedAt', {
await queryInterface.addColumn("documents", "deletedAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('atlases', 'deletedAt');
await queryInterface.removeColumn('documents', 'deletedAt');
await queryInterface.removeColumn("atlases", "deletedAt");
await queryInterface.removeColumn("documents", "deletedAt");
},
};

View File

@@ -1,47 +1,51 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
// Remove old indeces
await queryInterface.removeIndex('documents', ['urlId']);
await queryInterface.removeIndex('documents', ['id', 'atlasId']);
await queryInterface.removeIndex('documents', ['id', 'teamId']);
await queryInterface.removeIndex('documents', ['parentDocumentId', 'atlasId']);
await queryInterface.removeIndex('atlases', ['id', 'teamId']);
await queryInterface.removeIndex("documents", ["urlId"]);
await queryInterface.removeIndex("documents", ["id", "atlasId"]);
await queryInterface.removeIndex("documents", ["id", "teamId"]);
await queryInterface.removeIndex("documents", [
"parentDocumentId",
"atlasId",
]);
await queryInterface.removeIndex("atlases", ["id", "teamId"]);
// Add new ones
await queryInterface.addIndex('documents', ['id', 'deletedAt']);
await queryInterface.addIndex('documents', ['urlId', 'deletedAt']);
await queryInterface.addIndex('documents', ['id', 'atlasId', 'deletedAt']);
await queryInterface.addIndex('documents', ['id', 'teamId', 'deletedAt']);
await queryInterface.addIndex('documents', [
'parentDocumentId',
'atlasId',
'deletedAt',
await queryInterface.addIndex("documents", ["id", "deletedAt"]);
await queryInterface.addIndex("documents", ["urlId", "deletedAt"]);
await queryInterface.addIndex("documents", ["id", "atlasId", "deletedAt"]);
await queryInterface.addIndex("documents", ["id", "teamId", "deletedAt"]);
await queryInterface.addIndex("documents", [
"parentDocumentId",
"atlasId",
"deletedAt",
]);
await queryInterface.addIndex('atlases', ['id', 'deletedAt']);
await queryInterface.addIndex('atlases', ['id', 'teamId', 'deletedAt']);
await queryInterface.addIndex("atlases", ["id", "deletedAt"]);
await queryInterface.addIndex("atlases", ["id", "teamId", "deletedAt"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.addIndex('documents', ['urlId']);
await queryInterface.addIndex('documents', ['id', 'atlasId']);
await queryInterface.addIndex('documents', ['id', 'teamId']);
await queryInterface.addIndex('documents', ['parentDocumentId', 'atlasId']);
await queryInterface.addIndex('atlases', ['id', 'teamId']);
await queryInterface.removeIndex('documents', ['id', 'deletedAt']);
await queryInterface.removeIndex('documents', ['urlId', 'deletedAt']);
await queryInterface.removeIndex('documents', ['id', 'atlasId', 'deletedAt']);
await queryInterface.removeIndex('documents', ['id', 'teamId', 'deletedAt']);
await queryInterface.removeIndex('documents', [
'parentDocumentId',
'atlasId',
'deletedAt',
await queryInterface.addIndex("documents", ["urlId"]);
await queryInterface.addIndex("documents", ["id", "atlasId"]);
await queryInterface.addIndex("documents", ["id", "teamId"]);
await queryInterface.addIndex("documents", ["parentDocumentId", "atlasId"]);
await queryInterface.addIndex("atlases", ["id", "teamId"]);
await queryInterface.removeIndex("documents", ["id", "deletedAt"]);
await queryInterface.removeIndex("documents", ["urlId", "deletedAt"]);
await queryInterface.removeIndex("documents", [
"id",
"atlasId",
"deletedAt",
]);
await queryInterface.removeIndex('atlases', ['id', 'deletedAt']);
await queryInterface.removeIndex('atlases', ['id', 'teamId', 'deletedAt']);
await queryInterface.removeIndex("documents", [
"id",
"teamId",
"deletedAt",
]);
await queryInterface.removeIndex("documents", [
"parentDocumentId",
"atlasId",
"deletedAt",
]);
await queryInterface.removeIndex("atlases", ["id", "deletedAt"]);
await queryInterface.removeIndex("atlases", ["id", "teamId", "deletedAt"]);
},
};

View File

@@ -1,15 +1,14 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'createdById', {
type: 'UUID',
await queryInterface.addColumn("documents", "createdById", {
type: "UUID",
allowNull: true,
references: {
model: 'users',
model: "users",
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'createdById');
await queryInterface.removeColumn("documents", "createdById");
},
};

View File

@@ -1,10 +1,10 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'collaboratorIds', {
await queryInterface.addColumn("documents", "collaboratorIds", {
type: Sequelize.ARRAY(Sequelize.UUID),
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'collaboratorIds');
await queryInterface.removeColumn("documents", "collaboratorIds");
},
};

View File

@@ -1,12 +1,11 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('atlases', 'urlId', {
await queryInterface.addColumn("atlases", "urlId", {
type: Sequelize.STRING,
unique: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('atlases', 'urlId');
await queryInterface.removeColumn("atlases", "urlId");
},
};

View File

@@ -1,9 +1,8 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addIndex('revisions', ['documentId']);
await queryInterface.addIndex("revisions", ["documentId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('revisions', ['documentId']);
await queryInterface.removeIndex("revisions", ["documentId"]);
},
};

View File

@@ -1,40 +1,39 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('apiKeys', {
await queryInterface.createTable("apiKeys", {
id: {
type: 'UUID',
type: "UUID",
allowNull: false,
primaryKey: true,
},
name: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: true,
},
secret: {
type: 'CHARACTER VARYING',
type: "CHARACTER VARYING",
allowNull: false,
unique: true,
},
userId: {
type: 'UUID',
allowNull: true
type: "UUID",
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: false,
},
deletedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
type: "TIMESTAMP WITH TIME ZONE",
allowNull: true,
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('apiKeys');
await queryInterface.dropTable("apiKeys");
},
};

View File

@@ -1,11 +1,10 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addIndex('apiKeys', ['secret', 'deletedAt']);
await queryInterface.addIndex('apiKeys', ['userId', 'deletedAt']);
await queryInterface.addIndex("apiKeys", ["secret", "deletedAt"]);
await queryInterface.addIndex("apiKeys", ["userId", "deletedAt"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('apiKeys', ['secret', 'deletedAt']);
await queryInterface.removeIndex('apiKeys', ['userId', 'deletedAt']);
await queryInterface.removeIndex("apiKeys", ["secret", "deletedAt"]);
await queryInterface.removeIndex("apiKeys", ["userId", "deletedAt"]);
},
};

View File

@@ -1,24 +1,23 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'slackId', {
await queryInterface.changeColumn("users", "slackId", {
type: Sequelize.STRING,
unique: false,
allowNull: true,
});
await queryInterface.changeColumn('teams', 'slackId', {
await queryInterface.changeColumn("teams", "slackId", {
type: Sequelize.STRING,
unique: false,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'slackId', {
await queryInterface.changeColumn("users", "slackId", {
type: Sequelize.STRING,
unique: true,
allowNull: false,
});
await queryInterface.changeColumn('teams', 'slackId', {
await queryInterface.changeColumn("teams", "slackId", {
type: Sequelize.STRING,
unique: true,
allowNull: false,

View File

@@ -1,25 +1,23 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'email', {
await queryInterface.changeColumn("users", "email", {
type: Sequelize.STRING,
unique: true,
allowNull: false,
});
await queryInterface.changeColumn('users', 'username', {
await queryInterface.changeColumn("users", "username", {
type: Sequelize.STRING,
unique: true,
allowNull: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'email', {
await queryInterface.changeColumn("users", "email", {
type: Sequelize.STRING,
unique: false,
allowNull: true,
});
await queryInterface.changeColumn('users', 'username', {
await queryInterface.changeColumn("users", "username", {
type: Sequelize.STRING,
unique: false,
allowNull: true,

View File

@@ -1,12 +1,11 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'passwordDigest', {
await queryInterface.addColumn("users", "passwordDigest", {
type: Sequelize.STRING,
allowNull: true,
});
},
down: async (queryInterface, _Sequelize) => {
await queryInterface.removeColumn('users', 'passwordDigest');
await queryInterface.removeColumn("users", "passwordDigest");
},
};

View File

@@ -1,14 +1,13 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.renameTable('atlases', 'collections');
await queryInterface.addColumn('collections', 'documentStructure', {
await queryInterface.renameTable("atlases", "collections");
await queryInterface.addColumn("collections", "documentStructure", {
type: Sequelize.JSONB,
allowNull: true,
});
},
down: async (queryInterface, _Sequelize) => {
await queryInterface.renameTable('collections', 'atlases');
await queryInterface.removeColumn('atlases', 'documentStructure');
await queryInterface.renameTable("collections", "atlases");
await queryInterface.removeColumn("atlases", "documentStructure");
},
};

View File

@@ -1,41 +1,39 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface
.createTable('views', {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
},
count: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 1,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex('views', ['documentId', 'userId'], {
indicesType: 'UNIQUE',
});
await queryInterface.createTable("views", {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
},
count: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 1,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex("views", ["documentId", "userId"], {
indicesType: "UNIQUE",
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('views', ['documentId', 'userId']);
await queryInterface.dropTable('views');
await queryInterface.removeIndex("views", ["documentId", "userId"]);
await queryInterface.dropTable("views");
},
};

View File

@@ -1,36 +1,34 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface
.createTable('stars', {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex('stars', ['documentId', 'userId'], {
indicesType: 'UNIQUE',
await queryInterface.createTable("stars", {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex("stars", ["documentId", "userId"], {
indicesType: "UNIQUE",
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('stars', ['documentId', 'userId']);
await queryInterface.dropTable('stars');
await queryInterface.removeIndex("stars", ["documentId", "userId"]);
await queryInterface.dropTable("stars");
},
};

View File

@@ -1,16 +1,15 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.removeConstraint('users', 'users_email_key', {})
await queryInterface.removeConstraint('users', 'users_username_key', {})
await queryInterface.removeConstraint("users", "users_email_key", {});
await queryInterface.removeConstraint("users", "users_username_key", {});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'email', {
await queryInterface.changeColumn("users", "email", {
type: Sequelize.STRING,
unique: true,
allowNull: false,
});
await queryInterface.changeColumn('users', 'username', {
await queryInterface.changeColumn("users", "username", {
type: Sequelize.STRING,
unique: true,
allowNull: false,

View File

@@ -1,13 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'slackId', {
await queryInterface.changeColumn("users", "slackId", {
type: Sequelize.STRING,
unique: true,
allowNull: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeConstraint('users', 'users_slack_id_idx');
await queryInterface.removeConstraint("users", "users_slack_id_idx");
},
};

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