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,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,
}
);
});
}