chore: Typescript database models (#2886)

closes #2798
This commit is contained in:
Tom Moor
2022-01-06 18:24:28 -08:00
committed by GitHub
parent d3cbf250e6
commit b20a341f0c
207 changed files with 5624 additions and 5315 deletions

View File

@@ -22,5 +22,6 @@
],
"setupFilesAfterEnv": [
"./app/test/setup.ts"
]
],
"testEnvironment": "jsdom"
}

View File

@@ -8,59 +8,6 @@ declare module "sequelize-encrypted";
declare module "styled-components-breakpoint";
declare module "formidable/lib/file";
declare module "socket.io-client";
declare module "socket.io-redis" {
import { Redis } from "ioredis";
type Config = {
pubClient: Redis;
subClient: Redis;
};
const socketRedisAdapter: (config: Config) => void;
export = socketRedisAdapter;
}
declare module "socketio-auth" {
import IO from "socket.io";
type AuthenticatedSocket = IO.Socket & {
client: IO.Client & {
user: any;
};
};
type AuthenticateCallback = (
socket: AuthenticatedSocket,
data: { token: string },
callback: (err: Error | null, allow: boolean) => void
) => Promise<void>;
type PostAuthenticateCallback = (
socket: AuthenticatedSocket
) => Promise<void>;
type AuthenticationConfig = {
authenticate: AuthenticateCallback;
postAuthenticate: PostAuthenticateCallback;
};
const SocketAuth: (io: IO.Server, config: AuthenticationConfig) => void;
export = SocketAuth;
}
declare module "oy-vey";
declare module "emoji-regex" {
const RegExpFactory: () => RegExp;
export = RegExpFactory;
}
declare module "*.png" {
const value: any;
export = value;

View File

@@ -31,7 +31,7 @@
"url": "https://github.com/sponsors/outline"
},
"engines": {
"node": ">= 12 <=16"
"node": ">= 14 <=16"
},
"repository": {
"type": "git",
@@ -149,12 +149,14 @@
"react-waypoint": "^10.1.0",
"react-window": "^1.8.6",
"reakit": "^1.3.10",
"reflect-metadata": "^0.1.13",
"regenerator-runtime": "^0.13.7",
"rich-markdown-editor": "^11.21.3",
"semver": "^7.3.2",
"sequelize": "^6.9.0",
"sequelize-cli": "^6.3.0",
"sequelize-encrypted": "^1.0.0",
"sequelize-typescript": "^2.1.1",
"slate": "0.45.0",
"slate-md-serializer": "5.5.4",
"slug": "^4.0.4",
@@ -209,7 +211,7 @@
"@types/koa-static": "^4.0.2",
"@types/markdown-it": "^12.2.3",
"@types/natural-sort": "^0.0.21",
"@types/node": "^15.12.0",
"@types/node": "15.12.2",
"@types/nodemailer": "^6.4.4",
"@types/passport-oauth2": "^1.4.11",
"@types/prosemirror-inputrules": "^1.0.4",
@@ -242,12 +244,14 @@
"@types/tmp": "^0.2.2",
"@types/turndown": "^5.0.1",
"@types/utf8": "^3.0.0",
"@types/validator": "^13.7.1",
"@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.2.2",
"babel-jest": "^27.4.5",
"babel-loader": "^8.1.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"babel-plugin-transform-typescript-metadata": "^0.3.2",
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.3",
"concurrently": "^6.2.1",
"enzyme": "^3.11.0",
@@ -264,7 +268,7 @@
"fetch-test-server": "^1.1.0",
"html-webpack-plugin": "4.5.1",
"i18next-parser": "^4.7.0",
"jest-cli": "^26.0.0",
"jest-cli": "^27.4.5",
"jest-fetch-mock": "^3.0.3",
"koa-webpack-dev-middleware": "^1.4.5",
"koa-webpack-hot-middleware": "^1.0.3",

View File

@@ -1,24 +1,19 @@
{
"presets": [
"@babel/preset-react",
"@babel/preset-typescript",
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"targets": {
"node": "12"
},
"useBuiltIns": "usage"
"node": "14"
}
}
]
],
"@babel/preset-typescript"
],
"plugins": [
"transform-class-properties",
"tsconfig-paths-module-resolver",
"babel-plugin-transform-typescript-metadata",
["@babel/plugin-proposal-decorators", { "legacy": true }],
[
"transform-inline-environment-variables",
{
@@ -27,6 +22,7 @@
"SOURCE_VERSION"
]
}
]
],
"tsconfig-paths-module-resolver"
]
}

View File

@@ -1,10 +1,8 @@
import { onAuthenticatePayload } from "@hocuspocus/server";
import { Document } from "@server/models";
import Document from "@server/models/Document";
import { can } from "@server/policies";
import { getUserForJWT } from "@server/utils/jwt";
import { AuthenticationError } from "../errors";
import policy from "../policies";
const { can } = policy;
export default class Authentication {
async onAuthenticate({

View File

@@ -1,8 +1,9 @@
import { onChangePayload, onLoadDocumentPayload } from "@hocuspocus/server";
import invariant from "invariant";
import { debounce } from "lodash";
import * as Y from "yjs";
import Logger from "@server/logging/logger";
import { Document } from "@server/models";
import Document from "@server/models/Document";
import documentUpdater from "../commands/documentUpdater";
import markdownToYDoc from "./utils/markdownToYDoc";
@@ -20,6 +21,7 @@ export default class Persistence {
}
const document = await Document.findByPk(documentId);
invariant(document, "Document not found");
if (document.state) {
const ydoc = new Y.Doc();

View File

@@ -1,30 +1,19 @@
import { Collection, UserAuthentication } from "@server/models";
import mailer from "@server/mailer";
import Collection from "@server/models/Collection";
import UserAuthentication from "@server/models/UserAuthentication";
import { buildUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import mailer from "../mailer";
import accountProvisioner from "./accountProvisioner";
jest.mock("../mailer");
jest.mock("aws-sdk", () => {
const mS3 = {
createPresignedPost: jest.fn(),
putObject: jest.fn().mockReturnThis(),
promise: jest.fn(),
};
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => {
// @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";
it("should create a new user and team", async () => {
const spy = jest.spyOn(mailer, "sendTemplate");
const { user, team, isNewTeam, isNewUser } = await accountProvisioner({
ip,
user: {
@@ -48,7 +37,7 @@ describe("accountProvisioner", () => {
scopes: ["read"],
},
});
const authentications = await user.getAuthentications();
const authentications = await user.$get("authentications");
const auth = authentications[0];
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
@@ -58,19 +47,22 @@ describe("accountProvisioner", () => {
expect(user.username).toEqual("jtester");
expect(isNewUser).toEqual(true);
expect(isNewTeam).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
expect(spy).toHaveBeenCalled();
const collectionCount = await Collection.count();
expect(collectionCount).toEqual(1);
spy.mockRestore();
});
it("should update exising user and authentication", async () => {
const spy = jest.spyOn(mailer, "sendTemplate");
const existingTeam = await buildTeam();
const providers = await existingTeam.getAuthenticationProviders();
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
const existing = await buildUser({
teamId: existingTeam.id,
});
const authentications = await existing.getAuthentications();
const authentications = await existing.$get("authentications");
const authentication = authentications[0];
const newEmail = "test@example.com";
const newUsername = "tname";
@@ -98,21 +90,23 @@ describe("accountProvisioner", () => {
},
});
const auth = await UserAuthentication.findByPk(authentication.id);
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
expect(auth.scopes[0]).toEqual("read");
expect(auth?.accessToken).toEqual("123");
expect(auth?.scopes.length).toEqual(1);
expect(auth?.scopes[0]).toEqual("read");
expect(user.email).toEqual(newEmail);
expect(user.username).toEqual(newUsername);
expect(isNewTeam).toEqual(false);
expect(isNewUser).toEqual(false);
expect(mailer.sendTemplate).not.toHaveBeenCalled();
expect(spy).not.toHaveBeenCalled();
const collectionCount = await Collection.count();
expect(collectionCount).toEqual(0);
spy.mockRestore();
});
it("should throw an error when authentication provider is disabled", async () => {
const existingTeam = await buildTeam();
const providers = await existingTeam.getAuthenticationProviders();
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
await authenticationProvider.update({
enabled: false,
@@ -120,7 +114,7 @@ describe("accountProvisioner", () => {
const existing = await buildUser({
teamId: existingTeam.id,
});
const authentications = await existing.getAuthentications();
const authentications = await existing.$get("authentications");
const authentication = authentications[0];
let error;
@@ -129,7 +123,7 @@ describe("accountProvisioner", () => {
ip,
user: {
name: existing.name,
email: existing.email,
email: existing.email!,
avatarUrl: existing.avatarUrl,
},
team: {
@@ -155,8 +149,9 @@ describe("accountProvisioner", () => {
});
it("should create a new user in an existing team", async () => {
const spy = jest.spyOn(mailer, "sendTemplate");
const team = await buildTeam();
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const { user, isNewUser } = await accountProvisioner({
ip,
@@ -181,7 +176,7 @@ describe("accountProvisioner", () => {
scopes: ["read"],
},
});
const authentications = await user.getAuthentications();
const authentications = await user.$get("authentications");
const auth = authentications[0];
expect(auth.accessToken).toEqual("123");
expect(auth.scopes.length).toEqual(1);
@@ -189,9 +184,11 @@ describe("accountProvisioner", () => {
expect(user.email).toEqual("jenny@example.com");
expect(user.username).toEqual("jtester");
expect(isNewUser).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
expect(spy).toHaveBeenCalled();
// should provision welcome collection
const collectionCount = await Collection.count();
expect(collectionCount).toEqual(1);
spy.mockRestore();
});
});

View File

@@ -1,12 +1,12 @@
import invariant from "invariant";
import Sequelize from "sequelize";
import { Collection, Team, User } from "@server/models";
import { UniqueConstraintError } from "sequelize";
import {
AuthenticationError,
EmailAuthenticationRequiredError,
AuthenticationProviderDisabledError,
} from "../errors";
import mailer from "../mailer";
} from "@server/errors";
import mailer from "@server/mailer";
import { Collection, Team, User } from "@server/models";
import teamCreator from "./teamCreator";
import userCreator from "./userCreator";
@@ -15,14 +15,14 @@ type Props = {
user: {
name: string;
email: string;
avatarUrl?: string;
avatarUrl?: string | null;
username?: string;
};
team: {
name: string;
domain?: string;
subdomain: string;
avatarUrl?: string;
avatarUrl?: string | null;
};
authenticationProvider: {
name: string;
@@ -37,9 +37,7 @@ type Props = {
};
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;
@@ -123,7 +121,7 @@ export default async function accountProvisioner({
isNewTeam,
};
} catch (err) {
if (err instanceof Sequelize.UniqueConstraintError) {
if (err instanceof UniqueConstraintError) {
const exists = await User.findOne({
where: {
email: userParams.email,

View File

@@ -13,7 +13,6 @@ export default async function attachmentCreator({
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;

View File

@@ -7,11 +7,8 @@ export default async function collectionExporter({
user,
ip,
}: {
// @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;
}) {
@@ -39,6 +36,10 @@ export default async function collectionExporter({
});
fileOperation.user = user;
fileOperation.collection = collection;
if (collection) {
fileOperation.collection = collection;
}
return fileOperation;
}

View File

@@ -7,6 +7,7 @@ import collectionImporter from "./collectionImporter";
jest.mock("../utils/s3");
beforeEach(() => flushdb());
describe("collectionImporter", () => {
const ip = "127.0.0.1";

View File

@@ -24,7 +24,6 @@ export default async function collectionImporter({
ip,
}: {
file: FileWithPath;
// @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;
@@ -48,7 +47,6 @@ export default async function collectionImporter({
// store progress and pointers
// @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
@@ -57,7 +55,6 @@ export default async function collectionImporter({
} = {};
// @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;
} = {};
@@ -186,13 +183,13 @@ 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
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
await document.save({
fields: ["text"],
});

View File

@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Document, Event, User } from "@server/models";
export default async function documentCreator({
@@ -21,18 +22,16 @@ export default async function documentCreator({
publish?: boolean;
collectionId: string;
parentDocumentId?: string;
templateDocument?: Document;
templateDocument?: Document | null;
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;
}): Promise<Document> {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
const templateId = templateDocument ? templateDocument.id : undefined;
const document = await Document.create({
parentDocumentId,
@@ -47,7 +46,6 @@ 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({
@@ -83,10 +81,13 @@ export default async function documentCreator({
// reload to get all of the data needed to present (user, collection etc)
// we need to specify publishedAt to bypass default scope that only returns
// published documents
return Document.findOne({
const doc = await Document.findOne({
where: {
id: document.id,
publishedAt: document.publishedAt,
},
});
invariant(doc, "Document must exist");
return doc;
}

View File

@@ -1,12 +1,13 @@
import path from "path";
import File from "formidable/lib/file";
import { Attachment } from "@server/models";
import Attachment from "@server/models/Attachment";
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";

View File

@@ -144,7 +144,6 @@ export default async function documentImporter({
user,
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;
file: File;
ip: string;

View File

@@ -1,4 +1,4 @@
import { Attachment } from "@server/models";
import Attachment from "@server/models/Attachment";
import {
buildDocument,
buildAttachment,
@@ -10,6 +10,7 @@ import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import documentMover from "./documentMover";
beforeEach(() => flushdb());
describe("documentMover", () => {
const ip = "127.0.0.1";
@@ -33,7 +34,7 @@ describe("documentMover", () => {
const document = await buildDocument({
collectionId: collection.id,
});
await document.archive();
await document.archive(user.id);
const response = await documentMover({
user,
document,
@@ -63,13 +64,11 @@ 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(
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);
});
@@ -98,22 +97,17 @@ describe("documentMover", () => {
// 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(
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);
});
@@ -152,8 +146,8 @@ describe("documentMover", () => {
// 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);
expect(newAttachment?.documentId).toBe(newDocument.id);
expect(newAttachment?.key).toBe(attachment.key);
await document.reload();
expect(document.collectionId).toBe(newCollection.id);
});

View File

@@ -1,16 +1,22 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { Document, Attachment, Collection, Pin, Event } from "@server/models";
import { sequelize } from "@server/database/sequelize";
import {
User,
Document,
Attachment,
Collection,
Pin,
Event,
} from "@server/models";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { sequelize } from "../sequelize";
import pinDestroyer from "./pinDestroyer";
async function copyAttachments(
document: Document,
options?: { transaction?: Transaction }
) {
// @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);
@@ -18,7 +24,6 @@ async function copyAttachments(
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,
},
@@ -29,6 +34,7 @@ async function copyAttachments(
// 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) {
// @ts-expect-error dataValues exists
const { id, ...rest } = existing.dataValues;
const attachment = await Attachment.create(
{ ...rest, documentId },
@@ -41,6 +47,21 @@ async function copyAttachments(
return text;
}
type Props = {
user: User;
document: Document;
collectionId: string;
parentDocumentId?: string | null;
index?: number;
ip: string;
};
type Result = {
collections: Collection[];
documents: Document[];
collectionChanged: boolean;
};
export default async function documentMover({
user,
document,
@@ -49,19 +70,11 @@ export default async function documentMover({
// 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: any;
collectionId: string;
parentDocumentId?: string | null;
index?: number;
ip: string;
}) {
}: Props): Promise<Result> {
let transaction: Transaction | undefined;
const collectionChanged = collectionId !== document.collectionId;
const previousCollectionId = document.collectionId;
const result = {
const result: Result = {
collections: [],
documents: [],
collectionChanged,
@@ -77,7 +90,6 @@ export default async function documentMover({
document.lastModifiedById = user.id;
document.updatedBy = user;
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 {
@@ -88,12 +100,13 @@ export default async function documentMover({
transaction,
paranoid: false,
});
const [
documentJson,
fromIndex,
] = (await collection.removeDocumentInStructure(document, {
const response = await collection?.removeDocumentInStructure(document, {
save: false,
})) || [undefined, index];
});
const documentJson = response?.[0];
const fromIndex = response?.[1] || 0;
// if we're reordering from within the same parent
// the original and destination collection are the same,
@@ -110,7 +123,7 @@ export default async function documentMover({
// 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({
await collection?.save({
transaction,
});
document.text = await copyAttachments(document, {
@@ -124,36 +137,37 @@ export default async function documentMover({
document.lastModifiedById = user.id;
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
const newCollection = collectionChanged
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, {
transaction,
})
: collection;
await newCollection.addDocumentToStructure(document, toIndex, {
invariant(newCollection, "collection should exist");
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) {
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 loopChildren = async (documentId: string) => {
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, {
@@ -161,8 +175,10 @@ export default async function documentMover({
});
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
if (newCollection) {
child.collection = newCollection;
}
result.documents.push(child);
})
);
@@ -189,13 +205,13 @@ export default async function documentMover({
await document.save({
transaction,
});
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
if (newCollection) {
document.collection = newCollection;
}
result.documents.push(document);
if (transaction) {
await transaction.commit();
}
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
@@ -213,9 +229,7 @@ export default async function documentMover({
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,

View File

@@ -4,18 +4,8 @@ import { buildAttachment, buildDocument } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import documentPermanentDeleter from "./documentPermanentDeleter";
jest.mock("aws-sdk", () => {
const mS3 = {
createPresignedPost: jest.fn(),
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({

View File

@@ -1,15 +1,14 @@
import { QueryTypes } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import Logger from "@server/logging/logger";
import { Document, Attachment } from "@server/models";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { sequelize } from "../sequelize";
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.`
);
}
@@ -23,16 +22,13 @@ 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,
type: 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,
},
@@ -41,7 +37,6 @@ 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,
},
@@ -59,7 +54,6 @@ 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,3 +1,4 @@
import invariant from "invariant";
import { uniq } from "lodash";
import { Node } from "prosemirror-model";
import { schema, serializer } from "rich-markdown-editor";
@@ -15,6 +16,8 @@ export default async function documentUpdater({
userId?: string;
}) {
const document = await Document.findByPk(documentId);
invariant(document, "document not found");
const state = Y.encodeStateAsUpdate(ydoc);
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
const text = serializer.serialize(node, undefined);
@@ -30,6 +33,7 @@ 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

@@ -3,18 +3,8 @@ import { buildAdmin, buildFileOperation } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import fileOperationDeleter from "./fileOperationDeleter";
jest.mock("aws-sdk", () => {
const mS3 = {
createPresignedPost: jest.fn(),
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";

View File

@@ -1,10 +1,8 @@
import { sequelize } from "@server/database/sequelize";
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
) {
@@ -19,6 +17,7 @@ export default async function fileOperationDeleter(
name: "fileOperations.delete",
teamId: user.teamId,
actorId: user.id,
// @ts-expect-error dataValues does exist
data: fileOp.dataValues,
ip,
},

View File

@@ -25,8 +25,8 @@ describe("pinCreator", () => {
expect(pin.collectionId).toEqual(null);
expect(pin.createdById).toEqual(user.id);
expect(pin.index).toEqual("P");
expect(event.name).toEqual("pins.create");
expect(event.modelId).toEqual(pin.id);
expect(event!.name).toEqual("pins.create");
expect(event!.modelId).toEqual(pin.id);
});
it("should create pin to collection", async () => {
@@ -48,8 +48,8 @@ describe("pinCreator", () => {
expect(pin.collectionId).toEqual(document.collectionId);
expect(pin.createdById).toEqual(user.id);
expect(pin.index).toEqual("P");
expect(event.name).toEqual("pins.create");
expect(event.modelId).toEqual(pin.id);
expect(event.collectionId).toEqual(pin.collectionId);
expect(event!.name).toEqual("pins.create");
expect(event!.modelId).toEqual(pin.id);
expect(event!.collectionId).toEqual(pin.collectionId);
});
});

View File

@@ -1,13 +1,14 @@
import fractionalIndex from "fractional-index";
import { Sequelize, Op, WhereOptions } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import { ValidationError } from "@server/errors";
import { Pin, Event } from "@server/models";
import { sequelize, Op } from "@server/sequelize";
import { Pin, User, Event } from "@server/models";
const MAX_PINS = 8;
type Props = {
/** The user creating the pin */
user: any;
user: User;
/** The document to pin */
documentId: string;
/** The collection to pin the document in. If no collection is provided then it will be pinned to home */
@@ -31,11 +32,11 @@ export default async function pinCreator({
collectionId,
ip,
...rest
}: Props): Promise<any> {
}: Props): Promise<Pin> {
let { index } = rest;
const where = {
const where: WhereOptions<Pin> = {
teamId: user.teamId,
...(collectionId ? { collectionId } : { collectionId: { [Op.eq]: null } }),
...(collectionId ? { collectionId } : { collectionId: { [Op.is]: null } }),
};
const count = await Pin.count({ where });
@@ -51,7 +52,7 @@ export default async function pinCreator({
order: [
// using LC_COLLATE:"C" because we need byte order to drive the sorting
// find only the last pin so we can create an index after it
sequelize.literal('"pins"."index" collate "C" DESC'),
Sequelize.literal('"pin"."index" collate "C" DESC'),
["updatedAt", "ASC"],
],
});

View File

@@ -32,7 +32,7 @@ describe("pinCreator", () => {
expect(count).toEqual(0);
const event = await Event.findOne();
expect(event.name).toEqual("pins.delete");
expect(event.modelId).toEqual(pin.id);
expect(event!.name).toEqual("pins.delete");
expect(event!.modelId).toEqual(pin.id);
});
});

View File

@@ -1,12 +1,12 @@
import { Transaction } from "sequelize";
import { Event } from "@server/models";
import { sequelize } from "@server/sequelize";
import { sequelize } from "@server/database/sequelize";
import { Event, Pin, User } from "@server/models";
type Props = {
/** The user destroying the pin */
user: any;
user: User;
/** The pin to destroy */
pin: any;
pin: Pin;
/** The IP address of the user creating the pin */
ip: string;
/** Optional existing transaction */
@@ -25,7 +25,7 @@ export default async function pinDestroyer({
pin,
ip,
transaction: t,
}: Props): Promise<any> {
}: Props): Promise<Pin> {
const transaction = t || (await sequelize.transaction());
try {

View File

@@ -1,13 +1,13 @@
import { Event } from "@server/models";
import { sequelize } from "@server/sequelize";
import { sequelize } from "@server/database/sequelize";
import { Event, Pin, User } from "@server/models";
type Props = {
/** The user updating the pin */
user: any;
user: User;
/** The existing pin */
pin: any;
pin: Pin;
/** The index to pin the document at */
index?: string;
index: string;
/** The IP address of the user creating the pin */
ip: string;
};
@@ -24,7 +24,7 @@ export default async function pinUpdater({
pin,
index,
ip,
}: Props): Promise<any> {
}: Props): Promise<Pin> {
const transaction = await sequelize.transaction();
try {

View File

@@ -4,6 +4,7 @@ import { flushdb } from "@server/test/support";
import revisionCreator from "./revisionCreator";
beforeEach(() => flushdb());
describe("revisionCreator", () => {
const ip = "127.0.0.1";
@@ -21,8 +22,8 @@ describe("revisionCreator", () => {
const event = await Event.findOne();
expect(revision.documentId).toEqual(document.id);
expect(revision.userId).toEqual(user.id);
expect(event.name).toEqual("revisions.create");
expect(event.modelId).toEqual(revision.id);
expect(event.createdAt).toEqual(document.updatedAt);
expect(event!.name).toEqual("revisions.create");
expect(event!.modelId).toEqual(revision.id);
expect(event!.createdAt).toEqual(document.updatedAt);
});
});

View File

@@ -1,5 +1,5 @@
import { sequelize } from "@server/database/sequelize";
import { Document, User, Event, Revision } from "@server/models";
import { sequelize } from "../sequelize";
export default async function revisionCreator({
document,
@@ -7,7 +7,6 @@ export default async function revisionCreator({
ip,
}: {
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;
}) {
@@ -21,13 +20,10 @@ export default async function revisionCreator({
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,
},

View File

@@ -2,18 +2,8 @@ import { buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import teamCreator from "./teamCreator";
jest.mock("aws-sdk", () => {
const mS3 = {
createPresignedPost: jest.fn(),
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({
@@ -73,7 +63,7 @@ describe("teamCreator", () => {
expect(authenticationProvider.name).toEqual("google");
expect(authenticationProvider.providerId).toEqual("allowed-domain.com");
expect(isNewTeam).toEqual(false);
const providers = await team.getAuthenticationProviders();
const providers = await team.$get("authenticationProviders");
expect(providers.length).toEqual(2);
});

View File

@@ -1,34 +1,34 @@
import invariant from "invariant";
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 { sequelize } from "../sequelize";
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;
};
type Props = {
name: string;
domain?: string;
subdomain: string;
avatarUrl?: string | null;
authenticationProvider: {
name: string;
providerId: string;
};
};
export default async function teamCreator({
name,
domain,
subdomain,
avatarUrl,
authenticationProvider,
}: {
name: string;
domain?: string;
subdomain: string;
avatarUrl?: string;
authenticationProvider: {
name: string;
providerId: string;
};
}): Promise<TeamCreatorResult> {
}: Props): Promise<TeamCreatorResult> {
let authP = await AuthenticationProvider.findOne({
where: authenticationProvider,
include: [
@@ -61,7 +61,12 @@ export default async function teamCreator({
// authentication provider to the existing team
if (teamCount === 1 && domain && getAllowedDomains().includes(domain)) {
const team = await Team.findOne();
authP = await team.createAuthenticationProvider(authenticationProvider);
invariant(team, "Team should exist");
authP = await team.$create<AuthenticationProvider>(
"authenticationProvider",
authenticationProvider
);
return {
authenticationProvider: authP,
team,
@@ -84,7 +89,7 @@ export default async function teamCreator({
});
}
const transaction = await sequelize.transaction();
const transaction = await Team.sequelize!.transaction();
let team;
try {

View File

@@ -9,18 +9,8 @@ import {
import { flushdb } from "@server/test/support";
import teamPermanentDeleter from "./teamPermanentDeleter";
jest.mock("aws-sdk", () => {
const mS3 = {
createPresignedPost: jest.fn(),
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({

View File

@@ -1,3 +1,4 @@
import { sequelize } from "@server/database/sequelize";
import Logger from "@server/logging/logger";
import {
ApiKey,
@@ -17,9 +18,7 @@ import {
SearchQuery,
Share,
} 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(
@@ -45,16 +44,14 @@ 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",
`Deleting attachments ${options.offset} ${
options.offset + options.limit
(options.offset || 0) + (options?.limit || 0)
}`
);
await Promise.all(
// @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
@@ -74,9 +71,7 @@ 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: {

View File

@@ -3,12 +3,13 @@ import { flushdb } from "@server/test/support";
import userCreator from "./userCreator";
beforeEach(() => flushdb());
describe("userCreator", () => {
const ip = "127.0.0.1";
it("should update exising user and authentication", async () => {
const existing = await buildUser();
const authentications = await existing.getAuthentications();
const authentications = await existing.$get("authentications");
const existingAuth = authentications[0];
const newEmail = "test@example.com";
const newUsername = "tname";
@@ -37,7 +38,7 @@ describe("userCreator", () => {
it("should create user with deleted user matching providerId", async () => {
const existing = await buildUser();
const authentications = await existing.getAuthentications();
const authentications = await existing.$get("authentications");
const existingAuth = authentications[0];
const newEmail = "test@example.com";
await existing.destroy();
@@ -63,7 +64,7 @@ describe("userCreator", () => {
it("should handle duplicate providerId for different iDP", async () => {
const existing = await buildUser();
const authentications = await existing.getAuthentications();
const authentications = await existing.$get("authentications");
const existingAuth = authentications[0];
let error;
@@ -89,7 +90,7 @@ describe("userCreator", () => {
it("should create a new user", async () => {
const team = await buildTeam();
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
@@ -119,7 +120,7 @@ describe("userCreator", () => {
const team = await buildTeam({
defaultUserRole: "viewer",
});
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
@@ -143,7 +144,7 @@ describe("userCreator", () => {
const team = await buildTeam({
defaultUserRole: "viewer",
});
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
@@ -187,11 +188,11 @@ describe("userCreator", () => {
const invite = await buildInvite({
teamId: team.id,
});
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: invite.name,
email: invite.email,
email: invite.email!,
teamId: invite.teamId,
ip,
authentication: {

View File

@@ -1,15 +1,29 @@
import { Op } from "sequelize";
import { Event, Team, User, UserAuthentication } from "@server/models";
import { sequelize } from "../sequelize";
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;
};
type Props = {
name: string;
email: string;
username?: string;
isAdmin?: boolean;
avatarUrl?: string | null;
teamId: string;
ip: string;
authentication: {
authenticationProviderId: string;
providerId: string;
scopes: string[];
accessToken?: string;
refreshToken?: string;
};
};
export default async function userCreator({
name,
email,
@@ -19,22 +33,7 @@ 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> {
}: Props): Promise<UserCreatorResult> {
const { authenticationProviderId, providerId, ...rest } = authentication;
const auth = await UserAuthentication.findOne({
where: {
@@ -90,7 +89,7 @@ export default async function userCreator({
email,
teamId,
lastActiveAt: {
[Op.eq]: null,
[Op.is]: null,
},
},
include: [
@@ -105,7 +104,7 @@ 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) {
const transaction = await sequelize.transaction();
const transaction = await User.sequelize!.transaction();
let auth;
try {
@@ -118,9 +117,13 @@ export default async function userCreator({
transaction,
}
);
auth = await invite.createAuthentication(authentication, {
transaction,
});
auth = await invite.$create<UserAuthentication>(
"authentication",
authentication,
{
transaction,
}
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
@@ -135,13 +138,15 @@ export default async function userCreator({
}
// No auth, no user this is an entirely new sign in.
const transaction = await sequelize.transaction();
const transaction = await User.sequelize!.transaction();
try {
const { defaultUserRole } = await Team.findByPk(teamId, {
const team = await Team.findByPk(teamId, {
attributes: ["defaultUserRole"],
transaction,
});
const defaultUserRole = team?.defaultUserRole;
const user = await User.create(
{
name,

View File

@@ -3,6 +3,7 @@ import { flushdb } from "@server/test/support";
import userDestroyer from "./userDestroyer";
beforeEach(() => flushdb());
describe("userDestroyer", () => {
const ip = "127.0.0.1";

View File

@@ -1,15 +1,14 @@
import { Op } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import { Event, User } from "@server/models";
import { ValidationError } from "../errors";
import { Op, sequelize } from "../sequelize";
export default async function userDestroyer({
user,
actor,
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;
// @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;
}) {

View File

@@ -3,6 +3,7 @@ import { flushdb } from "@server/test/support";
import userInviter from "./userInviter";
beforeEach(() => flushdb());
describe("userInviter", () => {
const ip = "127.0.0.1";
@@ -10,8 +11,8 @@ describe("userInviter", () => {
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
{
role: "member",
email: "test@example.com",
name: "Test",
},
@@ -26,8 +27,8 @@ describe("userInviter", () => {
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
{
role: "member",
email: " ",
name: "Test",
},
@@ -42,8 +43,8 @@ describe("userInviter", () => {
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
{
role: "member",
email: "notanemail",
name: "Test",
},
@@ -58,13 +59,13 @@ describe("userInviter", () => {
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
{
role: "member",
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
{
role: "member",
email: "the@SAME.COM",
name: "Test",
},
@@ -79,9 +80,9 @@ describe("userInviter", () => {
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,
role: "member",
email: user.email!,
name: user.name,
},
],

View File

@@ -1,7 +1,8 @@
import invariant from "invariant";
import { uniqBy } from "lodash";
import { Role } from "@shared/types";
import mailer from "@server/mailer";
import { User, Event, Team } from "@server/models";
import mailer from "../mailer";
type Invite = {
name: string;
@@ -14,16 +15,16 @@ export default async function userInviter({
invites,
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;
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);
invariant(team, "team not found");
// filter out empties and obvious non-emails
const compactedInvites = invites.filter(
(invite) => !!invite.email.trim() && invite.email.match("@")
@@ -44,7 +45,6 @@ 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)

View File

@@ -1,9 +1,10 @@
import { GroupUser } from "@server/models";
import GroupUser from "@server/models/GroupUser";
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";
@@ -46,7 +47,7 @@ describe("userSuspender", () => {
const group = await buildGroup({
teamId: user.teamId,
});
await group.addUser(user, {
await group.$add("user", user, {
through: {
createdById: user.id,
},

View File

@@ -1,18 +1,19 @@
import { Transaction } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import { User, Event, GroupUser } from "@server/models";
import { ValidationError } from "../errors";
import { sequelize } from "../sequelize";
type Props = {
user: User;
actorId: string;
ip: string;
};
export default async function userSuspender({
user,
actorId,
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;
actorId: string;
ip: string;
}): Promise<void> {
}: Props): Promise<void> {
if (user.id === actorId) {
throw ValidationError("Unable to suspend the current user");
}

View File

@@ -1,22 +1,13 @@
import Sequelize from "sequelize";
import EncryptedField from "sequelize-encrypted";
import Logger from "./logging/logger";
import { Sequelize } from "sequelize-typescript";
import Logger from "../logging/logger";
import * as models from "../models";
const isProduction = process.env.NODE_ENV === "production";
const isSSLDisabled = process.env.PGSSLMODE === "disable";
export const encryptedFields = () =>
EncryptedField(Sequelize, process.env.SECRET_KEY);
export const DataTypes = Sequelize;
export const Op = Sequelize.Op;
// @ts-expect-error ts-migrate(2351) FIXME: This expression is not constructable.
export const sequelize = new Sequelize(
process.env.DATABASE_URL || process.env.DATABASE_CONNECTION_POOL_URL,
process.env.DATABASE_URL || process.env.DATABASE_CONNECTION_POOL_URL || "",
{
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'msg' implicitly has an 'any' type.
logging: (msg) => Logger.debug("database", msg),
typeValidation: true,
dialectOptions: {
@@ -28,5 +19,6 @@ export const sequelize = new Sequelize(
}
: false,
},
models: Object.values(models),
}
);

View File

@@ -0,0 +1,9 @@
import SequelizeEncrypted from "sequelize-encrypted";
import { Sequelize } from "sequelize-typescript";
/**
* Encrypted field storage, use via the Encrypted decorator, not directly.
*/
export default function vaults() {
return SequelizeEncrypted(Sequelize, process.env.SECRET_KEY);
}

View File

@@ -9,9 +9,7 @@ import Header from "./components/Header";
import Heading from "./components/Heading";
export type Props = {
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
actor: User;
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
collection: Collection;
eventName: string;
unsubscribeUrl: string;

View File

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

View File

@@ -28,7 +28,11 @@ export function AdminRequiredError(
});
}
export function UserSuspendedError({ adminEmail }: { adminEmail: string }) {
export function UserSuspendedError({
adminEmail,
}: {
adminEmail: string | undefined;
}) {
return httpErrors(403, "Your access has been suspended by the team admin", {
id: "user_suspended",
errorData: {

View File

@@ -1,4 +1,5 @@
import env from "./env"; // eslint-disable-line import/order
/* eslint-disable import/order */
import env from "./env";
import "./tracing"; // must come before importing any instrumented module
@@ -10,6 +11,7 @@ import logger from "koa-logger";
import onerror from "koa-onerror";
import Router from "koa-router";
import { uniq } from "lodash";
import { AddressInfo } from "net";
import stoppable from "stoppable";
import throng from "throng";
import Logger from "./logging/logger";
@@ -104,8 +106,10 @@ async function start(id: number, disconnect: () => void) {
server.on("listening", () => {
const address = server.address();
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
Logger.info("lifecycle", `Listening on http://localhost:${address.port}`);
Logger.info(
"lifecycle",
`Listening on http://localhost:${(address as AddressInfo).port}`
);
});
server.listen(normalizedPortFlag || env.PORT || "3000");
process.once("SIGTERM", shutdown);

View File

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

View File

@@ -251,8 +251,8 @@ export class Mailer {
});
};
sendTemplate = async (type: EmailTypes, opts: Record<string, any> = {}) => {
await emailsQueue.add(
sendTemplate = (type: EmailTypes, opts: Record<string, any> = {}) => {
return emailsQueue.add(
{
type,
opts,

View File

@@ -1,5 +1,5 @@
import randomstring from "randomstring";
import { ApiKey } from "@server/models";
import ApiKey from "@server/models/ApiKey";
import { buildUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import auth from "./authentication";

View File

@@ -89,7 +89,7 @@ export default function auth(
paranoid: false,
});
throw UserSuspendedError({
adminEmail: suspendingAdmin.email,
adminEmail: suspendingAdmin?.email || undefined,
});
}

View File

@@ -1,6 +1,6 @@
import { Context, Next } from "koa";
import { snakeCase } from "lodash";
import Sequelize from "sequelize";
import { ValidationError } from "sequelize";
export default function errorHandling() {
return async function errorHandlingMiddleware(ctx: Context, next: Next) {
@@ -11,7 +11,7 @@ export default function errorHandling() {
let message = err.message || err.name;
let error;
if (err instanceof Sequelize.ValidationError) {
if (err instanceof ValidationError) {
// super basic form error handling
ctx.status = 400;

View File

@@ -1,37 +1,43 @@
import randomstring from "randomstring";
import { DataTypes, sequelize } from "../sequelize";
import {
Column,
Table,
Unique,
BeforeValidate,
BelongsTo,
ForeignKey,
} from "sequelize-typescript";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
const ApiKey = sequelize.define(
"apiKey",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: DataTypes.STRING,
secret: {
type: DataTypes.STRING,
unique: true,
},
},
{
paranoid: true,
hooks: {
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'key' implicitly has an 'any' type.
beforeValidate: (key) => {
key.secret = randomstring.generate(38);
},
},
@Table({ tableName: "apiKeys", modelName: "apiKey" })
@Fix
class ApiKey extends ParanoidModel {
@Column
name: string;
@Unique
@Column
secret: string;
// hooks
@BeforeValidate
static async generateSecret(model: ApiKey) {
if (!model.secret) {
model.secret = randomstring.generate(38);
}
}
);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
ApiKey.associate = (models) => {
ApiKey.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
};
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column
userId: string;
}
export default ApiKey;

View File

@@ -1,88 +1,107 @@
import path from "path";
import { FindOptions } from "sequelize";
import {
BeforeDestroy,
BelongsTo,
Column,
Default,
ForeignKey,
IsIn,
Table,
DataType,
} from "sequelize-typescript";
import { deleteFromS3, getFileByKey } from "@server/utils/s3";
import { DataTypes, sequelize } from "../sequelize";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const Attachment = sequelize.define(
"attachment",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
key: {
type: DataTypes.STRING,
allowNull: false,
},
url: {
type: DataTypes.STRING,
allowNull: false,
},
contentType: {
type: DataTypes.STRING,
allowNull: false,
},
size: {
type: DataTypes.BIGINT,
allowNull: false,
},
acl: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "public-read",
validate: {
isIn: [["private", "public-read"]],
},
},
},
{
getterMethods: {
name: function () {
return path.parse(this.key).base;
},
redirectUrl: function () {
return `/api/attachments.redirect?id=${this.id}`;
},
isPrivate: function () {
return this.acl === "private";
},
buffer: function () {
return getFileByKey(this.key);
},
},
@Table({ tableName: "attachments", modelName: "attachment" })
@Fix
class Attachment extends BaseModel {
@Column
key: string;
@Column
url: string;
@Column
contentType: string;
@Column(DataType.BIGINT)
size: number;
@Default("public-read")
@IsIn([["private", "public-read"]])
@Column
acl: string;
// getters
get name() {
return path.parse(this.key).base;
}
);
Attachment.findAllInBatches = async (
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'query' implicitly has an 'any' type.
query,
callback: (
// @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
attachments: Array<Attachment>,
query: Record<string, any>
) => Promise<void>
) => {
if (!query.offset) query.offset = 0;
if (!query.limit) query.limit = 10;
let results;
get redirectUrl() {
return `/api/attachments.redirect?id=${this.id}`;
}
do {
results = await Attachment.findAll(query);
await callback(results, query);
query.offset += query.limit;
} while (results.length >= query.limit);
};
get isPrivate() {
return this.acl === "private";
}
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
Attachment.beforeDestroy(async (model) => {
await deleteFromS3(model.key);
});
get buffer() {
return getFileByKey(this.key);
}
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Attachment.associate = (models) => {
Attachment.belongsTo(models.Team);
Attachment.belongsTo(models.Document);
Attachment.belongsTo(models.User);
};
// hooks
@BeforeDestroy
static async deleteAttachmentFromS3(model: Attachment) {
await deleteFromS3(model.key);
}
// associations
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string | null;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
static async findAllInBatches(
query: FindOptions<Attachment>,
callback: (
attachments: Array<Attachment>,
query: FindOptions<Attachment>
) => Promise<void>
) {
if (!query.offset) query.offset = 0;
if (!query.limit) query.limit = 10;
let results;
do {
results = await this.findAll(query);
await callback(results, query);
query.offset += query.limit;
} while (results.length >= query.limit);
}
}
export default Attachment;

View File

@@ -1,68 +1,89 @@
import { Op } from "sequelize";
import {
BelongsTo,
Column,
CreatedAt,
DataType,
Default,
ForeignKey,
HasMany,
Table,
Model,
IsUUID,
PrimaryKey,
} from "sequelize-typescript";
import { ValidationError } from "../errors";
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'providers' implicitly has type 'any[]' i... Remove this comment to see the full error message
import providers from "../routes/auth/providers";
import { DataTypes, Op, sequelize } from "../sequelize";
import Team from "./Team";
import UserAuthentication from "./UserAuthentication";
import Fix from "./decorators/Fix";
const AuthenticationProvider = sequelize.define(
"authentication_providers",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
validate: {
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'providers' implicitly has an 'any[]' typ... Remove this comment to see the full error message
isIn: [providers.map((p) => p.id)],
@Table({
tableName: "authentication_providers",
modelName: "authentication_provider",
updatedAt: false,
})
@Fix
class AuthenticationProvider extends Model {
@IsUUID(4)
@PrimaryKey
@Default(DataType.UUIDV4)
@Column(DataType.UUID)
id: string;
@Column
name: string;
@Default(true)
@Column
enabled: boolean;
@Column
providerId: string;
@CreatedAt
createdAt: Date;
// associations
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@HasMany(() => UserAuthentication, "providerId")
userAuthentications: UserAuthentication[];
// instance methods
disable = async () => {
const res = await (this
.constructor as typeof AuthenticationProvider).findAndCountAll({
where: {
teamId: this.teamId,
enabled: true,
id: {
[Op.ne]: this.id,
},
},
},
enabled: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
providerId: {
type: DataTypes.STRING,
},
},
{
timestamps: true,
updatedAt: false,
}
);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
AuthenticationProvider.associate = (models) => {
AuthenticationProvider.belongsTo(models.Team);
AuthenticationProvider.hasMany(models.UserAuthentication);
};
AuthenticationProvider.prototype.disable = async function () {
const res = await AuthenticationProvider.findAndCountAll({
where: {
teamId: this.teamId,
enabled: true,
id: {
[Op.ne]: this.id,
},
},
limit: 1,
});
if (res.count >= 1) {
return this.update({
enabled: false,
limit: 1,
});
} else {
throw ValidationError("At least one authentication provider is required");
}
};
AuthenticationProvider.prototype.enable = async function () {
return this.update({
enabled: true,
});
};
if (res.count >= 1) {
return this.update({
enabled: false,
});
} else {
throw ValidationError("At least one authentication provider is required");
}
};
enable = () => {
return this.update({
enabled: true,
});
};
}
export default AuthenticationProvider;

View File

@@ -1,27 +1,38 @@
import { DataTypes, sequelize } from "../sequelize";
import {
DataType,
BelongsTo,
ForeignKey,
Column,
Table,
} from "sequelize-typescript";
import Document from "./Document";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const Backlink = sequelize.define("backlink", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
});
@Table({ tableName: "backlinks", modelName: "backlink" })
@Fix
class Backlink extends BaseModel {
@BelongsTo(() => User, "userId")
user: User;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Backlink.associate = (models) => {
Backlink.belongsTo(models.Document, {
as: "document",
foreignKey: "documentId",
});
Backlink.belongsTo(models.Document, {
as: "reverseDocument",
foreignKey: "reverseDocumentId",
});
Backlink.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
};
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => Document, "reverseDocumentId")
reverseDocument: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
reverseDocumentId: string;
}
export default Backlink;

View File

@@ -1,6 +1,5 @@
import randomstring from "randomstring";
import { v4 as uuidv4 } from "uuid";
import { Collection, Document } from "@server/models";
import {
buildUser,
buildGroup,
@@ -10,9 +9,12 @@ import {
} from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
import slugify from "@server/utils/slugify";
import Collection from "./Collection";
import Document from "./Document";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
describe("#url", () => {
test("should return correct url for the collection", () => {
const collection = new Collection({
@@ -21,6 +23,7 @@ describe("#url", () => {
expect(collection.url).toBe(`/collection/untitled-${collection.urlId}`);
});
});
describe("getDocumentParents", () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
@@ -31,7 +34,7 @@ describe("getDocumentParents", () => {
],
});
const result = collection.getDocumentParents(document.id);
expect(result.length).toBe(1);
expect(result?.length).toBe(1);
expect(result[0]).toBe(parent.id);
});
@@ -44,7 +47,7 @@ describe("getDocumentParents", () => {
],
});
const result = collection.getDocumentParents(parent.id);
expect(result.length).toBe(0);
expect(result?.length).toBe(0);
});
test("should not error if documentStructure is empty", async () => {
@@ -55,6 +58,7 @@ describe("getDocumentParents", () => {
expect(result).toBe(undefined);
});
});
describe("getDocumentTree", () => {
test("should return document tree", async () => {
const document = await buildDocument();
@@ -79,6 +83,7 @@ describe("getDocumentTree", () => {
expect(collection.getDocumentTree(document.id)).toEqual(document.toJSON());
});
});
describe("isChildDocument", () => {
test("should return false with unexpected data", async () => {
const document = await buildDocument();
@@ -128,6 +133,7 @@ describe("isChildDocument", () => {
expect(collection.isChildDocument(document.id, parent.id)).toEqual(false);
});
});
describe("#addDocumentToStructure", () => {
test("should add as last element without index", async () => {
const { collection } = await seed();
@@ -138,8 +144,8 @@ describe("#addDocumentToStructure", () => {
parentDocumentId: null,
});
await collection.addDocumentToStructure(newDocument);
expect(collection.documentStructure.length).toBe(2);
expect(collection.documentStructure[1].id).toBe(id);
expect(collection.documentStructure!.length).toBe(2);
expect(collection.documentStructure![1].id).toBe(id);
});
test("should add with an index", async () => {
@@ -151,8 +157,8 @@ describe("#addDocumentToStructure", () => {
parentDocumentId: null,
});
await collection.addDocumentToStructure(newDocument, 1);
expect(collection.documentStructure.length).toBe(2);
expect(collection.documentStructure[1].id).toBe(id);
expect(collection.documentStructure!.length).toBe(2);
expect(collection.documentStructure![1].id).toBe(id);
});
test("should add as a child if with parent", async () => {
@@ -164,10 +170,10 @@ describe("#addDocumentToStructure", () => {
parentDocumentId: document.id,
});
await collection.addDocumentToStructure(newDocument, 1);
expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[0].id).toBe(document.id);
expect(collection.documentStructure[0].children.length).toBe(1);
expect(collection.documentStructure[0].children[0].id).toBe(id);
expect(collection.documentStructure!.length).toBe(1);
expect(collection.documentStructure![0].id).toBe(document.id);
expect(collection.documentStructure![0].children.length).toBe(1);
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
test("should add as a child if with parent with index", async () => {
@@ -185,10 +191,10 @@ describe("#addDocumentToStructure", () => {
});
await collection.addDocumentToStructure(newDocument);
await collection.addDocumentToStructure(secondDocument, 0);
expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[0].id).toBe(document.id);
expect(collection.documentStructure[0].children.length).toBe(2);
expect(collection.documentStructure[0].children[0].id).toBe(id);
expect(collection.documentStructure!.length).toBe(1);
expect(collection.documentStructure![0].id).toBe(document.id);
expect(collection.documentStructure![0].children.length).toBe(2);
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
describe("options: documentJson", () => {
test("should append supplied json over document's own", async () => {
@@ -201,27 +207,32 @@ describe("#addDocumentToStructure", () => {
});
await collection.addDocumentToStructure(newDocument, undefined, {
documentJson: {
id,
title: "Parent",
url: "parent",
children: [
{
id,
title: "Totally fake",
children: [],
url: "totally-fake",
},
],
},
});
expect(collection.documentStructure[1].children.length).toBe(1);
expect(collection.documentStructure[1].children[0].id).toBe(id);
expect(collection.documentStructure![1].children.length).toBe(1);
expect(collection.documentStructure![1].children[0].id).toBe(id);
});
});
});
describe("#updateDocument", () => {
test("should update root document's data", async () => {
const { collection, document } = await seed();
document.title = "Updated title";
await document.save();
await collection.updateDocument(document);
expect(collection.documentStructure[0].title).toBe("Updated title");
expect(collection.documentStructure![0].title).toBe("Updated title");
});
test("should update child document's data", async () => {
@@ -241,11 +252,12 @@ describe("#updateDocument", () => {
await newDocument.save();
await collection.updateDocument(newDocument);
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded.documentStructure[0].children[0].title).toBe(
expect(reloaded!.documentStructure![0].children[0].title).toBe(
"Updated title"
);
});
});
describe("#removeDocument", () => {
test("should save if removing", async () => {
const { collection, document } = await seed();
@@ -257,7 +269,7 @@ describe("#removeDocument", () => {
test("should remove documents from root", async () => {
const { collection, document } = await seed();
await collection.deleteDocument(document);
expect(collection.documentStructure.length).toBe(0);
expect(collection.documentStructure!.length).toBe(0);
// Verify that the document was removed
const collectionDocuments = await Document.findAndCountAll({
where: {
@@ -281,10 +293,10 @@ describe("#removeDocument", () => {
text: "content",
});
await collection.addDocumentToStructure(newDocument);
expect(collection.documentStructure[0].children.length).toBe(1);
expect(collection.documentStructure![0].children.length).toBe(1);
// Remove the document
await collection.deleteDocument(document);
expect(collection.documentStructure.length).toBe(0);
expect(collection.documentStructure!.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
where: {
collectionId: collection.id,
@@ -308,13 +320,13 @@ describe("#removeDocument", () => {
text: "content",
});
await collection.addDocumentToStructure(newDocument);
expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[0].children.length).toBe(1);
expect(collection.documentStructure!.length).toBe(1);
expect(collection.documentStructure![0].children.length).toBe(1);
// Remove the document
await collection.deleteDocument(newDocument);
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded.documentStructure.length).toBe(1);
expect(reloaded.documentStructure[0].children.length).toBe(0);
expect(reloaded!.documentStructure!.length).toBe(1);
expect(reloaded!.documentStructure![0].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
where: {
collectionId: collection.id,
@@ -323,6 +335,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(1);
});
});
describe("#membershipUserIds", () => {
test("should return collection and group memberships", async () => {
const team = await buildTeam();
@@ -350,42 +363,42 @@ describe("#membershipUserIds", () => {
teamId,
});
const createdById = users[0].id;
await group1.addUser(users[0], {
await group1.$add("user", users[0], {
through: {
createdById,
},
});
await group1.addUser(users[1], {
await group1.$add("user", users[1], {
through: {
createdById,
},
});
await group2.addUser(users[2], {
await group2.$add("user", users[2], {
through: {
createdById,
},
});
await group2.addUser(users[3], {
await group2.$add("user", users[3], {
through: {
createdById,
},
});
await collection.addUser(users[4], {
await collection.$add("user", users[4], {
through: {
createdById,
},
});
await collection.addUser(users[5], {
await collection.$add("user", users[5], {
through: {
createdById,
},
});
await collection.addGroup(group1, {
await collection.$add("group", group1, {
through: {
createdById,
},
});
await collection.addGroup(group2, {
await collection.$add("group", group2, {
through: {
createdById,
},
@@ -394,18 +407,19 @@ describe("#membershipUserIds", () => {
expect(membershipUserIds.length).toBe(6);
});
});
describe("#findByPk", () => {
test("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response.id).toBe(collection.id);
expect(response!.id).toBe(collection.id);
});
test("should return collection when urlId is present", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response.id).toBe(collection.id);
expect(response!.id).toBe(collection.id);
});
test("should return undefined when incorrect uuid type", async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,44 @@
import { DataTypes, sequelize } from "../sequelize";
import {
BelongsTo,
Column,
Default,
ForeignKey,
IsIn,
Model,
Table,
DataType,
} from "sequelize-typescript";
import Collection from "./Collection";
import Group from "./Group";
import User from "./User";
import Fix from "./decorators/Fix";
const CollectionGroup = sequelize.define(
"collection_group",
{
permission: {
type: DataTypes.STRING,
defaultValue: "read_write",
validate: {
isIn: [["read", "read_write", "maintainer"]],
},
},
},
{
timestamps: true,
paranoid: true,
}
);
@Table({ tableName: "collection_groups", modelName: "collection_group" })
@Fix
class CollectionGroup extends Model {
@Default("read_write")
@IsIn([["read", "read_write", "maintainer"]])
@Column
permission: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
CollectionGroup.associate = (models) => {
CollectionGroup.belongsTo(models.Collection, {
as: "collection",
foreignKey: "collectionId",
primary: true,
});
CollectionGroup.belongsTo(models.Group, {
as: "group",
foreignKey: "groupId",
primary: true,
});
CollectionGroup.belongsTo(models.User, {
as: "createdBy",
foreignKey: "createdById",
});
};
// associations
@BelongsTo(() => Collection, "collectionId")
collection: Collection;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string;
@BelongsTo(() => Group, "groupId")
group: Group;
@ForeignKey(() => Group)
@Column(DataType.UUID)
groupId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
}
export default CollectionGroup;

View File

@@ -1,35 +1,47 @@
import { DataTypes, sequelize } from "../sequelize";
import {
Column,
ForeignKey,
BelongsTo,
Default,
IsIn,
Table,
DataType,
Model,
} from "sequelize-typescript";
import Collection from "./Collection";
import User from "./User";
import Fix from "./decorators/Fix";
const CollectionUser = sequelize.define(
"collection_user",
{
permission: {
type: DataTypes.STRING,
defaultValue: "read_write",
validate: {
isIn: [["read", "read_write", "maintainer"]],
},
},
},
{
timestamps: true,
}
);
@Table({ tableName: "collection_users", modelName: "collection_user" })
@Fix
class CollectionUser extends Model {
@Default("read_write")
@IsIn([["read", "read_write", "maintainer"]])
@Column
permission: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
CollectionUser.associate = (models) => {
CollectionUser.belongsTo(models.Collection, {
as: "collection",
foreignKey: "collectionId",
});
CollectionUser.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
CollectionUser.belongsTo(models.User, {
as: "createdBy",
foreignKey: "createdById",
});
};
// associations
@BelongsTo(() => Collection, "collectionId")
collection: Collection;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
}
export default CollectionUser;

View File

@@ -1,4 +1,4 @@
import { Document } from "@server/models";
import Document from "@server/models/Document";
import {
buildDocument,
buildCollection,
@@ -10,6 +10,7 @@ import slugify from "@server/utils/slugify";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
describe("#getSummary", () => {
test("should strip markdown", async () => {
const document = await buildDocument({
@@ -23,7 +24,7 @@ paragraph 2`,
test("should strip title when no version", async () => {
const document = await buildDocument({
version: null,
version: 0,
text: `# Heading
*paragraph*`,
@@ -31,6 +32,7 @@ paragraph 2`,
expect(document.getSummary()).toBe("paragraph");
});
});
describe("#migrateVersion", () => {
test("should maintain empty paragraph under headings", async () => {
const document = await buildDocument({
@@ -155,6 +157,7 @@ paragraph`);
`);
});
});
describe("#searchForTeam", () => {
test("should return search results from public collections", async () => {
const team = await buildTeam();
@@ -168,7 +171,7 @@ describe("#searchForTeam", () => {
});
const { results } = await Document.searchForTeam(team, "test");
expect(results.length).toBe(1);
expect(results[0].document.id).toBe(document.id);
expect(results[0].document?.id).toBe(document.id);
});
test("should not return search results from private collections", async () => {
@@ -252,6 +255,7 @@ describe("#searchForTeam", () => {
expect(totalCount).toBe("0");
});
});
describe("#searchForUser", () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
@@ -270,7 +274,7 @@ describe("#searchForUser", () => {
});
const { results } = await Document.searchForUser(user, "test");
expect(results.length).toBe(1);
expect(results[0].document.id).toBe(document.id);
expect(results[0].document?.id).toBe(document.id);
});
test("should handle no collections", async () => {
@@ -352,44 +356,47 @@ describe("#searchForUser", () => {
expect(totalCount).toBe("0");
});
});
describe("#delete", () => {
test("should soft delete and set last modified", async () => {
let document = await buildDocument();
const document = await buildDocument();
const user = await buildUser();
await document.delete(user.id);
document = await Document.findByPk(document.id, {
const newDocument = await Document.findByPk(document.id, {
paranoid: false,
});
expect(document.lastModifiedById).toBe(user.id);
expect(document.deletedAt).toBeTruthy();
expect(newDocument?.lastModifiedById).toBe(user.id);
expect(newDocument?.deletedAt).toBeTruthy();
});
test("should soft delete templates", async () => {
let document = await buildDocument({
const document = await buildDocument({
template: true,
});
const user = await buildUser();
await document.delete(user.id);
document = await Document.findByPk(document.id, {
const newDocument = await Document.findByPk(document.id, {
paranoid: false,
});
expect(document.lastModifiedById).toBe(user.id);
expect(document.deletedAt).toBeTruthy();
expect(newDocument?.lastModifiedById).toBe(user.id);
expect(newDocument?.deletedAt).toBeTruthy();
});
test("should soft delete archived", async () => {
let document = await buildDocument({
const document = await buildDocument({
archivedAt: new Date(),
});
const user = await buildUser();
await document.delete(user.id);
document = await Document.findByPk(document.id, {
const newDocument = await Document.findByPk(document.id, {
paranoid: false,
});
expect(document.lastModifiedById).toBe(user.id);
expect(document.deletedAt).toBeTruthy();
expect(newDocument?.lastModifiedById).toBe(user.id);
expect(newDocument?.deletedAt).toBeTruthy();
});
});
describe("#save", () => {
test("should have empty previousTitles by default", async () => {
const document = await buildDocument();
@@ -414,14 +421,16 @@ describe("#save", () => {
expect(document.previousTitles.length).toBe(3);
});
});
describe("#findByPk", () => {
test("should return document when urlId is correct", async () => {
const { document } = await seed();
const id = `${slugify(document.title)}-${document.urlId}`;
const response = await Document.findByPk(id);
expect(response.id).toBe(document.id);
expect(response?.id).toBe(document.id);
});
});
describe("tasks", () => {
test("should consider all the possible checkTtems", async () => {
const document = await buildDocument({

File diff suppressed because it is too large Load Diff

View File

@@ -1,126 +1,164 @@
import {
ForeignKey,
AfterCreate,
BeforeCreate,
BelongsTo,
Column,
IsIP,
IsUUID,
Table,
DataType,
} from "sequelize-typescript";
import { globalEventQueue } from "../queues";
import { DataTypes, sequelize } from "../sequelize";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const Event = sequelize.define("event", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
modelId: DataTypes.UUID,
name: DataTypes.STRING,
ip: DataTypes.STRING,
data: DataTypes.JSONB,
});
@Table({ tableName: "events", modelName: "event" })
@Fix
class Event extends BaseModel {
@IsUUID(4)
@Column(DataType.UUID)
modelId: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Event.associate = (models) => {
Event.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
Event.belongsTo(models.User, {
as: "actor",
foreignKey: "actorId",
});
Event.belongsTo(models.Collection, {
as: "collection",
foreignKey: "collectionId",
});
Event.belongsTo(models.Collection, {
as: "document",
foreignKey: "documentId",
});
Event.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
};
@Column
name: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'event' implicitly has an 'any' type.
Event.beforeCreate((event) => {
if (event.ip) {
// cleanup IPV6 representations of IPV4 addresses
event.ip = event.ip.replace(/^::ffff:/, "");
@IsIP
@Column
ip: string | null;
@Column(DataType.JSONB)
data: Record<string, any>;
// hooks
@BeforeCreate
static cleanupIp(model: Event) {
if (model.ip) {
// cleanup IPV6 representations of IPV4 addresses
model.ip = model.ip.replace(/^::ffff:/, "");
}
}
});
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'event' implicitly has an 'any' type.
Event.afterCreate((event) => {
globalEventQueue.add(event);
});
// add can be used to send events into the event system without recording them
// in the database or audit trail
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'event' implicitly has an 'any' type.
Event.add = (event) => {
const now = new Date();
globalEventQueue.add(
Event.build({
createdAt: now,
updatedAt: now,
...event,
})
);
};
@AfterCreate
static async enqueue(model: Event) {
globalEventQueue.add(model);
}
Event.ACTIVITY_EVENTS = [
"collections.create",
"collections.delete",
"collections.move",
"collections.permission_changed",
"documents.publish",
"documents.archive",
"documents.unarchive",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"revisions.create",
"users.create",
];
Event.AUDIT_EVENTS = [
"api_keys.create",
"api_keys.delete",
"authenticationProviders.update",
"collections.create",
"collections.update",
"collections.permission_changed",
"collections.move",
"collections.add_user",
"collections.remove_user",
"collections.add_group",
"collections.remove_group",
"collections.delete",
"collections.export_all",
"documents.create",
"documents.publish",
"documents.update",
"documents.archive",
"documents.unarchive",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"groups.create",
"groups.update",
"groups.delete",
"pins.create",
"pins.update",
"pins.delete",
"revisions.create",
"shares.create",
"shares.update",
"shares.revoke",
"teams.update",
"users.create",
"users.update",
"users.signin",
"users.promote",
"users.demote",
"users.invite",
"users.suspend",
"users.activate",
"users.delete",
];
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => User, "actorId")
actor: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
actorId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
// add can be used to send events into the event system without recording them
// in the database or audit trail
static add(event: Partial<Event>) {
const now = new Date();
globalEventQueue.add(
this.build({
createdAt: now,
updatedAt: now,
...event,
})
);
}
static ACTIVITY_EVENTS = [
"collections.create",
"collections.delete",
"collections.move",
"collections.permission_changed",
"documents.publish",
"documents.archive",
"documents.unarchive",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"revisions.create",
"users.create",
];
static AUDIT_EVENTS = [
"api_keys.create",
"api_keys.delete",
"authenticationProviders.update",
"collections.create",
"collections.update",
"collections.permission_changed",
"collections.move",
"collections.add_user",
"collections.remove_user",
"collections.add_group",
"collections.remove_group",
"collections.delete",
"collections.export_all",
"documents.create",
"documents.publish",
"documents.update",
"documents.archive",
"documents.unarchive",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"groups.create",
"groups.update",
"groups.delete",
"pins.create",
"pins.update",
"pins.delete",
"revisions.create",
"shares.create",
"shares.update",
"shares.revoke",
"teams.update",
"users.create",
"users.update",
"users.signin",
"users.promote",
"users.demote",
"users.invite",
"users.suspend",
"users.activate",
"users.delete",
];
}
export default Event;

View File

@@ -1,76 +1,88 @@
import {
ForeignKey,
DefaultScope,
Column,
BeforeDestroy,
BelongsTo,
Table,
DataType,
} from "sequelize-typescript";
import { deleteFromS3 } from "@server/utils/s3";
import { DataTypes, sequelize } from "../sequelize";
import Collection from "./Collection";
import Team from "./Team";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const FileOperation = sequelize.define("file_operations", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
type: {
type: DataTypes.ENUM("import", "export"),
allowNull: false,
},
state: {
type: DataTypes.ENUM(
"creating",
"uploading",
"complete",
"error",
"expired"
),
allowNull: false,
},
key: {
type: DataTypes.STRING,
},
url: {
type: DataTypes.STRING,
},
size: {
type: DataTypes.BIGINT,
allowNull: false,
},
});
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
FileOperation.beforeDestroy(async (model) => {
await deleteFromS3(model.key);
});
@DefaultScope(() => ({
include: [
{
model: User,
as: "user",
paranoid: false,
},
{
model: Collection,
as: "collection",
paranoid: false,
},
],
}))
@Table({ tableName: "file_operations", modelName: "file_operation" })
@Fix
class FileOperation extends BaseModel {
@Column(DataType.ENUM("import", "export"))
type: "import" | "export";
FileOperation.prototype.expire = async function () {
this.state = "expired";
await deleteFromS3(this.key);
await this.save();
};
@Column(
DataType.ENUM("creating", "uploading", "complete", "error", "expired")
)
state: "creating" | "uploading" | "complete" | "error" | "expired";
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
FileOperation.associate = (models) => {
FileOperation.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
FileOperation.belongsTo(models.Collection, {
as: "collection",
foreignKey: "collectionId",
});
FileOperation.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
FileOperation.addScope("defaultScope", {
include: [
{
model: models.User,
as: "user",
paranoid: false,
},
{
model: models.Collection,
as: "collection",
paranoid: false,
},
],
});
};
@Column
key: string;
@Column
url: string;
@Column(DataType.BIGINT)
size: number;
expire = async function () {
this.state = "expired";
await deleteFromS3(this.key);
await this.save();
};
// hooks
@BeforeDestroy
static async deleteFileFromS3(model: FileOperation) {
await deleteFromS3(model.key);
}
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string;
}
export default FileOperation;

View File

@@ -1,9 +1,11 @@
import { CollectionGroup, GroupUser } from "@server/models";
import { buildUser, buildGroup, buildCollection } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import CollectionGroup from "./CollectionGroup";
import GroupUser from "./GroupUser";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
describe("afterDestroy hook", () => {
test("should destroy associated group and collection join relations", async () => {
const group = await buildGroup();
@@ -23,22 +25,22 @@ describe("afterDestroy hook", () => {
teamId,
});
const createdById = user1.id;
await group.addUser(user1, {
await group.$add("user", user1, {
through: {
createdById,
},
});
await group.addUser(user2, {
await group.$add("user", user2, {
through: {
createdById,
},
});
await collection1.addGroup(group, {
await collection1.$add("group", group, {
through: {
createdById,
},
});
await collection2.addGroup(group, {
await collection2.$add("group", group, {
through: {
createdById,
},

View File

@@ -1,96 +1,100 @@
import { CollectionGroup, GroupUser } from "@server/models";
import { Op, DataTypes, sequelize } from "../sequelize";
import { Op } from "sequelize";
import {
AfterDestroy,
BelongsTo,
Column,
ForeignKey,
Table,
HasMany,
BelongsToMany,
DefaultScope,
DataType,
} from "sequelize-typescript";
import CollectionGroup from "./CollectionGroup";
import GroupUser from "./GroupUser";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
const Group = sequelize.define(
"group",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
@DefaultScope(() => ({
include: [
{
association: "groupMemberships",
required: false,
},
teamId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
},
name: {
type: DataTypes.STRING,
allowNull: false,
],
order: [["name", "ASC"]],
}))
@Table({
tableName: "groups",
modelName: "group",
validate: {
isUniqueNameInTeam: async function () {
const foundItem = await Group.findOne({
where: {
teamId: this.teamId,
name: {
[Op.iLike]: this.name,
},
id: {
[Op.not]: this.id,
},
},
});
if (foundItem) {
throw new Error("The name of this group is already in use");
}
},
},
{
timestamps: true,
paranoid: true,
validate: {
isUniqueNameInTeam: async function () {
const foundItem = await Group.findOne({
where: {
teamId: this.teamId,
name: {
[Op.iLike]: this.name,
},
id: {
[Op.not]: this.id,
},
},
});
})
@Fix
class Group extends ParanoidModel {
@Column
name: string;
if (foundItem) {
throw new Error("The name of this group is already in use");
}
// hooks
@AfterDestroy
static async deleteGroupUsers(model: Group) {
if (!model.deletedAt) return;
await GroupUser.destroy({
where: {
groupId: model.id,
},
},
});
await CollectionGroup.destroy({
where: {
groupId: model.id,
},
});
}
);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Group.associate = (models) => {
Group.hasMany(models.GroupUser, {
as: "groupMemberships",
foreignKey: "groupId",
});
Group.hasMany(models.CollectionGroup, {
as: "collectionGroupMemberships",
foreignKey: "groupId",
});
Group.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
Group.belongsTo(models.User, {
as: "createdBy",
foreignKey: "createdById",
});
Group.belongsToMany(models.User, {
as: "users",
through: models.GroupUser,
foreignKey: "groupId",
});
Group.addScope("defaultScope", {
include: [
{
association: "groupMemberships",
required: false,
},
],
order: [["name", "ASC"]],
});
};
// associations
// Cascade deletes to group and collection relations
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'group' implicitly has an 'any' type.
Group.addHook("afterDestroy", async (group) => {
if (!group.deletedAt) return;
await GroupUser.destroy({
where: {
groupId: group.id,
},
});
await CollectionGroup.destroy({
where: {
groupId: group.id,
},
});
});
@HasMany(() => GroupUser, "groupId")
groupMemberships: GroupUser[];
@HasMany(() => CollectionGroup, "groupId")
collectionGroupMemberships: CollectionGroup[];
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsToMany(() => User, () => GroupUser)
users: User[];
}
export default Group;

View File

@@ -1,37 +1,46 @@
import { sequelize } from "../sequelize";
import {
DefaultScope,
BelongsTo,
ForeignKey,
Column,
Table,
DataType,
Model,
} from "sequelize-typescript";
import Group from "./Group";
import User from "./User";
import Fix from "./decorators/Fix";
const GroupUser = sequelize.define(
"group_user",
{},
{
timestamps: true,
paranoid: true,
}
);
@DefaultScope(() => ({
include: [
{
association: "user",
},
],
}))
@Table({ tableName: "group_users", modelName: "group_user", paranoid: true })
@Fix
class GroupUser extends Model {
@BelongsTo(() => User, "userId")
user: User;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
GroupUser.associate = (models) => {
GroupUser.belongsTo(models.Group, {
as: "group",
foreignKey: "groupId",
primary: true,
});
GroupUser.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
primary: true,
});
GroupUser.belongsTo(models.User, {
as: "createdBy",
foreignKey: "createdById",
});
GroupUser.addScope("defaultScope", {
include: [
{
association: "user",
},
],
});
};
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Group, "groupId")
group: Group;
@ForeignKey(() => Group)
@Column(DataType.UUID)
groupId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
}
export default GroupUser;

View File

@@ -1,35 +1,61 @@
import { DataTypes, sequelize } from "../sequelize";
import {
ForeignKey,
BelongsTo,
Column,
Table,
DataType,
} from "sequelize-typescript";
import Collection from "./Collection";
import IntegrationAuthentication from "./IntegrationAuthentication";
import Team from "./Team";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const Integration = sequelize.define("integration", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
type: DataTypes.STRING,
service: DataTypes.STRING,
settings: DataTypes.JSONB,
events: DataTypes.ARRAY(DataTypes.STRING),
});
@Table({ tableName: "integrations", modelName: "integration" })
@Fix
class Integration extends BaseModel {
@Column
type: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Integration.associate = (models) => {
Integration.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
Integration.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
Integration.belongsTo(models.Collection, {
as: "collection",
foreignKey: "collectionId",
});
Integration.belongsTo(models.IntegrationAuthentication, {
as: "authentication",
foreignKey: "authenticationId",
});
};
@Column
service: string;
@Column(DataType.JSONB)
settings: any;
@Column(DataType.ARRAY(DataType.STRING))
events: string[];
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string;
@BelongsTo(() => IntegrationAuthentication, "authenticationId")
authentication: IntegrationAuthentication;
@ForeignKey(() => IntegrationAuthentication)
@Column(DataType.UUID)
authenticationId: string;
}
export default Integration;

View File

@@ -1,26 +1,53 @@
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
import {
DataType,
Table,
ForeignKey,
BelongsTo,
Column,
} from "sequelize-typescript";
import Team from "./Team";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Encrypted, {
getEncryptedColumn,
setEncryptedColumn,
} from "./decorators/Encrypted";
import Fix from "./decorators/Fix";
const IntegrationAuthentication = sequelize.define("authentication", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
service: DataTypes.STRING,
scopes: DataTypes.ARRAY(DataTypes.STRING),
token: encryptedFields().vault("token"),
});
@Table({ tableName: "authentications", modelName: "authentication" })
@Fix
class IntegrationAuthentication extends BaseModel {
@Column
service: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
IntegrationAuthentication.associate = (models) => {
IntegrationAuthentication.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
IntegrationAuthentication.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
};
@Column(DataType.ARRAY(DataType.STRING))
scopes: string[];
@Column(DataType.BLOB)
@Encrypted
get token() {
return getEncryptedColumn(this, "token");
}
set token(value: string) {
setEncryptedColumn(this, "token", value);
}
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default IntegrationAuthentication;

View File

@@ -1,36 +1,55 @@
import { DataTypes, sequelize } from "../sequelize";
import {
Table,
ForeignKey,
Model,
Column,
PrimaryKey,
IsUUID,
CreatedAt,
BelongsTo,
DataType,
Default,
} from "sequelize-typescript";
import User from "./User";
import Fix from "./decorators/Fix";
const Notification = sequelize.define(
"notification",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
event: {
type: DataTypes.STRING,
},
email: {
type: DataTypes.BOOLEAN,
},
},
{
timestamps: true,
updatedAt: false,
}
);
@Table({
tableName: "notifications",
modelName: "notification",
updatedAt: false,
})
@Fix
class Notification extends Model {
@IsUUID(4)
@PrimaryKey
@Default(DataType.UUIDV4)
@Column(DataType.UUID)
id: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Notification.associate = (models) => {
Notification.belongsTo(models.User, {
as: "actor",
foreignKey: "actorId",
});
Notification.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
};
@CreatedAt
createdAt: Date;
@Column
event: string;
@Column
email: boolean;
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => User, "actorId")
actor: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
actorId: string;
}
export default Notification;

View File

@@ -1,62 +1,81 @@
import crypto from "crypto";
import { DataTypes, sequelize } from "../sequelize";
import {
Table,
ForeignKey,
Model,
Column,
PrimaryKey,
IsUUID,
CreatedAt,
BelongsTo,
IsIn,
Default,
DataType,
} from "sequelize-typescript";
import Team from "./Team";
import User from "./User";
import Fix from "./decorators/Fix";
const NotificationSetting = sequelize.define(
"notification_setting",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
event: {
type: DataTypes.STRING,
validate: {
isIn: [
[
"documents.publish",
"documents.update",
"collections.create",
"emails.onboarding",
"emails.features",
],
],
},
},
},
{
timestamps: true,
updatedAt: false,
getterMethods: {
unsubscribeUrl: function () {
const token = NotificationSetting.getUnsubscribeToken(this.userId);
return `${process.env.URL}/api/notificationSettings.unsubscribe?token=${token}&id=${this.id}`;
},
unsubscribeToken: function () {
return NotificationSetting.getUnsubscribeToken(this.userId);
},
},
@Table({
tableName: "notification_settings",
modelName: "notification_setting",
updatedAt: false,
})
@Fix
class NotificationSetting extends Model {
@IsUUID(4)
@PrimaryKey
@Default(DataType.UUIDV4)
@Column
id: string;
@CreatedAt
createdAt: Date;
@IsIn([
[
"documents.publish",
"documents.update",
"collections.create",
"emails.onboarding",
"emails.features",
],
])
@Column(DataType.STRING)
event: string;
// getters
get unsubscribeUrl() {
const token = NotificationSetting.getUnsubscribeToken(this.userId);
return `${process.env.URL}/api/notificationSettings.unsubscribe?token=${token}&id=${this.id}`;
}
);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
NotificationSetting.getUnsubscribeToken = (userId) => {
const hash = crypto.createHash("sha256");
hash.update(`${userId}-${process.env.SECRET_KEY}`);
return hash.digest("hex");
};
get unsubscribeToken() {
return NotificationSetting.getUnsubscribeToken(this.userId);
}
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
NotificationSetting.associate = (models) => {
NotificationSetting.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
onDelete: "cascade",
});
NotificationSetting.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
};
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
static getUnsubscribeToken = (userId: string) => {
const hash = crypto.createHash("sha256");
hash.update(`${userId}-${process.env.SECRET_KEY}`);
return hash.digest("hex");
};
}
export default NotificationSetting;

View File

@@ -1,50 +1,52 @@
import { DataTypes, sequelize } from "../sequelize";
import {
DataType,
Column,
ForeignKey,
BelongsTo,
Table,
} from "sequelize-typescript";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const Pin = sequelize.define(
"pins",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
teamId: {
type: DataTypes.UUID,
},
documentId: {
type: DataTypes.UUID,
},
collectionId: {
type: DataTypes.UUID,
defaultValue: null,
},
index: {
type: DataTypes.STRING,
defaultValue: null,
},
},
{
timestamps: true,
}
);
@Table({ tableName: "pins", modelName: "pin" })
@Fix
class Pin extends BaseModel {
@Column
index: string | null;
Pin.associate = (models: any) => {
Pin.belongsTo(models.Document, {
as: "document",
foreignKey: "documentId",
});
Pin.belongsTo(models.Collection, {
as: "collection",
foreignKey: "collectionId",
});
Pin.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
Pin.belongsTo(models.User, {
as: "createdBy",
foreignKey: "createdById",
});
};
// associations
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default Pin;

View File

@@ -1,9 +1,10 @@
import { Revision } from "@server/models";
import { buildDocument } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import Revision from "./Revision";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
describe("#findLatest", () => {
test("should return latest revision", async () => {
const document = await buildDocument({
@@ -18,7 +19,7 @@ describe("#findLatest", () => {
await document.save();
await Revision.createFromDocument(document);
const revision = await Revision.findLatest(document.id);
expect(revision.title).toBe("Changed 2");
expect(revision.text).toBe("Content");
expect(revision?.title).toBe("Changed 2");
expect(revision?.text).toBe("Content");
});
});

View File

@@ -1,103 +1,117 @@
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'slat... Remove this comment to see the full error message
import { FindOptions } from "sequelize";
import {
DataType,
BelongsTo,
Column,
DefaultScope,
ForeignKey,
Table,
} from "sequelize-typescript";
import MarkdownSerializer from "slate-md-serializer";
import { DataTypes, sequelize } from "../sequelize";
import Document from "./Document";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const serializer = new MarkdownSerializer();
const Revision = sequelize.define("revision", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
version: DataTypes.SMALLINT,
editorVersion: DataTypes.STRING,
title: DataTypes.STRING,
text: DataTypes.TEXT,
});
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Revision.associate = (models) => {
Revision.belongsTo(models.Document, {
as: "document",
foreignKey: "documentId",
onDelete: "cascade",
});
Revision.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
Revision.addScope(
"defaultScope",
@DefaultScope(() => ({
include: [
{
include: [
{
model: models.User,
as: "user",
paranoid: false,
},
],
model: User,
as: "user",
paranoid: false,
},
{
override: true,
],
}))
@Table({ tableName: "revisions", modelName: "revision" })
@Fix
class Revision extends BaseModel {
@Column(DataType.SMALLINT)
version: number;
@Column
editorVersion: string;
@Column
title: string;
@Column(DataType.TEXT)
text: string;
// associations
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
static findLatest(documentId: string) {
return this.findOne({
where: {
documentId,
},
order: [["createdAt", "DESC"]],
});
}
static createFromDocument(
document: Document,
options?: FindOptions<Revision>
) {
return this.create(
{
title: document.title,
text: document.text,
userId: document.lastModifiedById,
editorVersion: document.editorVersion,
version: document.version,
documentId: document.id,
// revision time is set to the last time document was touched as this
// handler can be debounced in the case of an update
createdAt: document.updatedAt,
},
options
);
}
migrateVersion = function () {
let migrated = false;
// migrate from document version 0 -> 1
if (!this.version) {
// removing the title from the document text attribute
this.text = this.text.replace(/^#\s(.*)\n/, "");
this.version = 1;
migrated = true;
}
);
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'documentId' implicitly has an 'any' typ... Remove this comment to see the full error message
Revision.findLatest = function (documentId) {
return Revision.findOne({
where: {
documentId,
},
order: [["createdAt", "DESC"]],
});
};
// migrate from document version 1 -> 2
if (this.version === 1) {
const nodes = serializer.deserialize(this.text);
this.text = serializer.serialize(nodes, {
version: 2,
});
this.version = 2;
migrated = true;
}
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'document' implicitly has an 'any' type.
Revision.createFromDocument = function (document, options) {
return Revision.create(
{
title: document.title,
text: document.text,
userId: document.lastModifiedById,
editorVersion: document.editorVersion,
version: document.version,
documentId: document.id,
// revision time is set to the last time document was touched as this
// handler can be debounced in the case of an update
createdAt: document.updatedAt,
},
options
);
};
Revision.prototype.migrateVersion = function () {
let migrated = false;
// migrate from document version 0 -> 1
if (!this.version) {
// removing the title from the document text attribute
this.text = this.text.replace(/^#\s(.*)\n/, "");
this.version = 1;
migrated = true;
}
// migrate from document version 1 -> 2
if (this.version === 1) {
const nodes = serializer.deserialize(this.text);
this.text = serializer.serialize(nodes, {
version: 2,
});
this.version = 2;
migrated = true;
}
if (migrated) {
return this.save({
silent: true,
hooks: false,
});
}
};
if (migrated) {
return this.save({
silent: true,
hooks: false,
});
}
};
}
export default Revision;

View File

@@ -1,48 +1,65 @@
import { DataTypes, sequelize } from "../sequelize";
import {
Table,
ForeignKey,
Model,
Column,
PrimaryKey,
IsUUID,
CreatedAt,
BelongsTo,
DataType,
Default,
} from "sequelize-typescript";
import Team from "./Team";
import User from "./User";
import Fix from "./decorators/Fix";
const SearchQuery = sequelize.define(
"search_queries",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
source: {
type: DataTypes.ENUM("slack", "app", "api"),
allowNull: false,
},
query: {
type: DataTypes.STRING,
@Table({
tableName: "search_queries",
modelName: "search_query",
updatedAt: false,
})
@Fix
class SearchQuery extends Model {
@IsUUID(4)
@PrimaryKey
@Default(DataType.UUIDV4)
@Column(DataType.UUID)
id: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'val' implicitly has an 'any' type.
set(val) {
this.setDataValue("query", val.substring(0, 255));
},
@CreatedAt
createdAt: Date;
allowNull: false,
},
results: {
type: DataTypes.NUMBER,
allowNull: false,
},
},
{
timestamps: true,
updatedAt: false,
@Column(DataType.ENUM("slack", "app", "api"))
source: string;
@Column
results: number;
@Column(DataType.STRING)
set query(value: string) {
this.setDataValue("query", value.substring(0, 255));
}
);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
SearchQuery.associate = (models) => {
SearchQuery.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
SearchQuery.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
};
get query() {
return this.getDataValue("query");
}
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default SearchQuery;

View File

@@ -1,67 +1,45 @@
import { DataTypes, sequelize } from "../sequelize";
import {
ForeignKey,
BelongsTo,
Column,
DefaultScope,
Table,
Scopes,
DataType,
} from "sequelize-typescript";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const Share = sequelize.define(
"share",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
@DefaultScope(() => ({
include: [
{
association: "user",
paranoid: false,
},
published: DataTypes.BOOLEAN,
includeChildDocuments: DataTypes.BOOLEAN,
revokedAt: DataTypes.DATE,
revokedById: DataTypes.UUID,
lastAccessedAt: DataTypes.DATE,
},
{
getterMethods: {
isRevoked() {
return !!this.revokedAt;
},
{
association: "document",
required: false,
},
}
);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Share.associate = (models) => {
Share.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
Share.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
Share.belongsTo(models.Document.scope("withUnpublished"), {
as: "document",
foreignKey: "documentId",
});
Share.addScope("defaultScope", {
include: [
{
association: "user",
paranoid: false,
},
{
association: "document",
},
{
association: "team",
},
],
});
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
Share.addScope("withCollection", (userId) => {
{
association: "team",
},
],
}))
@Scopes(() => ({
withCollection: (userId: string) => {
return {
include: [
{
model: models.Document,
model: Document,
paranoid: true,
as: "document",
include: [
{
model: models.Collection.scope({
model: Collection.scope({
method: ["withMembership", userId],
}),
as: "collection",
@@ -77,14 +55,64 @@ Share.associate = (models) => {
},
],
};
});
};
},
}))
@Table({ tableName: "shares", modelName: "share" })
@Fix
class Share extends BaseModel {
@Column
published: boolean;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
Share.prototype.revoke = function (userId) {
this.revokedAt = new Date();
this.revokedById = userId;
return this.save();
};
@Column
includeChildDocuments: boolean;
@Column
revokedAt: Date | null;
@Column
lastAccessedAt: Date | null;
// getters
get isRevoked() {
return !!this.revokedAt;
}
// associations
@BelongsTo(() => User, "revokedById")
revokedBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
revokedById: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
revoke(userId: string) {
this.revokedAt = new Date();
this.revokedById = userId;
return this.save();
}
}
export default Share;

View File

@@ -1,17 +1,31 @@
import { DataTypes, sequelize } from "../sequelize";
import {
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
} from "sequelize-typescript";
import Document from "./Document";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const Star = sequelize.define("star", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
});
@Table({ tableName: "stars", modelName: "star" })
@Fix
class Star extends BaseModel {
@BelongsTo(() => User, "userId")
user: User;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Star.associate = (models) => {
Star.belongsTo(models.Document);
Star.belongsTo(models.User);
};
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
}
export default Star;

View File

@@ -2,6 +2,7 @@ import { buildTeam, buildCollection } from "@server/test/factories";
import { flushdb } from "@server/test/support";
beforeEach(() => flushdb());
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
@@ -20,6 +21,7 @@ describe("collectionIds", () => {
expect(response[0]).toEqual(collection.id);
});
});
describe("provisionSubdomain", () => {
it("should set subdomain if available", async () => {
const team = await buildTeam();

View File

@@ -2,258 +2,246 @@ import fs from "fs";
import path from "path";
import { URL } from "url";
import util from "util";
import { Op } from "sequelize";
import {
Column,
IsLowercase,
NotIn,
Default,
Table,
Unique,
IsIn,
BeforeSave,
HasMany,
Scopes,
Length,
Is,
DataType,
} from "sequelize-typescript";
import { v4 as uuidv4 } from "uuid";
import { stripSubdomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import Logger from "@server/logging/logger";
import { generateAvatarUrl } from "@server/utils/avatars";
import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3";
import { DataTypes, sequelize, Op } from "../sequelize";
import AuthenticationProvider from "./AuthenticationProvider";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
const readFile = util.promisify(fs.readFile);
const Team = sequelize.define(
"team",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: DataTypes.STRING,
subdomain: {
type: DataTypes.STRING,
allowNull: true,
validate: {
isLowercase: true,
is: {
args: [/^[a-z\d-]+$/, "i"],
msg: "Must be only alphanumeric and dashes",
},
len: {
args: [4, 32],
msg: "Must be between 4 and 32 characters",
},
notIn: {
args: [RESERVED_SUBDOMAINS],
msg: "You chose a restricted word, please try another.",
},
},
unique: true,
},
domain: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
},
slackId: {
type: DataTypes.STRING,
allowNull: true,
},
googleId: {
type: DataTypes.STRING,
allowNull: true,
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: true,
},
sharing: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
signupQueryParams: {
type: DataTypes.JSONB,
allowNull: true,
},
guestSignin: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
documentEmbeds: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
collaborativeEditing: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
defaultUserRole: {
type: DataTypes.STRING,
defaultValue: "member",
allowNull: false,
validate: {
isIn: [["viewer", "member"]],
},
},
},
{
paranoid: true,
getterMethods: {
url() {
if (this.domain) {
return `https://${this.domain}`;
}
if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") {
return process.env.URL;
}
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message
const url = new URL(process.env.URL);
url.host = `${this.subdomain}.${stripSubdomain(url.host)}`;
return url.href.replace(/\/$/, "");
},
logoUrl() {
return (
this.avatarUrl ||
generateAvatarUrl({
id: this.id,
name: this.name,
})
);
},
},
}
);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
Team.associate = (models) => {
Team.hasMany(models.Collection, {
as: "collections",
});
Team.hasMany(models.Document, {
as: "documents",
});
Team.hasMany(models.User, {
as: "users",
});
Team.hasMany(models.AuthenticationProvider, {
as: "authenticationProviders",
});
Team.addScope("withAuthenticationProviders", {
@Scopes(() => ({
withAuthenticationProviders: {
include: [
{
model: models.AuthenticationProvider,
model: AuthenticationProvider,
as: "authenticationProviders",
},
],
});
};
},
}))
@Table({ tableName: "teams", modelName: "team" })
@Fix
class Team extends ParanoidModel {
@Column
name: string;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
const uploadAvatar = async (model) => {
const endpoint = publicS3Endpoint();
const { avatarUrl } = model;
@IsLowercase
@Unique
@Length({ min: 4, max: 32, msg: "Must be between 4 and 32 characters" })
@Is({
args: [/^[a-z\d-]+$/, "i"],
msg: "Must be only alphanumeric and dashes",
})
@NotIn({
args: [RESERVED_SUBDOMAINS],
msg: "You chose a restricted word, please try another.",
})
@Column
subdomain: string | null;
if (
avatarUrl &&
!avatarUrl.startsWith("/api") &&
!avatarUrl.startsWith(endpoint)
) {
try {
const newUrl = await uploadToS3FromUrl(
avatarUrl,
`avatars/${model.id}/${uuidv4()}`,
"public-read"
);
if (newUrl) model.avatarUrl = newUrl;
} catch (err) {
Logger.error("Error uploading avatar to S3", err, {
url: avatarUrl,
});
@Unique
@Column
domain: string | null;
@Column
avatarUrl: string | null;
@Default(true)
@Column
sharing: boolean;
@Default(true)
@Column(DataType.JSONB)
signupQueryParams: { [key: string]: string } | null;
@Default(true)
@Column
guestSignin: boolean;
@Default(true)
@Column
documentEmbeds: boolean;
@Default(false)
@Column
collaborativeEditing: boolean;
@Default("member")
@IsIn([["viewer", "member"]])
@Column
defaultUserRole: string;
// getters
get url() {
if (this.domain) {
return `https://${this.domain}`;
}
}
};
Team.prototype.provisionSubdomain = async function (
requestedSubdomain: string,
options = {}
) {
if (this.subdomain) return this.subdomain;
let subdomain = requestedSubdomain;
let append = 0;
for (;;) {
try {
await this.update(
{
subdomain,
},
options
);
break;
} catch (err) {
// subdomain was invalid or already used, try again
subdomain = `${requestedSubdomain}${++append}`;
if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") {
return process.env.URL;
}
const url = new URL(process.env.URL || "");
url.host = `${this.subdomain}.${stripSubdomain(url.host)}`;
return url.href.replace(/\/$/, "");
}
return subdomain;
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
Team.prototype.provisionFirstCollection = async function (userId) {
const collection = await Collection.create({
name: "Welcome",
description:
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
teamId: this.id,
createdById: userId,
sort: Collection.DEFAULT_SORT,
permission: "read_write",
});
// For the first collection we go ahead and create some intitial documents to get
// the team started. You can edit these in /server/onboarding/x.md
const onboardingDocs = [
"Integrations & API",
"Our Editor",
"Getting Started",
"What is Outline",
];
for (const title of onboardingDocs) {
const text = await readFile(
path.join(process.cwd(), "server", "onboarding", `${title}.md`),
"utf8"
get logoUrl() {
return (
this.avatarUrl ||
generateAvatarUrl({
id: this.id,
name: this.name,
})
);
const document = await Document.create({
version: 2,
isWelcome: true,
parentDocumentId: null,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.createdById,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title,
text,
});
await document.publish(collection.createdById);
}
};
Team.prototype.collectionIds = async function (paranoid = true) {
const models = await Collection.findAll({
attributes: ["id"],
where: {
// TODO: Move to command
provisionSubdomain = async function (
requestedSubdomain: string,
options = {}
) {
if (this.subdomain) return this.subdomain;
let subdomain = requestedSubdomain;
let append = 0;
for (;;) {
try {
await this.update(
{
subdomain,
},
options
);
break;
} catch (err) {
// subdomain was invalid or already used, try again
subdomain = `${requestedSubdomain}${++append}`;
}
}
return subdomain;
};
provisionFirstCollection = async function (userId: string) {
const collection = await Collection.create({
name: "Welcome",
description:
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
teamId: this.id,
permission: {
[Op.ne]: null,
},
},
paranoid,
});
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
return models.map((c) => c.id);
};
createdById: userId,
sort: Collection.DEFAULT_SORT,
permission: "read_write",
});
Team.beforeSave(uploadAvatar);
// For the first collection we go ahead and create some intitial documents to get
// the team started. You can edit these in /server/onboarding/x.md
const onboardingDocs = [
"Integrations & API",
"Our Editor",
"Getting Started",
"What is Outline",
];
for (const title of onboardingDocs) {
const text = await readFile(
path.join(process.cwd(), "server", "onboarding", `${title}.md`),
"utf8"
);
const document = await Document.create({
version: 2,
isWelcome: true,
parentDocumentId: null,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.createdById,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title,
text,
});
await document.publish(collection.createdById);
}
};
collectionIds = async function (paranoid = true) {
const models = await Collection.findAll({
attributes: ["id"],
where: {
teamId: this.id,
permission: {
[Op.ne]: null,
},
},
paranoid,
});
return models.map((c) => c.id);
};
// associations
@HasMany(() => Collection)
collections: Collection[];
@HasMany(() => Document)
documents: Document[];
@HasMany(() => User)
users: User[];
@HasMany(() => AuthenticationProvider)
authenticationProviders: AuthenticationProvider[];
// hooks
@BeforeSave
static uploadAvatar = async (model: Team) => {
const endpoint = publicS3Endpoint();
const { avatarUrl } = model;
if (
avatarUrl &&
!avatarUrl.startsWith("/api") &&
!avatarUrl.startsWith(endpoint)
) {
try {
const newUrl = await uploadToS3FromUrl(
avatarUrl,
`avatars/${model.id}/${uuidv4()}`,
"public-read"
);
if (newUrl) model.avatarUrl = newUrl;
} catch (err) {
Logger.error("Error uploading avatar to S3", err, {
url: avatarUrl,
});
}
}
};
}
export default Team;

View File

@@ -1,8 +1,10 @@
import { UserAuthentication, CollectionUser } from "@server/models";
import { buildUser, buildTeam, buildCollection } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import CollectionUser from "./CollectionUser";
import UserAuthentication from "./UserAuthentication";
beforeEach(() => flushdb());
describe("user model", () => {
describe("destroy", () => {
it("should delete user authentications", async () => {

View File

@@ -1,6 +1,25 @@
import crypto from "crypto";
import { addMinutes, subMinutes } from "date-fns";
import JWT from "jsonwebtoken";
import { Transaction, QueryTypes, FindOptions, Op } from "sequelize";
import {
Table,
Column,
IsIP,
IsEmail,
HasOne,
Default,
IsIn,
BeforeDestroy,
BeforeSave,
BeforeCreate,
AfterCreate,
BelongsTo,
ForeignKey,
DataType,
HasMany,
Scopes,
} from "sequelize-typescript";
import { v4 as uuidv4 } from "uuid";
import { languages } from "@shared/i18n";
import Logger from "@server/logging/logger";
@@ -8,427 +27,441 @@ import { DEFAULT_AVATAR_HOST } from "@server/utils/avatars";
import { palette } from "@server/utils/color";
import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3";
import { ValidationError } from "../errors";
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
import {
UserAuthentication,
Star,
Collection,
NotificationSetting,
ApiKey,
} from ".";
import ApiKey from "./ApiKey";
import Collection from "./Collection";
import NotificationSetting from "./NotificationSetting";
import Star from "./Star";
import Team from "./Team";
import UserAuthentication from "./UserAuthentication";
import ParanoidModel from "./base/ParanoidModel";
import Encrypted, {
setEncryptedColumn,
getEncryptedColumn,
} from "./decorators/Encrypted";
import Fix from "./decorators/Fix";
const User = sequelize.define(
"user",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
email: {
type: DataTypes.STRING,
},
username: {
type: DataTypes.STRING,
},
name: DataTypes.STRING,
avatarUrl: {
type: DataTypes.STRING,
allowNull: true,
},
isAdmin: DataTypes.BOOLEAN,
isViewer: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
service: {
type: DataTypes.STRING,
allowNull: true,
},
serviceId: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
},
jwtSecret: encryptedFields().vault("jwtSecret"),
lastActiveAt: DataTypes.DATE,
lastActiveIp: {
type: DataTypes.STRING,
allowNull: true,
},
lastSignedInAt: DataTypes.DATE,
lastSignedInIp: {
type: DataTypes.STRING,
allowNull: true,
},
lastSigninEmailSentAt: DataTypes.DATE,
suspendedAt: DataTypes.DATE,
suspendedById: DataTypes.UUID,
language: {
type: DataTypes.STRING,
defaultValue: process.env.DEFAULT_LANGUAGE,
validate: {
isIn: [languages],
},
},
},
{
paranoid: true,
getterMethods: {
isSuspended() {
return !!this.suspendedAt;
},
isInvited() {
return !this.lastActiveAt;
},
avatarUrl() {
const original = this.getDataValue("avatarUrl");
if (original) {
return original;
}
const color = this.color.replace(/^#/, "");
const initial = this.name ? this.name[0] : "?";
const hash = crypto
.createHash("md5")
.update(this.email || "")
.digest("hex");
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
},
color() {
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
const idAsNumber = parseInt(idAsHex, 16);
return palette[idAsNumber % palette.length];
},
},
}
);
// Class methods
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
User.associate = (models) => {
User.hasMany(models.ApiKey, {
as: "apiKeys",
onDelete: "cascade",
});
User.hasMany(models.NotificationSetting, {
as: "notificationSettings",
onDelete: "cascade",
});
User.hasMany(models.Document, {
as: "documents",
});
User.hasMany(models.View, {
as: "views",
});
User.hasMany(models.UserAuthentication, {
as: "authentications",
});
User.belongsTo(models.Team);
User.addScope("withAuthentications", {
@Scopes(() => ({
withAuthentications: {
include: [
{
model: models.UserAuthentication,
model: UserAuthentication,
as: "authentications",
},
],
});
};
},
}))
@Table({ tableName: "users", modelName: "user" })
@Fix
class User extends ParanoidModel {
@IsEmail
@Column
email: string | null;
// Instance methods
User.prototype.collectionIds = async function (options = {}) {
const collectionStubs = await Collection.scope({
method: ["withMembership", this.id],
}).findAll({
attributes: ["id", "permission"],
where: {
teamId: this.teamId,
},
paranoid: true,
...options,
});
return (
collectionStubs
@Column
username: string | null;
@Column
name: string;
@Default(false)
@Column
isAdmin: boolean;
@Default(false)
@Column
isViewer: boolean;
@Column(DataType.BLOB)
@Encrypted
get jwtSecret() {
return getEncryptedColumn(this, "jwtSecret");
}
set jwtSecret(value: string) {
setEncryptedColumn(this, "jwtSecret", value);
}
@Column
lastActiveAt: Date | null;
@IsIP
@Column
lastActiveIp: string | null;
@Column
lastSignedInAt: Date | null;
@IsIP
@Column
lastSignedInIp: string | null;
@Column
lastSigninEmailSentAt: Date | null;
@Column
suspendedAt: Date | null;
@Default(process.env.DEFAULT_LANGUAGE)
@IsIn([languages])
@Column
language: string;
@Column(DataType.STRING)
get avatarUrl() {
const original = this.getDataValue("avatarUrl");
if (original) {
return original;
}
const color = this.color.replace(/^#/, "");
const initial = this.name ? this.name[0] : "?";
const hash = crypto
.createHash("md5")
.update(this.email || "")
.digest("hex");
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
}
set avatarUrl(value: string | null) {
this.setDataValue("avatarUrl", value);
}
// associations
@HasOne(() => User, "suspendedById")
suspendedBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
suspendedById: string;
@BelongsTo(() => Team)
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@HasMany(() => UserAuthentication)
authentications: UserAuthentication[];
// getters
get isSuspended(): boolean {
return !!this.suspendedAt;
}
get isInvited() {
return !this.lastActiveAt;
}
get color() {
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
const idAsNumber = parseInt(idAsHex, 16);
return palette[idAsNumber % palette.length];
}
// instance methods
collectionIds = async (options = {}) => {
const collectionStubs = await Collection.scope({
method: ["withMembership", this.id],
}).findAll({
attributes: ["id", "permission"],
where: {
teamId: this.teamId,
},
paranoid: true,
...options,
});
return collectionStubs
.filter(
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
(c) =>
c.permission === "read" ||
c.permission === "read_write" ||
c.memberships.length > 0 ||
c.collectionGroupMemberships.length > 0
)
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
.map((c) => c.id)
);
};
.map((c) => c.id);
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ip' implicitly has an 'any' type.
User.prototype.updateActiveAt = function (ip, force = false) {
const fiveMinutesAgo = subMinutes(new Date(), 5);
updateActiveAt = (ip: string, force = false) => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
// ensure this is updated only every few minutes otherwise
// we'll be constantly writing to the DB as API requests happen
if (this.lastActiveAt < fiveMinutesAgo || force) {
this.lastActiveAt = new Date();
this.lastActiveIp = ip;
// ensure this is updated only every few minutes otherwise
// we'll be constantly writing to the DB as API requests happen
if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo || force) {
this.lastActiveAt = new Date();
this.lastActiveIp = ip;
return this.save({
hooks: false,
});
}
return this;
};
updateSignedIn = (ip: string) => {
this.lastSignedInAt = new Date();
this.lastSignedInIp = ip;
return this.save({
hooks: false,
});
}
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ip' implicitly has an 'any' type.
User.prototype.updateSignedIn = function (ip) {
this.lastSignedInAt = new Date();
this.lastSignedInIp = ip;
return this.save({
hooks: false,
});
};
// Returns a session token that is used to make API requests and is stored
// in the client browser cookies to remain logged in.
User.prototype.getJwtToken = function (expiresAt?: Date) {
return JWT.sign(
{
id: this.id,
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
type: "session",
},
this.jwtSecret
);
};
// Returns a temporary token that is only used for transferring a session
// between subdomains or domains. It has a short expiry and can only be used once
User.prototype.getTransferToken = function () {
return JWT.sign(
{
id: this.id,
createdAt: new Date().toISOString(),
expiresAt: addMinutes(new Date(), 1).toISOString(),
type: "transfer",
},
this.jwtSecret
);
};
// Returns a temporary token that is only used for logging in from an email
// It can only be used to sign in once and has a medium length expiry
User.prototype.getEmailSigninToken = function () {
return JWT.sign(
{
id: this.id,
createdAt: new Date().toISOString(),
type: "email-signin",
},
this.jwtSecret
);
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
const uploadAvatar = async (model) => {
const endpoint = publicS3Endpoint();
const { avatarUrl } = model;
if (
avatarUrl &&
!avatarUrl.startsWith("/api") &&
!avatarUrl.startsWith(endpoint) &&
!avatarUrl.startsWith(DEFAULT_AVATAR_HOST)
) {
try {
const newUrl = await uploadToS3FromUrl(
avatarUrl,
`avatars/${model.id}/${uuidv4()}`,
"public-read"
);
if (newUrl) model.avatarUrl = newUrl;
} catch (err) {
Logger.error("Couldn't upload user avatar image to S3", err, {
url: avatarUrl,
});
}
}
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
const setRandomJwtSecret = (model) => {
model.jwtSecret = crypto.randomBytes(64).toString("hex");
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
const removeIdentifyingInfo = async (model, options) => {
await NotificationSetting.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
await ApiKey.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
await Star.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
await UserAuthentication.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
model.email = null;
model.name = "Unknown";
model.avatarUrl = "";
model.serviceId = null;
model.username = null;
model.lastActiveIp = null;
model.lastSignedInIp = null;
// this shouldn't be needed once this issue is resolved:
// https://github.com/sequelize/sequelize/issues/9318
await model.save({
hooks: false,
transaction: options.transaction,
});
};
User.beforeDestroy(removeIdentifyingInfo);
User.beforeSave(uploadAvatar);
User.beforeCreate(setRandomJwtSecret);
// By default when a user signs up we subscribe them to email notifications
// when documents they created are edited by other team members and onboarding
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
User.afterCreate(async (user, options) => {
await Promise.all([
NotificationSetting.findOrCreate({
where: {
userId: user.id,
teamId: user.teamId,
event: "documents.update",
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: user.id,
teamId: user.teamId,
event: "emails.onboarding",
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: user.id,
teamId: user.teamId,
event: "emails.features",
},
transaction: options.transaction,
}),
]);
});
User.getCounts = async function (teamId: string) {
const countSql = `
SELECT
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
COUNT(*) as count
FROM users
WHERE "deletedAt" IS NULL
AND "teamId" = :teamId
`;
const results = await sequelize.query(countSql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId,
},
});
const counts = results[0];
return {
active: parseInt(counts.activeCount),
admins: parseInt(counts.adminCount),
viewers: parseInt(counts.viewerCount),
all: parseInt(counts.count),
invited: parseInt(counts.invitedCount),
suspended: parseInt(counts.suspendedCount),
};
};
User.findAllInBatches = async (
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'query' implicitly has an 'any' type.
query,
// @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
callback: (users: Array<User>, query: Record<string, any>) => Promise<void>
) => {
if (!query.offset) query.offset = 0;
if (!query.limit) query.limit = 10;
let results;
do {
results = await User.findAll(query);
await callback(results, query);
query.offset += query.limit;
} while (results.length >= query.limit);
};
User.prototype.demote = async function (
teamId: string,
to: "member" | "viewer"
) {
const res = await User.findAndCountAll({
where: {
teamId,
isAdmin: true,
id: {
[Op.ne]: this.id,
// Returns a session token that is used to make API requests and is stored
// in the client browser cookies to remain logged in.
getJwtToken = (expiresAt?: Date) => {
return JWT.sign(
{
id: this.id,
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
type: "session",
},
},
limit: 1,
});
this.jwtSecret
);
};
if (res.count >= 1) {
if (to === "member") {
return this.update({
isAdmin: false,
isViewer: false,
});
} else if (to === "viewer") {
return this.update({
isAdmin: false,
isViewer: true,
});
// Returns a temporary token that is only used for transferring a session
// between subdomains or domains. It has a short expiry and can only be used once
getTransferToken = () => {
return JWT.sign(
{
id: this.id,
createdAt: new Date().toISOString(),
expiresAt: addMinutes(new Date(), 1).toISOString(),
type: "transfer",
},
this.jwtSecret
);
};
// Returns a temporary token that is only used for logging in from an email
// It can only be used to sign in once and has a medium length expiry
getEmailSigninToken = () => {
return JWT.sign(
{
id: this.id,
createdAt: new Date().toISOString(),
type: "email-signin",
},
this.jwtSecret
);
};
demote = async (teamId: string, to: "member" | "viewer") => {
const res = await (this.constructor as typeof User).findAndCountAll({
where: {
teamId,
isAdmin: true,
id: {
[Op.ne]: this.id,
},
},
limit: 1,
});
if (res.count >= 1) {
if (to === "member") {
return this.update({
isAdmin: false,
isViewer: false,
});
} else if (to === "viewer") {
return this.update({
isAdmin: false,
isViewer: true,
});
}
return undefined;
} else {
throw ValidationError("At least one admin is required");
}
} else {
throw ValidationError("At least one admin is required");
};
promote = () => {
return this.update({
isAdmin: true,
isViewer: false,
});
};
activate = () => {
return this.update({
suspendedById: null,
suspendedAt: null,
});
};
// hooks
@BeforeDestroy
static removeIdentifyingInfo = async (
model: User,
options: { transaction: Transaction }
) => {
await NotificationSetting.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
await ApiKey.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
await Star.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
await UserAuthentication.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
model.email = null;
model.name = "Unknown";
model.avatarUrl = null;
model.username = null;
model.lastActiveIp = null;
model.lastSignedInIp = null;
// this shouldn't be needed once this issue is resolved:
// https://github.com/sequelize/sequelize/issues/9318
await model.save({
hooks: false,
transaction: options.transaction,
});
};
@BeforeSave
static uploadAvatar = async (model: User) => {
const endpoint = publicS3Endpoint();
const { avatarUrl } = model;
if (
avatarUrl &&
!avatarUrl.startsWith("/api") &&
!avatarUrl.startsWith(endpoint) &&
!avatarUrl.startsWith(DEFAULT_AVATAR_HOST)
) {
try {
const newUrl = await uploadToS3FromUrl(
avatarUrl,
`avatars/${model.id}/${uuidv4()}`,
"public-read"
);
if (newUrl) model.avatarUrl = newUrl;
} catch (err) {
Logger.error("Couldn't upload user avatar image to S3", err, {
url: avatarUrl,
});
}
}
};
@BeforeCreate
static setRandomJwtSecret = (model: User) => {
model.jwtSecret = crypto.randomBytes(64).toString("hex");
};
// By default when a user signs up we subscribe them to email notifications
// when documents they created are edited by other team members and onboarding
@AfterCreate
static subscribeToNotifications = async (
model: User,
options: { transaction: Transaction }
) => {
await Promise.all([
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "documents.update",
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "emails.onboarding",
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "emails.features",
},
transaction: options.transaction,
}),
]);
};
static getCounts = async function (teamId: string) {
const countSql = `
SELECT
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
COUNT(*) as count
FROM users
WHERE "deletedAt" IS NULL
AND "teamId" = :teamId
`;
const [results] = await this.sequelize.query(countSql, {
type: QueryTypes.SELECT,
replacements: {
teamId,
},
});
const counts: {
activeCount: string;
adminCount: string;
invitedCount: string;
suspendedCount: string;
viewerCount: string;
count: string;
} = results as any;
return {
active: parseInt(counts.activeCount),
admins: parseInt(counts.adminCount),
viewers: parseInt(counts.viewerCount),
all: parseInt(counts.count),
invited: parseInt(counts.invitedCount),
suspended: parseInt(counts.suspendedCount),
};
};
static async findAllInBatches(
query: FindOptions<User>,
callback: (users: Array<User>, query: FindOptions<User>) => Promise<void>
) {
if (!query.offset) query.offset = 0;
if (!query.limit) query.limit = 10;
let results;
do {
results = await this.findAll(query);
await callback(results, query);
query.offset += query.limit;
} while (results.length >= query.limit);
}
};
User.prototype.promote = async function () {
return this.update({
isAdmin: true,
isViewer: false,
});
};
User.prototype.activate = async function () {
return this.update({
suspendedById: null,
suspendedAt: null,
});
};
}
export default User;

View File

@@ -1,24 +1,65 @@
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
import {
BelongsTo,
Column,
DataType,
ForeignKey,
Table,
Unique,
} from "sequelize-typescript";
import AuthenticationProvider from "./AuthenticationProvider";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Encrypted, {
getEncryptedColumn,
setEncryptedColumn,
} from "./decorators/Encrypted";
import Fix from "./decorators/Fix";
const UserAuthentication = sequelize.define("user_authentications", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
scopes: DataTypes.ARRAY(DataTypes.STRING),
accessToken: encryptedFields().vault("accessToken"),
refreshToken: encryptedFields().vault("refreshToken"),
providerId: {
type: DataTypes.STRING,
unique: true,
},
});
@Table({ tableName: "user_authentications", modelName: "user_authentication" })
@Fix
class UserAuthentication extends BaseModel {
@Column(DataType.ARRAY(DataType.STRING))
scopes: string[];
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
UserAuthentication.associate = (models) => {
UserAuthentication.belongsTo(models.AuthenticationProvider);
UserAuthentication.belongsTo(models.User);
};
@Column(DataType.BLOB)
@Encrypted
get accessToken() {
return getEncryptedColumn(this, "accessToken");
}
set accessToken(value: string) {
setEncryptedColumn(this, "accessToken", value);
}
@Column(DataType.BLOB)
@Encrypted
get refreshToken() {
return getEncryptedColumn(this, "refreshToken");
}
set refreshToken(value: string) {
setEncryptedColumn(this, "refreshToken", value);
}
@Column
providerId: string;
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => AuthenticationProvider, "providerId")
authenticationProvider: AuthenticationProvider;
@ForeignKey(() => AuthenticationProvider)
@Unique
@Column(DataType.UUID)
authenticationProviderId: string;
}
export default UserAuthentication;

View File

@@ -1,85 +1,105 @@
import { subMilliseconds } from "date-fns";
import { Op } from "sequelize";
import {
BelongsTo,
Column,
Default,
ForeignKey,
Table,
DataType,
} from "sequelize-typescript";
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
import { User } from "@server/models";
import { DataTypes, Op, sequelize } from "../sequelize";
import Document from "./Document";
import User from "./User";
import BaseModel from "./base/BaseModel";
import Fix from "./decorators/Fix";
const View = sequelize.define("view", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
lastEditingAt: {
type: DataTypes.DATE,
},
count: {
type: DataTypes.INTEGER,
defaultValue: 1,
},
});
@Table({ tableName: "views", modelName: "view" })
@Fix
class View extends BaseModel {
@Column
lastEditingAt: Date | null;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
View.associate = (models) => {
View.belongsTo(models.Document);
View.belongsTo(models.User);
};
@Default(1)
@Column(DataType.INTEGER)
count: number;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'where' implicitly has an 'any' type.
View.increment = async (where) => {
const [model, created] = await View.findOrCreate({
where,
});
// associations
if (!created) {
model.count += 1;
model.save();
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
static async incrementOrCreate(where: {
userId?: string;
documentId?: string;
collectionId?: string;
}) {
const [model, created] = await this.findOrCreate({
where,
});
if (!created) {
model.count += 1;
model.save();
}
return model;
}
return model;
};
View.findByDocument = async (documentId: string) => {
return View.findAll({
where: {
documentId,
},
order: [["updatedAt", "DESC"]],
include: [
{
model: User,
paranoid: false,
static async findByDocument(documentId: string) {
return this.findAll({
where: {
documentId,
},
],
});
};
View.findRecentlyEditingByDocument = async (documentId: string) => {
return View.findAll({
where: {
documentId,
lastEditingAt: {
[Op.gt]: subMilliseconds(new Date(), USER_PRESENCE_INTERVAL * 2),
},
},
order: [["lastEditingAt", "DESC"]],
});
};
View.touch = async (documentId: string, userId: string, isEditing: boolean) => {
const [view] = await View.findOrCreate({
where: {
userId,
documentId,
},
});
if (isEditing) {
const lastEditingAt = new Date();
view.lastEditingAt = lastEditingAt;
await view.save();
order: [["updatedAt", "DESC"]],
include: [
{
model: User,
paranoid: false,
},
],
});
}
return view;
};
static async findRecentlyEditingByDocument(documentId: string) {
return this.findAll({
where: {
documentId,
lastEditingAt: {
[Op.gt]: subMilliseconds(new Date(), USER_PRESENCE_INTERVAL * 2),
},
},
order: [["lastEditingAt", "DESC"]],
});
}
static async touch(documentId: string, userId: string, isEditing: boolean) {
const [view] = await this.findOrCreate({
where: {
userId,
documentId,
},
});
if (isEditing) {
const lastEditingAt = new Date();
view.lastEditingAt = lastEditingAt;
await view.save();
}
return view;
}
}
export default View;

View File

@@ -0,0 +1,26 @@
import {
CreatedAt,
UpdatedAt,
Column,
PrimaryKey,
IsUUID,
DataType,
Model,
Default,
} from "sequelize-typescript";
class BaseModel extends Model {
@IsUUID(4)
@PrimaryKey
@Default(DataType.UUIDV4)
@Column(DataType.UUID)
id: string;
@CreatedAt
createdAt: Date;
@UpdatedAt
updatedAt: Date;
}
export default BaseModel;

View File

@@ -0,0 +1,9 @@
import { DeletedAt } from "sequelize-typescript";
import BaseModel from "./BaseModel";
class ParanoidModel extends BaseModel {
@DeletedAt
deletedAt: Date | null;
}
export default ParanoidModel;

View File

@@ -0,0 +1,30 @@
import vaults from "@server/database/vaults";
const key = "sequelize:vault";
/**
* A decorator that stores the encrypted vault for a particular database column
* so that it can be used by getters and setters. Must be accompanied by a
* @Column(DataType.BLOB) annotation.
*/
export default function Encrypted(target: any, propertyKey: string) {
Reflect.defineMetadata(key, vaults().vault(propertyKey), target, propertyKey);
}
/**
* Get the value of an encrypted column given the target and the property key.
*/
export function getEncryptedColumn(target: any, propertyKey: string): string {
return Reflect.getMetadata(key, target, propertyKey).get.call(target);
}
/**
* Set the value of an encrypted column given the target and the property key.
*/
export function setEncryptedColumn(
target: any,
propertyKey: string,
value: string
) {
Reflect.getMetadata(key, target, propertyKey).set.call(target, value);
}

View File

@@ -0,0 +1,50 @@
/**
* A decorator that must be applied to every model definition to workaround
* babel <> typescript incompatibility. See the following issue:
* https://github.com/RobinBuschmann/sequelize-typescript/issues/612#issuecomment-491890977
*
* @param target model class
*/
export default function Fix(target: any): void {
return class extends target {
constructor(...args: any[]) {
super(...args);
const rawAttributes = Object.keys(new.target.rawAttributes);
const associations = Object.keys(new.target.associations);
rawAttributes.forEach((propertyKey) => {
// check if we already defined getter/setter if so, do not override
const desc = Object.getOwnPropertyDescriptor(
target.prototype,
propertyKey
);
if (desc) {
return;
}
Object.defineProperty(this, propertyKey, {
get() {
return this.getDataValue(propertyKey);
},
set(value) {
this.setDataValue(propertyKey, value);
},
});
});
associations.forEach((propertyKey) => {
Object.defineProperty(this, propertyKey, {
get() {
return this.dataValues[propertyKey];
},
set(value) {
// sets without changing the "changed" flag for associations
this.dataValues[propertyKey] = value;
},
});
});
}
} as any;
}

View File

@@ -1,88 +1,49 @@
import ApiKey from "./ApiKey";
import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider";
import Backlink from "./Backlink";
import Collection from "./Collection";
import CollectionGroup from "./CollectionGroup";
import CollectionUser from "./CollectionUser";
import Document from "./Document";
import Event from "./Event";
import FileOperation from "./FileOperation";
import Group from "./Group";
import GroupUser from "./GroupUser";
import Integration from "./Integration";
import IntegrationAuthentication from "./IntegrationAuthentication";
import Notification from "./Notification";
import NotificationSetting from "./NotificationSetting";
import Pin from "./Pin";
import Revision from "./Revision";
import SearchQuery from "./SearchQuery";
import Share from "./Share";
import Star from "./Star";
import Team from "./Team";
import User from "./User";
import UserAuthentication from "./UserAuthentication";
import View from "./View";
export { default as ApiKey } from "./ApiKey";
const models = {
ApiKey,
Attachment,
AuthenticationProvider,
Backlink,
Collection,
CollectionGroup,
CollectionUser,
Document,
Event,
Group,
GroupUser,
Integration,
IntegrationAuthentication,
Notification,
NotificationSetting,
Pin,
Revision,
SearchQuery,
Share,
Star,
Team,
User,
UserAuthentication,
View,
FileOperation,
};
export { default as Attachment } from "./Attachment";
// based on https://github.com/sequelize/express-example/blob/master/models/index.js
Object.keys(models).forEach((modelName) => {
if ("associate" in models[modelName]) {
models[modelName].associate(models);
}
});
export { default as AuthenticationProvider } from "./AuthenticationProvider";
export {
ApiKey,
Attachment,
AuthenticationProvider,
Backlink,
Collection,
CollectionGroup,
CollectionUser,
Document,
Event,
Group,
GroupUser,
Integration,
IntegrationAuthentication,
Notification,
NotificationSetting,
Pin,
Revision,
SearchQuery,
Share,
Star,
Team,
User,
UserAuthentication,
View,
FileOperation,
};
export { default as Backlink } from "./Backlink";
export { default as Collection } from "./Collection";
export { default as CollectionGroup } from "./CollectionGroup";
export { default as CollectionUser } from "./CollectionUser";
export { default as Document } from "./Document";
export { default as Event } from "./Event";
export { default as FileOperation } from "./FileOperation";
export { default as Group } from "./Group";
export { default as GroupUser } from "./GroupUser";
export { default as Integration } from "./Integration";
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
export { default as Notification } from "./Notification";
export { default as NotificationSetting } from "./NotificationSetting";
export { default as Pin } from "./Pin";
export { default as Revision } from "./Revision";
export { default as SearchQuery } from "./SearchQuery";
export { default as Share } from "./Share";
export { default as Star } from "./Star";
export { default as Team } from "./Team";
export { default as User } from "./User";
export { default as UserAuthentication } from "./UserAuthentication";
export { default as View } from "./View";

View File

@@ -1,7 +1,5 @@
import { ApiKey, User, Team } from "@server/models";
import policy from "./policy";
const { allow } = policy;
import { allow } from "./cancan";
allow(User, "createApiKey", Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) return false;
@@ -9,6 +7,7 @@ allow(User, "createApiKey", Team, (user, team) => {
});
allow(User, ["read", "update", "delete"], ApiKey, (user, apiKey) => {
if (!apiKey) return false;
if (user.isViewer) return false;
return user && user.id === apiKey.userId;
});

View File

@@ -1,7 +1,5 @@
import { Attachment, User, Team } from "@server/models";
import policy from "./policy";
const { allow } = policy;
import { allow } from "./cancan";
allow(User, "createAttachment", Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) return false;

View File

@@ -1,8 +1,6 @@
import { AuthenticationProvider, User, Team } from "@server/models";
import { AdminRequiredError } from "../errors";
import policy from "./policy";
const { allow } = policy;
import { allow } from "./cancan";
allow(User, "createAuthenticationProvider", Team, (actor, team) => {
if (!team || actor.teamId !== team.id) return false;
@@ -17,7 +15,7 @@ allow(
AuthenticationProvider,
(actor, authenticationProvider) =>
actor && actor.teamId === authenticationProvider.teamId
actor && actor.teamId === authenticationProvider?.teamId
);
allow(
@@ -26,7 +24,7 @@ allow(
AuthenticationProvider,
(actor, authenticationProvider) => {
if (actor.teamId !== authenticationProvider.teamId) return false;
if (actor.teamId !== authenticationProvider?.teamId) return false;
if (actor.isAdmin) return true;
throw AdminRequiredError();

13
server/policies/cancan.ts Normal file
View File

@@ -0,0 +1,13 @@
import CanCan from "cancan";
const cancan = new CanCan();
export const _can = cancan.can;
export const _authorize = cancan.authorize;
export const _cannot = cancan.cannot;
export const _abilities = cancan.abilities;
export const allow = cancan.allow;

View File

@@ -4,6 +4,7 @@ import { flushdb } from "@server/test/support";
import { serialize } from "./index";
beforeEach(() => flushdb());
describe("read_write permission", () => {
it("should allow read write permissions for team member", async () => {
const team = await buildTeam();
@@ -25,7 +26,7 @@ describe("read_write permission", () => {
const user = await buildUser({
teamId: team.id,
});
let collection = await buildCollection({
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
});
@@ -36,15 +37,16 @@ describe("read_write permission", () => {
permission: "read",
});
// reload to get membership
collection = await Collection.scope({
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, collection);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});
describe("read permission", () => {
it("should allow read permissions for team member", async () => {
const team = await buildTeam();
@@ -66,7 +68,7 @@ describe("read permission", () => {
const user = await buildUser({
teamId: team.id,
});
let collection = await buildCollection({
const collection = await buildCollection({
teamId: team.id,
permission: "read",
});
@@ -77,15 +79,16 @@ describe("read permission", () => {
permission: "read_write",
});
// reload to get membership
collection = await Collection.scope({
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, collection);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});
describe("no permission", () => {
it("should allow no permissions for team member", async () => {
const team = await buildTeam();
@@ -107,7 +110,7 @@ describe("no permission", () => {
const user = await buildUser({
teamId: team.id,
});
let collection = await buildCollection({
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
@@ -118,10 +121,10 @@ describe("no permission", () => {
permission: "read_write",
});
// reload to get membership
collection = await Collection.scope({
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, collection);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);

View File

@@ -1,10 +1,8 @@
import invariant from "invariant";
import { concat, some } from "lodash";
import { some } from "lodash";
import { Collection, User, Team } from "@server/models";
import { AdminRequiredError } from "../errors";
import policy from "./policy";
const { allow } = policy;
import { allow } from "./cancan";
allow(User, "createCollection", Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) return false;
@@ -34,10 +32,10 @@ allow(User, "read", Collection, (user, collection) => {
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = concat(
collection.memberships,
collection.collectionGroupMemberships
);
const allMemberships = [
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read", "read_write", "maintainer"].includes(m.permission)
);
@@ -56,10 +54,10 @@ allow(User, "share", Collection, (user, collection) => {
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = concat(
collection.memberships,
collection.collectionGroupMemberships
);
const allMemberships = [
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
);
@@ -77,10 +75,10 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = concat(
collection.memberships,
collection.collectionGroupMemberships
);
const allMemberships = [
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
);
@@ -98,10 +96,10 @@ allow(User, "delete", Collection, (user, collection) => {
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = concat(
collection.memberships,
collection.collectionGroupMemberships
);
const allMemberships = [
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
);

View File

@@ -8,6 +8,7 @@ import { flushdb } from "@server/test/support";
import { serialize } from "./index";
beforeEach(() => flushdb());
describe("read_write collection", () => {
it("should allow read write permissions for team member", async () => {
const team = await buildTeam();
@@ -33,6 +34,7 @@ describe("read_write collection", () => {
expect(abilities.move).toEqual(true);
});
});
describe("read collection", () => {
it("should allow read only permissions permissions for team member", async () => {
const team = await buildTeam();
@@ -58,6 +60,7 @@ describe("read collection", () => {
expect(abilities.move).toEqual(false);
});
});
describe("private collection", () => {
it("should allow no permissions for team member", async () => {
const team = await buildTeam();

View File

@@ -1,8 +1,7 @@
import invariant from "invariant";
import { Document, Revision, User, Team } from "@server/models";
import policy from "./policy";
const { allow, cannot } = policy;
import { NavigationNode } from "~/types";
import { allow, _cannot as cannot } from "./cancan";
allow(User, "createDocument", Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) return false;
@@ -10,6 +9,8 @@ allow(User, "createDocument", Team, (user, team) => {
});
allow(User, ["read", "download"], Document, (user, document) => {
if (!document) return false;
// existence of collection option is not required here to account for share tokens
if (document.collection && cannot(user, "read", document.collection)) {
return false;
@@ -19,6 +20,7 @@ allow(User, ["read", "download"], Document, (user, document) => {
});
allow(User, ["star", "unstar"], Document, (user, document) => {
if (!document) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (document.template) return false;
@@ -31,8 +33,13 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
});
allow(User, "share", Document, (user, document) => {
if (!document) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
);
if (cannot(user, "share", document.collection)) {
return false;
@@ -42,6 +49,7 @@ allow(User, "share", Document, (user, document) => {
});
allow(User, "update", Document, (user, document) => {
if (!document) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
@@ -53,6 +61,7 @@ allow(User, "update", Document, (user, document) => {
});
allow(User, "createChildDocument", Document, (user, document) => {
if (!document) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (document.template) return false;
@@ -66,6 +75,7 @@ allow(User, "createChildDocument", Document, (user, document) => {
});
allow(User, "move", Document, (user, document) => {
if (!document) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (!document.publishedAt) return false;
@@ -78,6 +88,7 @@ allow(User, "move", Document, (user, document) => {
});
allow(User, ["pin", "unpin"], Document, (user, document) => {
if (!document) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (document.template) return false;
@@ -91,6 +102,7 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
});
allow(User, ["pinToHome"], Document, (user, document) => {
if (!document) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (document.template) return false;
@@ -100,8 +112,9 @@ allow(User, ["pinToHome"], Document, (user, document) => {
});
allow(User, "delete", Document, (user, document) => {
if (user.isViewer) return false;
if (!document) return false;
if (document.deletedAt) return false;
if (user.isViewer) return false;
// allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) {
@@ -121,8 +134,9 @@ allow(User, "delete", Document, (user, document) => {
});
allow(User, "permanentDelete", Document, (user, document) => {
if (user.isViewer) return false;
if (!document) return false;
if (!document.deletedAt) return false;
if (user.isViewer) return false;
// allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) {
@@ -133,8 +147,9 @@ allow(User, "permanentDelete", Document, (user, document) => {
});
allow(User, "restore", Document, (user, document) => {
if (user.isViewer) return false;
if (!document) return false;
if (!document.deletedAt) return false;
if (user.isViewer) return false;
if (document.collection && cannot(user, "update", document.collection)) {
return false;
@@ -144,6 +159,7 @@ allow(User, "restore", Document, (user, document) => {
});
allow(User, "archive", Document, (user, document) => {
if (!document) return false;
if (!document.publishedAt) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
@@ -156,6 +172,7 @@ allow(User, "archive", Document, (user, document) => {
});
allow(User, "unarchive", Document, (user, document) => {
if (!document) return false;
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
@@ -170,10 +187,11 @@ allow(
Document,
"restore",
Revision,
(document, revision) => document.id === revision.documentId
(document, revision) => document.id === revision?.documentId
);
allow(User, "unpublish", Document, (user, document) => {
if (!document) return false;
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
@@ -183,16 +201,14 @@ allow(User, "unpublish", Document, (user, document) => {
if (cannot(user, "update", document.collection)) return false;
const documentID = document.id;
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'documents' implicitly has an 'any' type... Remove this comment to see the full error message
const hasChild = (documents) =>
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'doc' implicitly has an 'any' type.
const hasChild = (documents: NavigationNode[]): boolean =>
documents.some((doc) => {
if (doc.id === documentID) return doc.children.length > 0;
return hasChild(doc.children);
});
return (
!hasChild(document.collection.documentStructure) &&
!hasChild(document.collection.documentStructure || []) &&
user.teamId === document.teamId
);
});

View File

@@ -1,8 +1,6 @@
import { Group, User, Team } from "@server/models";
import { AdminRequiredError } from "../errors";
import policy from "./policy";
const { allow } = policy;
import { allow } from "./cancan";
allow(User, "createGroup", Team, (actor, team) => {
if (!team || actor.isViewer || actor.teamId !== team.id) return false;

View File

@@ -3,12 +3,14 @@ import { flushdb } from "@server/test/support";
import { serialize } from "./index";
beforeEach(() => flushdb());
it("should serialize policy", async () => {
const user = await buildUser();
const response = serialize(user, user);
expect(response.update).toEqual(true);
expect(response.delete).toEqual(true);
});
it("should serialize domain policies on Team", async () => {
const team = await buildTeam();
const user = await buildUser({

View File

@@ -6,7 +6,7 @@ import {
Document,
Group,
} from "@server/models";
import policy from "./policy";
import { _abilities, _can, _cannot, _authorize } from "./cancan";
import "./apiKey";
import "./attachment";
import "./authenticationProvider";
@@ -21,19 +21,26 @@ import "./user";
import "./team";
import "./group";
const { can, abilities } = policy;
type Policy = Record<string, boolean>;
// this should not be needed but is a workaround for this TypeScript issue:
// https://github.com/microsoft/TypeScript/issues/36931
export const authorize: typeof _authorize = _authorize;
export const can = _can;
export const cannot = _cannot;
export const abilities = _abilities;
/*
* Given a user and a model output an object which describes the actions the
* user may take against the model. This serialized policy is used for testing
* and sent in API responses to allow clients to adjust which UI is displayed.
*/
export function serialize(
// @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
model: User,
// @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
target: Attachment | Team | Collection | Document | Group
target: Attachment | Team | Collection | Document | User | Group | null
): Policy {
const output = {};
abilities.forEach((ability) => {
@@ -51,5 +58,3 @@ export function serialize(
});
return output;
}
export default policy;

View File

@@ -1,8 +1,6 @@
import { Integration, User, Team } from "@server/models";
import { AdminRequiredError } from "../errors";
import policy from "./policy";
const { allow } = policy;
import { allow } from "./cancan";
allow(User, "createIntegration", Team, (actor, team) => {
if (!team || actor.isViewer || actor.teamId !== team.id) return false;
@@ -15,7 +13,7 @@ allow(
User,
"read",
Integration,
(user, integration) => user.teamId === integration.teamId
(user, integration) => user.teamId === integration?.teamId
);
allow(User, ["update", "delete"], Integration, (user, integration) => {

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