mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a570cddb19 | |||
| a0f6fd0751 | |||
| 08be6082fb | |||
| a8391dfec0 | |||
| 18d25b0e0e | |||
| 054e3b8321 | |||
| 78e5fa8d6f | |||
| 1a86238af4 | |||
| 9b383cbdf8 | |||
| 114436a585 | |||
| a41a21726b | |||
| 65be484d99 | |||
| 1eae330775 | |||
| 3ac9a644ea | |||
| 0d30474af1 | |||
| b1be4a9f15 | |||
| 4555e3b957 | |||
| ac919db914 | |||
| c6e5e46544 | |||
| 9abb279a32 | |||
| bb5a471df8 |
@@ -1,9 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Attachment, Event, User } from "@server/models";
|
||||
import { Attachment, User } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { APIContext } from "@server/types";
|
||||
import { RequestInit } from "@server/utils/fetch";
|
||||
|
||||
type BaseProps = {
|
||||
@@ -17,10 +17,8 @@ type BaseProps = {
|
||||
source?: "import";
|
||||
/** The preset to use for the attachment */
|
||||
preset: AttachmentPreset;
|
||||
/** The IP address of the user creating the attachment, if available. */
|
||||
ip?: string;
|
||||
/** The database transaction to use for the creation */
|
||||
transaction?: Transaction;
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
/** Options to pass to fetch when downloading the attachment */
|
||||
fetchOptions?: RequestInit;
|
||||
};
|
||||
@@ -42,8 +40,7 @@ export default async function attachmentCreator({
|
||||
user,
|
||||
source,
|
||||
preset,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
fetchOptions,
|
||||
...rest
|
||||
}: Props): Promise<Attachment | undefined> {
|
||||
@@ -64,20 +61,15 @@ export default async function attachmentCreator({
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: res.contentLength,
|
||||
contentType: res.contentType,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
attachment = await Attachment.createWithCtx(ctx, {
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: res.contentLength,
|
||||
contentType: res.contentType,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
} else {
|
||||
const { buffer, type } = rest;
|
||||
await FileStorage.store({
|
||||
@@ -88,38 +80,16 @@ export default async function attachmentCreator({
|
||||
acl,
|
||||
});
|
||||
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "attachments.create",
|
||||
data: {
|
||||
name,
|
||||
source,
|
||||
},
|
||||
modelId: attachment.id,
|
||||
attachment = await Attachment.createWithCtx(ctx, {
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
@@ -6,8 +5,9 @@ import {
|
||||
FileOperationState,
|
||||
} from "@shared/types";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Collection, Event, Team, User, FileOperation } from "@server/models";
|
||||
import { Collection, Team, User, FileOperation } from "@server/models";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
import { type APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
@@ -15,8 +15,7 @@ type Props = {
|
||||
user: User;
|
||||
format?: FileOperationFormat;
|
||||
includeAttachments?: boolean;
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
function getKeyForFileOp(
|
||||
@@ -35,8 +34,7 @@ async function collectionExporter({
|
||||
user,
|
||||
format = FileOperationFormat.MarkdownZip,
|
||||
includeAttachments = true,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props) {
|
||||
const collectionId = collection?.id;
|
||||
const key = getKeyForFileOp(
|
||||
@@ -44,43 +42,20 @@ async function collectionExporter({
|
||||
format,
|
||||
collection?.name || team.name
|
||||
);
|
||||
const fileOperation = await FileOperation.create(
|
||||
{
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
includeAttachments,
|
||||
},
|
||||
const fileOperation = await FileOperation.createWithCtx(ctx, {
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId,
|
||||
options: {
|
||||
includeAttachments,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "fileOperations.create",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
modelId: fileOperation.id,
|
||||
collectionId,
|
||||
ip,
|
||||
data: {
|
||||
type: FileOperationType.Export,
|
||||
format,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
fileOperation.user = user;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Optional } from "utility-types";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = Optional<
|
||||
Pick<
|
||||
@@ -31,8 +31,7 @@ type Props = Optional<
|
||||
publish?: boolean;
|
||||
templateDocument?: Document | null;
|
||||
user: User;
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
export default async function documentCreator({
|
||||
@@ -58,9 +57,9 @@ export default async function documentCreator({
|
||||
editorVersion,
|
||||
publishedAt,
|
||||
sourceMetadata,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props): Promise<Document> {
|
||||
const { transaction, ip } = ctx.context;
|
||||
const templateId = templateDocument ? templateDocument.id : undefined;
|
||||
|
||||
if (state && templateDocument) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createContext } from "@server/context";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import documentDuplicator from "./documentDuplicator";
|
||||
|
||||
describe("documentDuplicator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should duplicate existing document", async () => {
|
||||
const user = await buildUser();
|
||||
const original = await buildDocument({
|
||||
@@ -16,9 +15,8 @@ describe("documentDuplicator", () => {
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
transaction,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -43,9 +41,8 @@ describe("documentDuplicator", () => {
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
title: "New title",
|
||||
transaction,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -77,9 +74,8 @@ describe("documentDuplicator", () => {
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
user,
|
||||
transaction,
|
||||
recursive: true,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -97,10 +93,9 @@ describe("documentDuplicator", () => {
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
transaction,
|
||||
publish: false,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Transaction, Op } from "sequelize";
|
||||
import { Op } from "sequelize";
|
||||
import { User, Collection, Document } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
import documentCreator from "./documentCreator";
|
||||
|
||||
type Props = {
|
||||
@@ -19,10 +20,8 @@ type Props = {
|
||||
publish?: boolean;
|
||||
/** Whether to duplicate child documents */
|
||||
recursive?: boolean;
|
||||
/** The database transaction to use for the creation */
|
||||
transaction?: Transaction;
|
||||
/** The IP address of the request */
|
||||
ip: string;
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
export default async function documentDuplicator({
|
||||
@@ -33,16 +32,14 @@ export default async function documentDuplicator({
|
||||
title,
|
||||
publish,
|
||||
recursive,
|
||||
transaction,
|
||||
ip,
|
||||
ctx,
|
||||
}: Props): Promise<Document[]> {
|
||||
const newDocuments: Document[] = [];
|
||||
const sharedProperties = {
|
||||
user,
|
||||
collectionId: collection?.id,
|
||||
publish: publish ?? !!document.publishedAt,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
};
|
||||
|
||||
const duplicated = await documentCreator({
|
||||
@@ -76,9 +73,7 @@ export default async function documentDuplicator({
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
ctx
|
||||
);
|
||||
|
||||
for (const childDocument of childDocuments) {
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import { createContext } from "@server/context";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import documentImporter from "./documentImporter";
|
||||
|
||||
jest.mock("@server/storage/files");
|
||||
|
||||
describe("documentImporter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should convert Word Document to markdown", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "images.docx";
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -40,13 +43,15 @@ describe("documentImporter", () => {
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -67,13 +72,15 @@ describe("documentImporter", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
@@ -87,13 +94,15 @@ describe("documentImporter", () => {
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -112,13 +121,15 @@ describe("documentImporter", () => {
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
"utf8"
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("Text paragraph");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -129,13 +140,16 @@ describe("documentImporter", () => {
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/msword",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/msword",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.text).toContain("this is a test document");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -147,13 +161,15 @@ describe("documentImporter", () => {
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
"utf8"
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("This is a test paragraph");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -162,13 +178,16 @@ describe("documentImporter", () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "markdown.md";
|
||||
const content = `# Title`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.text).toEqual("");
|
||||
expect(response.title).toEqual("Title");
|
||||
});
|
||||
@@ -180,13 +199,15 @@ describe("documentImporter", () => {
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
"utf8"
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/lol",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/lol",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("This is a test paragraph");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -200,13 +221,15 @@ describe("documentImporter", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await documentImporter({
|
||||
user,
|
||||
mimeType: "executable/zip",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "executable/zip",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
@@ -228,13 +251,15 @@ describe("documentImporter", () => {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("\\$100");
|
||||
});
|
||||
|
||||
@@ -252,13 +277,15 @@ describe("documentImporter", () => {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("`echo $foo`");
|
||||
});
|
||||
|
||||
@@ -276,13 +303,15 @@ describe("documentImporter", () => {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("```\necho $foo\n```");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import truncate from "lodash/truncate";
|
||||
import { Transaction } from "sequelize";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
import { DocumentConverter } from "@server/utils/DocumentConverter";
|
||||
import { InvalidRequestError } from "../errors";
|
||||
|
||||
@@ -16,8 +16,7 @@ type Props = {
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
content: Buffer | string;
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
async function documentImporter({
|
||||
@@ -25,8 +24,7 @@ async function documentImporter({
|
||||
fileName,
|
||||
content,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props): Promise<{
|
||||
icon?: string;
|
||||
text: string;
|
||||
@@ -66,12 +64,7 @@ async function documentImporter({
|
||||
// Remove any closed and immediately reopened formatting marks
|
||||
text = text.replace(/\*\*\*\*/gi, "").replace(/____/gi, "");
|
||||
|
||||
text = await TextHelper.replaceImagesWithAttachments(
|
||||
text,
|
||||
user,
|
||||
ip,
|
||||
transaction
|
||||
);
|
||||
text = await TextHelper.replaceImagesWithAttachments(ctx, text, user);
|
||||
|
||||
// Sanity check – text cannot possibly be longer than state so if it is, we can short-circuit here
|
||||
if (text.length > DocumentValidation.maxStateLength) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { FileOperation, Event, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
fileOperation: FileOperation;
|
||||
user: User;
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
};
|
||||
|
||||
export default async function fileOperationDeleter({
|
||||
fileOperation,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props) {
|
||||
await fileOperation.destroy({ transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "fileOperations.delete",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
modelId: fileOperation.id,
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
export function createContext(
|
||||
user: User,
|
||||
transaction?: Transaction,
|
||||
ip?: string
|
||||
) {
|
||||
return {
|
||||
context: {
|
||||
ip: ip ?? user.lastActiveIp,
|
||||
transaction,
|
||||
auth: { user },
|
||||
},
|
||||
} as APIContext;
|
||||
}
|
||||
@@ -155,6 +155,16 @@ export default function auth(options: AuthenticationOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(ctx, "context", {
|
||||
get() {
|
||||
return {
|
||||
auth: ctx.state.auth,
|
||||
transaction: ctx.state.transaction,
|
||||
ip: ctx.request.ip,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { ApiKeyValidation } from "@shared/validations";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@@ -28,6 +29,8 @@ class ApiKey extends ParanoidModel<
|
||||
> {
|
||||
static prefix = "ol_api_";
|
||||
|
||||
static eventNamespace = "api_keys";
|
||||
|
||||
@Length({
|
||||
min: ApiKeyValidation.minNameLength,
|
||||
max: ApiKeyValidation.maxNameLength,
|
||||
@@ -48,10 +51,12 @@ class ApiKey extends ParanoidModel<
|
||||
/** The hashed value of the API key */
|
||||
@Unique
|
||||
@Column
|
||||
@SkipChangeset
|
||||
hash: string;
|
||||
|
||||
/** The last 4 characters of the API key */
|
||||
@Column
|
||||
@SkipChangeset
|
||||
last4: string;
|
||||
|
||||
@IsDate
|
||||
|
||||
@@ -26,6 +26,7 @@ import Document from "./Document";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@@ -35,6 +36,8 @@ class Attachment extends IdModel<
|
||||
InferAttributes<Attachment>,
|
||||
Partial<InferCreationAttributes<Attachment>>
|
||||
> {
|
||||
static eventNamespace = "attachments";
|
||||
|
||||
@Length({
|
||||
max: 4096,
|
||||
msg: "key must be 4096 characters or less",
|
||||
@@ -59,6 +62,7 @@ class Attachment extends IdModel<
|
||||
acl: string;
|
||||
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastAccessedAt: Date | null;
|
||||
|
||||
@Column
|
||||
|
||||
@@ -55,6 +55,7 @@ class Event extends IdModel<
|
||||
|
||||
/**
|
||||
* Metadata associated with the event, previously used for storing some changed attributes.
|
||||
* Note that the `data` column will be visible to the client and API requests.
|
||||
*/
|
||||
@Column(DataType.JSONB)
|
||||
data: Record<string, any> | null;
|
||||
|
||||
@@ -51,6 +51,8 @@ class FileOperation extends ParanoidModel<
|
||||
InferAttributes<FileOperation>,
|
||||
Partial<InferCreationAttributes<FileOperation>>
|
||||
> {
|
||||
static eventNamespace = "fileOperations";
|
||||
|
||||
@Column(DataType.ENUM(...Object.values(FileOperationType)))
|
||||
type: FileOperationType;
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("Model", () => {
|
||||
expect(document.changeset.previous.collaboratorIds).toEqual(prev);
|
||||
});
|
||||
});
|
||||
|
||||
describe("batch load", () => {
|
||||
it("should return data in batches", async () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
+207
-4
@@ -3,14 +3,173 @@ import isEqual from "fast-deep-equal";
|
||||
import isArray from "lodash/isArray";
|
||||
import isObject from "lodash/isObject";
|
||||
import pick from "lodash/pick";
|
||||
import { FindOptions, NonAttribute } from "sequelize";
|
||||
import { Model as SequelizeModel } from "sequelize-typescript";
|
||||
import { Replace } from "@server/types";
|
||||
import {
|
||||
CreateOptions,
|
||||
CreationAttributes,
|
||||
DataTypes,
|
||||
FindOptions,
|
||||
InstanceDestroyOptions,
|
||||
InstanceUpdateOptions,
|
||||
ModelStatic,
|
||||
NonAttribute,
|
||||
SaveOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
AfterCreate,
|
||||
AfterDestroy,
|
||||
AfterUpdate,
|
||||
BeforeCreate,
|
||||
Model as SequelizeModel,
|
||||
} from "sequelize-typescript";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Replace, APIContext } from "@server/types";
|
||||
import { getChangsetSkipped } from "../decorators/Changeset";
|
||||
|
||||
class Model<
|
||||
TModelAttributes extends {} = any,
|
||||
TCreationAttributes extends {} = TModelAttributes
|
||||
> extends SequelizeModel<TModelAttributes, TCreationAttributes> {
|
||||
/**
|
||||
* The namespace to use for events, if none is provided an event will not be created
|
||||
* during the migration period. In the future this may default to the table name.
|
||||
*/
|
||||
static eventNamespace: string | undefined;
|
||||
|
||||
/**
|
||||
* Validates this instance, and if the validation passes, persists it to the database.
|
||||
*/
|
||||
public saveWithCtx(ctx: APIContext) {
|
||||
this.cacheChangeset();
|
||||
return this.save(ctx.context as SaveOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the same as calling `set` and then calling `save`.
|
||||
*/
|
||||
public updateWithCtx(ctx: APIContext, keys: Partial<TModelAttributes>) {
|
||||
this.cacheChangeset();
|
||||
return this.update(keys, ctx.context as InstanceUpdateOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the row corresponding to this instance. Depending on your setting for paranoid, the row will
|
||||
* either be completely deleted, or have its deletedAt timestamp set to the current time.
|
||||
*/
|
||||
public destroyWithCtx(ctx: APIContext) {
|
||||
return this.destroy(ctx.context as InstanceDestroyOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new model instance and calls save on it.
|
||||
*/
|
||||
public static createWithCtx<M extends Model>(
|
||||
this: ModelStatic<M>,
|
||||
ctx: APIContext,
|
||||
values?: CreationAttributes<M>
|
||||
) {
|
||||
return this.create(values, ctx.context as CreateOptions);
|
||||
}
|
||||
|
||||
@BeforeCreate
|
||||
static async beforeCreateEvent<T extends Model>(model: T) {
|
||||
model.cacheChangeset();
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static async afterCreateEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("create", model, context);
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static async afterUpdateEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("update", model, context);
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
static async afterDestroyEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("delete", model, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an event into the database recording a mutation to this model.
|
||||
*
|
||||
* @param name The name of the event.
|
||||
* @param model The model that was mutated.
|
||||
* @param context The API context.
|
||||
*/
|
||||
protected static async insertEvent<T extends Model>(
|
||||
name: string,
|
||||
model: T,
|
||||
context: APIContext["context"] & InstanceUpdateOptions
|
||||
) {
|
||||
const namespace = this.eventNamespace;
|
||||
const models = this.sequelize!.models;
|
||||
|
||||
// If no namespace is defined, don't create an event
|
||||
if (!namespace || context.silent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.transaction) {
|
||||
Logger.warn("No transaction provided to insertEvent", {
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.ip) {
|
||||
Logger.warn("No ip provided to insertEvent", {
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
|
||||
return models.event.create(
|
||||
{
|
||||
name: `${namespace}.${name}`,
|
||||
modelId: model.id,
|
||||
collectionId:
|
||||
"collectionId" in model
|
||||
? model.collectionId
|
||||
: model instanceof models.collection
|
||||
? model.id
|
||||
: undefined,
|
||||
documentId:
|
||||
"documentId" in model
|
||||
? model.documentId
|
||||
: model instanceof models.document
|
||||
? model.id
|
||||
: undefined,
|
||||
userId:
|
||||
"userId" in model
|
||||
? model.userId
|
||||
: model instanceof models.user
|
||||
? model.id
|
||||
: undefined,
|
||||
teamId:
|
||||
"teamId" in model
|
||||
? model.teamId
|
||||
: model instanceof models.team
|
||||
? model.id
|
||||
: context.auth?.user.teamId,
|
||||
actorId: context.auth?.user?.id,
|
||||
authType: context.auth?.type,
|
||||
ip: context.ip,
|
||||
changes: model.previousChangeset,
|
||||
},
|
||||
{
|
||||
transaction: context.transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all models in batches, calling the callback function for each batch.
|
||||
*
|
||||
@@ -38,7 +197,7 @@ class Model<
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attributes that have changed since the last save and their previous values.
|
||||
* Returns a representation of the attributes that have changed since the last save and their previous values.
|
||||
*
|
||||
* @returns An object with `attributes` and `previousAttributes` keys.
|
||||
*/
|
||||
@@ -57,10 +216,22 @@ class Model<
|
||||
};
|
||||
}
|
||||
|
||||
const virtualFields = (this.constructor as typeof Model).virtualFields;
|
||||
const blobFields = (this.constructor as typeof Model).blobFields;
|
||||
const skippedFields = getChangsetSkipped(this);
|
||||
|
||||
for (const change of changes) {
|
||||
const previous = this.previous(change);
|
||||
const current = this.getDataValue(change);
|
||||
|
||||
if (
|
||||
virtualFields.includes(String(change)) ||
|
||||
blobFields.includes(String(change)) ||
|
||||
skippedFields.includes(String(change))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isObject(previous) &&
|
||||
isObject(current) &&
|
||||
@@ -91,6 +262,38 @@ class Model<
|
||||
previous: previousAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the current changeset for later use.
|
||||
*/
|
||||
protected cacheChangeset() {
|
||||
this.previousChangeset = this.changeset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the virtual fields for this model.
|
||||
*/
|
||||
protected static get virtualFields() {
|
||||
const attrs = this.rawAttributes;
|
||||
return Object.keys(attrs).filter(
|
||||
(attr) => attrs[attr].type instanceof DataTypes.VIRTUAL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blob fields for this model.
|
||||
*/
|
||||
protected static get blobFields() {
|
||||
const attrs = this.rawAttributes;
|
||||
return Object.keys(attrs).filter(
|
||||
(attr) => attrs[attr].type instanceof DataTypes.BLOB
|
||||
);
|
||||
}
|
||||
|
||||
private previousChangeset: NonAttribute<{
|
||||
attributes: Partial<TModelAttributes>;
|
||||
previous: Partial<TModelAttributes>;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
export default Model;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import "reflect-metadata";
|
||||
|
||||
const key = Symbol("skipChangeset");
|
||||
|
||||
/**
|
||||
* This decorator is used to annotate a property as being skipped from being included in a changeset.
|
||||
*/
|
||||
export function SkipChangeset(target: any, propertyKey: string) {
|
||||
const properties: string[] = Reflect.getMetadata(key, target);
|
||||
|
||||
if (!properties) {
|
||||
return Reflect.defineMetadata(key, [propertyKey], target);
|
||||
}
|
||||
|
||||
properties.push(propertyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to get the properties that should be skipped from a changeset.
|
||||
*/
|
||||
export function getChangsetSkipped(target: any): string[] {
|
||||
return Reflect.getMetadata(key, target) || [];
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import chunk from "lodash/chunk";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import startCase from "lodash/startCase";
|
||||
import { Transaction } from "sequelize";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
@@ -14,6 +13,7 @@ import env from "@server/env";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import { Attachment, User } from "@server/models";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { APIContext } from "@server/types";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import parseImages from "@server/utils/parseImages";
|
||||
|
||||
@@ -83,17 +83,15 @@ export class TextHelper {
|
||||
* Replaces remote and base64 encoded images in the given text with attachment
|
||||
* urls and uploads the images to the storage provider.
|
||||
*
|
||||
* @param ctx The API context
|
||||
* @param markdown The text to replace the images in
|
||||
* @param user The user context
|
||||
* @param ip The IP address of the user
|
||||
* @param transaction The transaction to use for the database operations
|
||||
* @returns The text with the images replaced
|
||||
*/
|
||||
static async replaceImagesWithAttachments(
|
||||
ctx: APIContext,
|
||||
markdown: string,
|
||||
user: User,
|
||||
ip?: string,
|
||||
transaction?: Transaction
|
||||
user: User
|
||||
) {
|
||||
let output = markdown;
|
||||
const images = parseImages(markdown);
|
||||
@@ -117,11 +115,10 @@ export class TextHelper {
|
||||
url: image.src,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
fetchOptions: {
|
||||
timeout: timeoutPerImage,
|
||||
},
|
||||
ctx,
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SourceMetadata } from "@shared/types";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import { User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import FileStorage from "@server/storage/files";
|
||||
@@ -48,8 +49,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
||||
fileName: sourceMetadata.fileName,
|
||||
mimeType: sourceMetadata.mimeType,
|
||||
content,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction, ip),
|
||||
});
|
||||
|
||||
return documentCreator({
|
||||
@@ -62,8 +62,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction, ip),
|
||||
});
|
||||
});
|
||||
return { documentId: document.id };
|
||||
|
||||
@@ -34,7 +34,7 @@ export default class ErrorTimedOutFileOperationsTask extends BaseTask<Props> {
|
||||
fileOperations.map(async (fileOperation) => {
|
||||
fileOperation.state = FileOperationState.Error;
|
||||
fileOperation.error = "Timed out";
|
||||
await fileOperation.save();
|
||||
await fileOperation.save({ hooks: false });
|
||||
})
|
||||
);
|
||||
Logger.info("task", `Updated ${fileOperations.length} file operations`);
|
||||
|
||||
@@ -157,12 +157,17 @@ export default abstract class ExportTask extends BaseTask<Props> {
|
||||
fileOperation: FileOperation,
|
||||
options: Partial<FileOperation> & { error?: Error }
|
||||
) {
|
||||
await fileOperation.update({
|
||||
...options,
|
||||
error: options.error
|
||||
? truncate(options.error.message, { length: 255 })
|
||||
: undefined,
|
||||
});
|
||||
await fileOperation.update(
|
||||
{
|
||||
...options,
|
||||
error: options.error
|
||||
? truncate(options.error.message, { length: 255 })
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.schedule({
|
||||
name: "fileOperations.update",
|
||||
|
||||
@@ -4,9 +4,11 @@ import escapeRegExp from "lodash/escapeRegExp";
|
||||
import mime from "mime-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { FileOperation, User } from "@server/models";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
|
||||
import ImportTask, { StructuredImportData } from "./ImportTask";
|
||||
|
||||
@@ -82,16 +84,19 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, icon, text } = await documentImporter({
|
||||
mimeType: "text/markdown",
|
||||
fileName: child.name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ip: user.lastActiveIp || undefined,
|
||||
});
|
||||
const { title, icon, text } = await sequelize.transaction(
|
||||
async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType: "text/markdown",
|
||||
fileName: child.name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
const existingDocumentIndex = output.documents.findIndex(
|
||||
(doc) =>
|
||||
|
||||
@@ -5,8 +5,10 @@ import escapeRegExp from "lodash/escapeRegExp";
|
||||
import mime from "mime-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { FileOperation, User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
|
||||
import ImportTask, { StructuredImportData } from "./ImportTask";
|
||||
|
||||
@@ -124,16 +126,19 @@ export default class ImportNotionTask extends ImportTask {
|
||||
|
||||
Logger.debug("task", `Processing ${name} as ${mimeType}`);
|
||||
|
||||
const { title, icon, text } = await documentImporter({
|
||||
mimeType: mimeType || "text/markdown",
|
||||
fileName: name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ip: user.lastActiveIp || undefined,
|
||||
});
|
||||
const { title, icon, text } = await sequelize.transaction(
|
||||
async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType: mimeType || "text/markdown",
|
||||
fileName: name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
const existingDocumentIndex = output.documents.findIndex(
|
||||
(doc) => doc.externalId === externalId
|
||||
@@ -246,13 +251,15 @@ export default class ImportNotionTask extends ImportTask {
|
||||
mimeType === "text/plain" ||
|
||||
mimeType === "text/html"
|
||||
) {
|
||||
const { text } = await documentImporter({
|
||||
mimeType,
|
||||
fileName: name,
|
||||
content: await fs.readFile(node.path, "utf8"),
|
||||
user,
|
||||
ip: user.lastActiveIp || undefined,
|
||||
});
|
||||
const { text } = await sequelize.transaction(async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType,
|
||||
fileName: name,
|
||||
content: await fs.readFile(node.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
description = text;
|
||||
} else if (node.children.length > 0) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import attachmentCreator from "@server/commands/attachmentCreator";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { serializer } from "@server/editor";
|
||||
import { InternalError, ValidationError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -183,10 +184,15 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
state: FileOperationState,
|
||||
error?: Error
|
||||
) {
|
||||
await fileOperation.update({
|
||||
state,
|
||||
error: error ? truncate(error.message, { length: 255 }) : undefined,
|
||||
});
|
||||
await fileOperation.update(
|
||||
{
|
||||
state,
|
||||
error: error ? truncate(error.message, { length: 255 }) : undefined,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
}
|
||||
);
|
||||
await Event.schedule({
|
||||
name: "fileOperations.update",
|
||||
modelId: fileOperation.id,
|
||||
@@ -468,8 +474,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
parentDocumentId: item.parentDocumentId,
|
||||
importId: fileOperation.id,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction),
|
||||
});
|
||||
documents.set(item.id, document);
|
||||
|
||||
@@ -503,8 +508,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
type: item.mimeType,
|
||||
buffer: await item.buffer(),
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction),
|
||||
});
|
||||
if (attachment) {
|
||||
attachments.set(item.id, attachment);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { ApiKey, Event, User } from "@server/models";
|
||||
import { ApiKey, User } from "@server/models";
|
||||
import { authorize, cannot } from "@server/policies";
|
||||
import { presentApiKey } from "@server/presenters";
|
||||
import { APIContext, AuthenticationType } from "@server/types";
|
||||
@@ -21,28 +21,17 @@ router.post(
|
||||
async (ctx: APIContext<T.APIKeysCreateReq>) => {
|
||||
const { name, expiresAt } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
authorize(user, "createApiKey", user.team);
|
||||
const key = await ApiKey.create(
|
||||
{
|
||||
name,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "api_keys.create",
|
||||
modelId: key.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
const apiKey = await ApiKey.createWithCtx(ctx, {
|
||||
name,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentApiKey(key),
|
||||
data: presentApiKey(apiKey),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -54,6 +43,7 @@ router.post(
|
||||
validate(T.APIKeysListSchema),
|
||||
async (ctx: APIContext<T.APIKeysListReq>) => {
|
||||
const { userId } = ctx.input.body;
|
||||
const { pagination } = ctx.state;
|
||||
const actor = ctx.state.auth.user;
|
||||
|
||||
let where: WhereOptions<User> = {
|
||||
@@ -77,7 +67,7 @@ router.post(
|
||||
};
|
||||
}
|
||||
|
||||
const keys = await ApiKey.findAll({
|
||||
const apiKeys = await ApiKey.findAll({
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
@@ -86,13 +76,13 @@ router.post(
|
||||
},
|
||||
],
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
offset: pagination.offset,
|
||||
limit: pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: keys.map(presentApiKey),
|
||||
pagination,
|
||||
data: apiKeys.map(presentApiKey),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -113,14 +103,7 @@ router.post(
|
||||
});
|
||||
authorize(user, "delete", key);
|
||||
|
||||
await key.destroy({ transaction });
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "api_keys.delete",
|
||||
modelId: key.id,
|
||||
data: {
|
||||
name: key.name,
|
||||
},
|
||||
});
|
||||
await key.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Attachment, Document, Event } from "@server/models";
|
||||
import { Attachment, Document } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentAttachment } from "@server/presenters";
|
||||
@@ -64,26 +64,16 @@ router.post(
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const attachment = await Attachment.create(
|
||||
{
|
||||
id: modelId,
|
||||
key,
|
||||
acl,
|
||||
size,
|
||||
expiresAt: AttachmentHelper.presetToExpiry(preset),
|
||||
contentType,
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "attachments.create",
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
modelId,
|
||||
const attachment = await Attachment.createWithCtx(ctx, {
|
||||
id: modelId,
|
||||
key,
|
||||
acl,
|
||||
size,
|
||||
expiresAt: AttachmentHelper.presetToExpiry(preset),
|
||||
contentType,
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const presignedPost = await FileStorage.getPresignedPost(
|
||||
@@ -139,10 +129,7 @@ router.post(
|
||||
}
|
||||
|
||||
authorize(user, "delete", attachment);
|
||||
await attachment.destroy({ transaction });
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "attachments.delete",
|
||||
});
|
||||
await attachment.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -165,7 +165,6 @@ router.post(
|
||||
async (ctx: APIContext<T.CollectionsImportReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { attachmentId, permission, format } = ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "importCollection", user.team);
|
||||
|
||||
@@ -174,29 +173,16 @@ router.post(
|
||||
});
|
||||
authorize(user, "read", attachment);
|
||||
|
||||
const fileOperation = await FileOperation.create(
|
||||
{
|
||||
type: FileOperationType.Import,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
size: attachment.size,
|
||||
key: attachment.key,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
permission,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "fileOperations.create",
|
||||
modelId: fileOperation.id,
|
||||
data: {
|
||||
type: FileOperationType.Import,
|
||||
await FileOperation.createWithCtx(ctx, {
|
||||
type: FileOperationType.Import,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
size: attachment.size,
|
||||
key: attachment.key,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
permission,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -560,8 +546,8 @@ router.post(
|
||||
validate(T.CollectionsExportSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsExportReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id, format, includeAttachments } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const team = await Team.findByPk(user.teamId, { transaction });
|
||||
@@ -578,8 +564,7 @@ router.post(
|
||||
team,
|
||||
format,
|
||||
includeAttachments,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
ctx,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -598,9 +583,9 @@ router.post(
|
||||
validate(T.CollectionsExportAllSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsExportAllReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { format, includeAttachments } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId, { transaction });
|
||||
authorize(user, "createExport", team);
|
||||
|
||||
@@ -609,8 +594,7 @@ router.post(
|
||||
team,
|
||||
format,
|
||||
includeAttachments,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
ctx,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -1273,10 +1273,9 @@ router.post(
|
||||
document,
|
||||
title,
|
||||
publish,
|
||||
transaction,
|
||||
recursive,
|
||||
parentDocumentId,
|
||||
ip: ctx.request.ip,
|
||||
ctx,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -1535,7 +1534,6 @@ router.post(
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
publish,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
const response: DocumentImportTaskResponse = await job.finished();
|
||||
if ("error" in response) {
|
||||
@@ -1630,12 +1628,7 @@ router.post(
|
||||
|
||||
const document = await documentCreator({
|
||||
title,
|
||||
text: await TextHelper.replaceImagesWithAttachments(
|
||||
text,
|
||||
user,
|
||||
ctx.request.ip,
|
||||
transaction
|
||||
),
|
||||
text: await TextHelper.replaceImagesWithAttachments(ctx, text, user),
|
||||
icon,
|
||||
color,
|
||||
createdAt,
|
||||
@@ -1647,8 +1640,7 @@ router.post(
|
||||
fullWidth,
|
||||
user,
|
||||
editorVersion,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
ctx,
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
|
||||
@@ -282,6 +282,7 @@ describe("#fileOperations.delete", () => {
|
||||
expect(
|
||||
await Event.count({
|
||||
where: {
|
||||
name: "fileOperations.delete",
|
||||
teamId: team.id,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Router from "koa-router";
|
||||
import { WhereOptions } from "sequelize";
|
||||
import { UserRole } from "@shared/types";
|
||||
import fileOperationDeleter from "@server/commands/fileOperationDeleter";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
@@ -116,15 +115,11 @@ router.post(
|
||||
const fileOperation = await FileOperation.unscoped().findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "delete", fileOperation);
|
||||
|
||||
await fileOperationDeleter({
|
||||
fileOperation,
|
||||
user,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
await fileOperation.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
+10
-3
@@ -49,8 +49,8 @@ export type AuthenticationResult = AccountProvisionerResult & {
|
||||
|
||||
export type Authentication = {
|
||||
user: User;
|
||||
token: string;
|
||||
type: AuthenticationType;
|
||||
token?: string;
|
||||
type?: AuthenticationType;
|
||||
};
|
||||
|
||||
export type Pagination = {
|
||||
@@ -77,8 +77,15 @@ export interface APIContext<ReqT = BaseReq, ResT = BaseRes>
|
||||
DefaultContext & IRouterParamContext<AppState>,
|
||||
ResT
|
||||
> {
|
||||
/** Typed and validated version of request, consisting of validated body, query, etc */
|
||||
/** Typed and validated version of request, consisting of validated body, query, etc. */
|
||||
input: ReqT;
|
||||
|
||||
/** The current request's context, which is passed to database mutations. */
|
||||
context: {
|
||||
transaction?: Transaction;
|
||||
auth: Authentication;
|
||||
ip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type BaseEvent<T extends Model> = {
|
||||
|
||||
Reference in New Issue
Block a user