Files
outline/server/routes/api/documents/documents.ts
T
000f2f98fb feat: Add TextPack (.textpack) support (#13235)
* feat: Add TextPack (.textpack) import support

Adds a single-document importer for TextPack, the zipped variant of
TextBundle used by Bear, Ulysses, iA Writer and others. Bare .textbundle
directories are not supported, as a directory cannot be delivered through
a browser file input.

The bundle's text entry may use any extension, per the spec, with
info.json's type deciding whether it can be read as markdown. Assets are
inlined as data URIs for the existing attachment pipeline to pick up,
bounded by the attachment size limit and a memory ceiling, and only for
media types markdown-it accepts as a link destination.

Also fixes an existing bug where a file embedded in an HTML or email
import as a data URI was stored in the document as base64 rather than
being uploaded as an attachment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refactor to individual converters

* refactor

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:42:05 -04:00

2204 lines
57 KiB
TypeScript

import path from "node:path";
import fractionalIndex from "fractional-index";
import fs from "fs-extra";
import invariant from "invariant";
import contentDisposition from "content-disposition";
import Router from "koa-router";
import { escapeRegExp, has, remove, uniq } from "es-toolkit/compat";
import mime from "mime-types";
import type { Order, ScopeOptions, WhereOptions } from "sequelize";
import { Op, Sequelize } from "sequelize";
import { randomUUID } from "node:crypto";
import { errToString } from "@shared/utils/error";
import type { DirectionFilter, SortFilter } from "@shared/types";
import { type NavigationNode } from "@shared/types";
import {
ExportContentType,
FileOperationFormat,
FileOperationState,
FileOperationType,
StatusFilter,
UserRole,
} from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import slugify from "@shared/utils/slugify";
import { Day } from "@shared/utils/time";
import documentCreator, {
authorizeDocumentCreate,
authorizeDocumentPublish,
} from "@server/commands/documentCreator";
import documentDuplicator from "@server/commands/documentDuplicator";
import documentLoader from "@server/commands/documentLoader";
import documentMover from "@server/commands/documentMover";
import documentPermanentDeleter from "@server/commands/documentPermanentDeleter";
import documentRestorer from "@server/commands/documentRestorer";
import documentUpdater from "@server/commands/documentUpdater";
import env from "@server/env";
import {
InvalidRequestError,
AuthenticationError,
ValidationError,
IncorrectEditionError,
NotFoundError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import multipart from "@server/middlewares/multipart";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import {
Attachment,
Relationship,
Collection,
Document,
DocumentInsight,
Event,
SearchQuery,
Template,
User,
View,
UserMembership,
Group,
GroupUser,
GroupMembership,
FileOperation,
} from "@server/models";
import { SearchQuerySource } from "@server/models/SearchQuery";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import HTMLHelper from "@server/models/helpers/HTMLHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import TextBundleHelper from "@server/models/helpers/TextBundleHelper";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize, cannot } from "@server/policies";
import {
presentDocument,
presentDocumentInsight,
presentDocuments,
presentPolicies,
presentTemplate,
presentMembership,
presentUser,
presentGroupMembership,
presentGroup,
presentFileOperation,
} from "@server/presenters";
import DocumentImportTask from "@server/queues/tasks/DocumentImportTask";
import EmptyTrashTask from "@server/queues/tasks/EmptyTrashTask";
import FileStorage from "@server/storage/files";
import type { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embeds";
import { streamZipResponse } from "@server/utils/koa";
import { QueryHelper } from "@server/storage/QueryHelper";
import { getTeamFromContext } from "@server/utils/passport";
import pagination, { paginateQuery } from "../middlewares/pagination";
import * as T from "./schema";
import {
loadPublicShare,
getAllIdsInSharedTree,
} from "@server/commands/shareLoader";
const router = new Router();
router.post(
"documents.list",
auth(),
pagination(),
validate(T.DocumentsListSchema),
async (ctx: APIContext<T.DocumentsListReq>) => {
const {
sort,
direction,
collectionId,
backlinkDocumentId,
parentDocumentId,
userId: createdById,
statusFilter,
} = ctx.input.body;
const { offset, limit } = ctx.state.pagination;
// always filter by the current team
const { user } = ctx.state.auth;
const where: WhereOptions<Document> & {
[Op.and]: WhereOptions<Document>[];
} = {
teamId: user.teamId,
[Op.and]: [
{
deletedAt: {
[Op.eq]: null,
},
},
],
};
// Exclude archived docs by default
if (!statusFilter) {
where[Op.and].push({ archivedAt: { [Op.eq]: null } });
}
// if a specific user is passed then add to filters. If the user doesn't
// exist in the team then nothing will be returned, so no need to check auth
if (createdById) {
where[Op.and].push({ createdById });
}
let documentIds: string[] = [];
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where[Op.and].push({ collectionId: [collectionId] });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: sort === "index",
});
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
// Extract all document IDs from the collection structure.
documentIds = (collection.documentStructure || [])
.slice(offset, offset + limit)
.map((node) => node.id);
where[Op.and].push({ id: documentIds });
} // if it's not a backlink request, filter by all collections the user has access to
} else if (!backlinkDocumentId) {
const collectionIds = await user.collectionIds();
where[Op.and].push({
collectionId: collectionIds,
});
}
if (parentDocumentId) {
const [groupMembership, membership] = await Promise.all([
GroupMembership.findOne({
where: {
documentId: parentDocumentId,
},
include: [
{
model: Group,
required: true,
include: [
{
model: GroupUser,
required: true,
where: {
userId: user.id,
},
},
],
},
],
}),
UserMembership.findOne({
where: {
userId: user.id,
documentId: parentDocumentId,
},
}),
]);
if (groupMembership || membership) {
remove(where[Op.and], (cond) => has(cond, "collectionId"));
}
where[Op.and].push({ parentDocumentId });
}
// Explicitly passing 'null' as the parentDocumentId allows listing documents
// that have no parent document (aka they are at the root of the collection)
if (parentDocumentId === null) {
where[Op.and].push({
parentDocumentId: {
[Op.is]: null,
},
});
}
if (backlinkDocumentId) {
const sourceDocumentIds = await Relationship.findSourceDocumentIdsForUser(
backlinkDocumentId,
user
);
where[Op.and].push({ id: sourceDocumentIds });
// For safety, ensure the collectionId is not set in the query.
remove(where[Op.and], (cond) => has(cond, "collectionId"));
}
const statusQuery = [];
if (statusFilter?.includes(StatusFilter.Published)) {
statusQuery.push({
[Op.and]: [
{
publishedAt: {
[Op.ne]: null,
},
archivedAt: {
[Op.eq]: null,
},
},
],
});
}
if (statusFilter?.includes(StatusFilter.Draft)) {
// Pre-fetch document IDs the user has a direct membership on so the
// filter can be expressed without referencing the (separately-loaded)
// memberships association, which would otherwise break the COUNT query.
const membershipDocumentIds = (
await UserMembership.findAll({
attributes: ["documentId"],
where: {
userId: user.id,
documentId: { [Op.ne]: null },
},
})
).map((m) => m.documentId as string);
statusQuery.push({
[Op.and]: [
{
publishedAt: {
[Op.eq]: null,
},
archivedAt: {
[Op.eq]: null,
},
[Op.or]: [
// Only ever include draft results for the user's own documents
{ createdById: user.id },
{ id: membershipDocumentIds },
],
},
],
});
}
if (statusFilter?.includes(StatusFilter.Archived)) {
statusQuery.push({
archivedAt: {
[Op.ne]: null,
},
});
}
if (statusQuery.length) {
where[Op.and].push({
[Op.or]: statusQuery,
});
}
// When sorting by index, use array_position to sort by the document order
// in the collection structure directly in SQL, enabling correct pagination
const orderClause =
sort === "index"
? documentIds.length > 0
? [
[
Sequelize.literal(
`array_position(ARRAY[:documentIds]::uuid[], "document"."id")`
),
direction,
],
]
: undefined
: [[sort, direction]];
const includeDrafts = !!statusFilter?.includes(StatusFilter.Draft);
// The withDrafts scope drops the defaultScope filters, so re-apply the
// ones we still want — templates and trial-import documents should never
// appear in this listing.
if (includeDrafts) {
where[Op.and].push({
template: false,
sourceMetadata: { trial: { [Op.is]: null } },
});
}
// When sorting by index, pagination is already handled by slicing documentIds,
// so we skip the SQL-level offset to avoid double-pagination
const { results: documents, pagination } = await paginateQuery(
ctx,
({ offset: queryOffset, limit: queryLimit }) =>
Document.withMembershipScope(user.id, { includeDrafts }).findAll({
where,
order: orderClause as Order,
offset: sort === "index" ? 0 : queryOffset,
limit: queryLimit,
replacements: {
documentIds,
},
}),
() =>
Document.withMembershipScope(user.id, { includeDrafts }).count({
where,
})
);
const data = await presentDocuments(ctx, documents);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination,
data,
policies,
};
}
);
router.post(
"documents.archived",
auth({ role: UserRole.Member }),
pagination(),
validate(T.DocumentsArchivedSchema),
async (ctx: APIContext<T.DocumentsArchivedReq>) => {
const { sort, direction, collectionId } = ctx.input.body;
const { user } = ctx.state.auth;
if (sort === "index") {
throw ValidationError(
"Sorting archived documents by index is not supported"
);
}
let where: WhereOptions<Document> = {
teamId: user.teamId,
archivedAt: {
[Op.ne]: null,
},
};
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where = { ...where, collectionId };
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
// otherwise, filter by all collections the user has access to
} else {
const collectionIds = await user.collectionIds();
where = {
...where,
collectionId: collectionIds,
};
}
const documents = await Document.withMembershipScope(user.id).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await presentDocuments(ctx, documents);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
}
);
router.post(
"documents.deleted",
auth({ role: UserRole.Member }),
pagination(),
validate(T.DocumentsDeletedSchema),
async (ctx: APIContext<T.DocumentsDeletedReq>) => {
const { sort, direction } = ctx.input.body;
const { user } = ctx.state.auth;
const collectionIds = await user.collectionIds({
paranoid: false,
});
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", user.id],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", user.id],
};
const documents = await Document.scope([
membershipScope,
viewScope,
"withDrafts",
]).findAll({
where: {
teamId: user.teamId,
deletedAt: {
[Op.ne]: null,
},
[Op.or]: [
{
collectionId: {
[Op.in]: collectionIds,
},
},
{
createdById: user.id,
collectionId: {
[Op.is]: null,
},
},
],
},
paranoid: false,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await presentDocuments(ctx, documents);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
}
);
router.post(
"documents.viewed",
auth(),
pagination(),
validate(T.DocumentsViewedSchema),
async (ctx: APIContext<T.DocumentsViewedReq>) => {
const { sort, direction } = ctx.input.body;
const { user } = ctx.state.auth;
const collectionIds = await user.collectionIds();
const userId = user.id;
const views = await View.findAll({
where: {
userId,
},
order: [[sort, direction]],
include: [
{
model: Document.scope([
"withDrafts",
{ method: ["withMembership", userId] },
]),
required: true,
where: {
teamId: user.teamId,
collectionId: collectionIds,
},
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
subQuery: false,
});
const documents = views.map((view) => {
const document = view.document;
document.views = [view];
return document;
});
const data = await presentDocuments(ctx, documents);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
}
);
router.post(
"documents.drafts",
auth(),
pagination(),
validate(T.DocumentsDraftsSchema),
async (ctx: APIContext<T.DocumentsDraftsReq>) => {
const { collectionId, dateFilter, direction, sort } = ctx.input.body;
const { user } = ctx.state.auth;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
const collectionIds = collectionId
? [collectionId]
: await user.collectionIds();
const where: WhereOptions = {
teamId: user.teamId,
createdById: user.id,
collectionId: {
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
},
publishedAt: {
[Op.is]: null,
},
};
if (dateFilter) {
where.updatedAt = {
[Op.gte]: subtractDate(new Date(), dateFilter),
};
} else {
delete where.updatedAt;
}
const documents = await Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await presentDocuments(ctx, documents);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
}
);
router.post(
"documents.info",
auth({ optional: true }),
validate(T.DocumentsInfoSchema),
async (ctx: APIContext<T.DocumentsInfoReq>) => {
const { id, shareId } = ctx.input.body;
const { user } = ctx.state.auth;
const apiVersion = getAPIVersion(ctx);
const teamFromCtx = await getTeamFromContext(ctx, {
includeOAuthState: false,
});
let document: Document | null;
let serializedDocument: Record<string, unknown> | undefined;
let isPublic = false;
if (shareId) {
const result = await loadPublicShare({
id: shareId,
documentId: id,
teamId: teamFromCtx?.id,
});
document = result.document;
if (!document) {
throw NotFoundError("Document could not be found for shareId");
}
// reload with membership scope if user is authenticated
if (user) {
document = await Document.findByPk(document.id, {
userId: user.id,
rejectOnEmpty: true,
});
}
isPublic = cannot(user, "read", document);
// Get backlinks that are within the shared tree
let backlinkIds: string[] | undefined;
if (result.sharedTree) {
const allowedDocumentIds = getAllIdsInSharedTree(result.sharedTree);
backlinkIds = await Relationship.findSourceDocumentIdsInSharedTree(
document.id,
allowedDocumentIds
);
}
serializedDocument = await presentDocument(ctx, document, {
isPublic,
shareId,
includeUpdatedAt: result.share.showLastUpdated,
backlinkIds,
});
} else {
if (!user) {
throw AuthenticationError("Authentication required");
}
document = await documentLoader({
id: id!, // validation ensures id will be present here
user,
});
serializedDocument = await presentDocument(ctx, document);
}
ctx.body = {
// Passing apiVersion=2 has a single effect, to change the response payload to
// include top level keys for document.
data:
apiVersion >= 2
? {
document: serializedDocument,
}
: serializedDocument,
policies: isPublic ? undefined : presentPolicies(user, [document]),
};
}
);
router.post(
"documents.insights",
auth(),
validate(T.DocumentsInsightsSchema),
async (ctx: APIContext<T.DocumentsInsightsReq>) => {
const { id, startDate, endDate } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "listViews", document);
if (!document.insightsEnabled) {
throw ValidationError("Insights are not enabled for this document");
}
const end = endDate ?? new Date();
const start = startDate ?? new Date(end.getTime() - 30 * Day.ms);
const insights = await DocumentInsight.findAll({
where: {
documentId: document.id,
date: {
[Op.gte]: start.toISOString().slice(0, 10),
[Op.lte]: end.toISOString().slice(0, 10),
},
},
order: [["date", "ASC"]],
});
ctx.body = {
data: insights.map(presentDocumentInsight),
};
}
);
router.post(
"documents.users",
auth(),
pagination(),
validate(T.DocumentsUsersSchema),
async (ctx: APIContext<T.DocumentsUsersReq>) => {
const { id, userId, query } = ctx.input.body;
const actor = ctx.state.auth.user;
const document = await Document.findByPk(id, {
userId: actor.id,
});
authorize(actor, "read", document);
let where: WhereOptions<User> = {
teamId: document.teamId,
suspendedAt: {
[Op.is]: null,
},
};
const [collection, memberIds, collectionMemberIds] = await Promise.all([
document.$get("collection"),
Document.membershipUserIds(document.id),
document.collectionId
? Collection.membershipUserIds(document.collectionId)
: [],
]);
where = {
...where,
[Op.or]: [
{
id: {
[Op.in]: uniq([...memberIds, ...collectionMemberIds]),
},
},
collection?.permission
? {
role: {
[Op.ne]: UserRole.Guest,
},
}
: {},
],
};
if (query) {
where = {
...where,
[Op.and]: [
Sequelize.literal(
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
),
],
};
}
if (userId) {
where = {
...where,
id: userId,
};
}
const replacements = { query: QueryHelper.likeContains(query ?? "") };
const { results: users, pagination } = await paginateQuery<User>(
ctx,
(opts) => User.findAll({ where, replacements, ...opts }),
() =>
User.count({
where,
// @ts-expect-error Types are incorrect for count
replacements,
}) as unknown as Promise<number>
);
ctx.body = {
pagination,
data: users.map((user) => presentUser(user)),
policies: presentPolicies(actor, users),
};
}
);
router.post(
"documents.documents",
auth(),
validate(T.DocumentsChildrenSchema),
async (ctx: APIContext<T.DocumentsChildrenReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "read", document);
let documentTree: NavigationNode | undefined;
if (document.collectionId) {
const collection = await Collection.findByPk(document.collectionId, {
includeDocumentStructure: true,
});
documentTree = collection?.getDocumentTree(document.id) ?? undefined;
}
ctx.body = {
data: documentTree,
};
}
);
router.post(
"documents.export",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.DocumentsExportSchema),
async (ctx: APIContext<T.DocumentsExportReq>) => {
const { id, signedUrls, includeChildDocuments } = ctx.input.body;
const { user } = ctx.state.auth;
const accept = ctx.request.headers["accept"];
const document = await documentLoader({
id,
user,
// We need the collaborative state to generate HTML, but not for the
// formats that are written from markdown.
includeState: !(
accept?.includes("text/markdown") ||
accept?.includes(ExportContentType.TextBundle)
),
});
authorize(user, "download", document);
const format = accept?.includes("text/html")
? FileOperationFormat.HTMLZip
: accept?.includes("text/markdown")
? FileOperationFormat.MarkdownZip
: accept?.includes(ExportContentType.TextBundle)
? FileOperationFormat.TextBundleZip
: accept?.includes("application/pdf")
? FileOperationFormat.PDF
: null;
if (format === FileOperationFormat.PDF) {
throw IncorrectEditionError(
"PDF export is not available in the community edition"
);
}
if (includeChildDocuments) {
if (!format) {
throw InvalidRequestError(
"format needed for exporting nested documents"
);
}
const fileOperation = await FileOperation.createWithCtx(ctx, {
type: FileOperationType.Export,
state: FileOperationState.Creating,
format,
key: FileOperation.getExportKey({
name: document.titleWithDefault,
teamId: document.teamId,
format,
}),
url: null,
size: 0,
documentId: document.id,
userId: user.id,
teamId: document.teamId,
});
fileOperation.user = user;
fileOperation.document = document;
ctx.body = {
success: true,
data: {
fileOperation: presentFileOperation(fileOperation),
},
};
return;
}
let contentType: string;
let content: string;
const toMarkdown = async () =>
DocumentHelper.toMarkdown(document, {
signedUrls,
teamId: user.teamId,
});
// A TextBundle is a directory of files, so unlike the other formats it has
// no self-contained single-file form to fall back to.
const isTextBundle = format === FileOperationFormat.TextBundleZip;
if (format === FileOperationFormat.HTMLZip) {
contentType = "text/html";
content = await DocumentHelper.toHTML(document, {
centered: true,
includeMermaid: true,
});
} else if (isTextBundle || format === FileOperationFormat.MarkdownZip) {
contentType = "text/markdown";
content = await toMarkdown();
} else {
ctx.body = {
data: await toMarkdown(),
};
return;
}
// Override the extension for Markdown as it's incorrect in the mime-types
// library until a new release > 2.1.35
const extension =
contentType === "text/markdown" ? "md" : mime.extension(contentType);
const fileName = slugify(document.titleWithDefault);
const attachmentIds = ProsemirrorHelper.parseAttachmentIds(
DocumentHelper.toProsemirror(document)
);
const attachments = attachmentIds.length
? await Attachment.findAll({
where: {
teamId: document.teamId,
id: attachmentIds,
},
})
: [];
// Read attachments and, when exporting HTML, inline small images that are
// referenced a single time as base64 data URIs. Any remaining attachments
// are bundled alongside the document in a zip.
const externalAttachments: { attachment: Attachment; buffer: Buffer }[] =
[];
for (const attachment of attachments) {
let buffer: Buffer;
try {
buffer = await attachment.buffer;
} catch (err) {
Logger.warn(`Failed to read attachment from storage`, {
attachmentId: attachment.id,
teamId: attachment.teamId,
error: errToString(err),
});
buffer = Buffer.from("");
}
if (contentType === "text/html") {
const inlined = HTMLHelper.inlineImage(
content,
attachment.redirectUrl,
attachment.contentType,
buffer
);
if (inlined !== null) {
content = inlined;
continue;
}
}
externalAttachments.push({ attachment, buffer });
}
if (isTextBundle) {
const root = `${fileName}.${TextBundleHelper.bundleExtension}`;
const usedAssetNames = new Set<string>();
streamZipResponse(
ctx,
`${fileName}.${TextBundleHelper.packExtension}`,
(zip) => {
for (const { attachment, buffer } of externalAttachments) {
const reference = TextBundleHelper.assetPath(
attachment.name,
usedAssetNames
);
zip.addBuffer(buffer, path.join(root, reference), {
mtime: attachment.updatedAt,
});
content = content.replace(
new RegExp(escapeRegExp(attachment.redirectUrl), "g"),
encodeURI(reference)
);
}
zip.addBuffer(
Buffer.from(TextBundleHelper.info(document)),
path.join(root, TextBundleHelper.infoFileName),
{ mtime: document.updatedAt }
);
zip.addBuffer(
Buffer.from(content),
path.join(root, TextBundleHelper.textFileName),
{ mtime: document.updatedAt }
);
}
);
return;
}
// When there are no external attachments the document is self-contained and
// can be served directly rather than bundled in a zip.
if (externalAttachments.length === 0) {
ctx.set("Content-Type", contentType);
ctx.set(
"Content-Disposition",
contentDisposition(`${fileName}.${extension}`, {
type: "attachment",
})
);
ctx.body = content;
return;
}
streamZipResponse(ctx, `${fileName}.zip`, (zip) => {
for (const { attachment, buffer } of externalAttachments) {
const location = path.join(
"attachments",
`${attachment.id}.${mime.extension(attachment.contentType)}`
);
zip.addBuffer(buffer, location, { mtime: attachment.updatedAt });
content = content.replace(
new RegExp(escapeRegExp(attachment.redirectUrl), "g"),
location
);
}
zip.addBuffer(Buffer.from(content), `${fileName}.${extension}`, {
mtime: document.updatedAt,
});
});
}
);
router.post(
"documents.restore",
auth({ role: UserRole.Member }),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsRestoreSchema),
transaction(),
async (ctx: APIContext<T.DocumentsRestoreReq>) => {
const { id, collectionId, revisionId } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const document = await Document.findByPk(id, {
userId: user.id,
paranoid: false,
rejectOnEmpty: true,
transaction,
});
await documentRestorer(ctx, { document, collectionId, revisionId });
ctx.body = {
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
);
router.post(
"documents.search_titles",
auth(),
pagination(),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsSearchTitlesSchema),
async (ctx: APIContext<T.DocumentsSearchTitlesReq>) => {
const {
query,
statusFilter,
dateFilter,
collectionId,
userId,
sort,
direction,
} = ctx.input.body;
const { offset, limit } = ctx.state.pagination;
const { user } = ctx.state.auth;
let collaboratorIds = undefined;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
if (userId) {
collaboratorIds = [userId];
}
const documents =
await SearchProviderManager.getProvider().searchTitlesForUser(user, {
query,
dateFilter,
statusFilter,
collectionId,
collaboratorIds,
offset,
limit,
sort: sort as SortFilter,
direction: direction as DirectionFilter,
});
const policies = presentPolicies(user, documents);
const data = await presentDocuments(ctx, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
}
);
router.post(
"documents.search",
auth({ optional: true }),
pagination(),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsSearchSchema),
async (ctx: APIContext<T.DocumentsSearchReq>) => {
const {
query,
collectionId,
documentId,
userId,
dateFilter,
statusFilter = [],
shareId,
snippetMinWords,
snippetMaxWords,
sort,
direction,
} = ctx.input.body;
const { offset, limit } = ctx.state.pagination;
const { user } = ctx.state.auth;
let teamId;
let response;
let share;
let isPublic = false;
const searchStartedAt = Date.now();
if (shareId) {
const teamFromCtx = await getTeamFromContext(ctx, {
includeOAuthState: false,
});
const result = await loadPublicShare({
id: shareId,
teamId: teamFromCtx?.id,
});
share = result.share;
let { collection, document } = result; // One of collection or document should be available
// reload with membership scope if user is authenticated
if (user) {
collection = collection
? await Collection.findByPk(collection.id, { userId: user.id })
: null;
document = document
? await Document.findByPk(document.id, { userId: user.id })
: null;
}
isPublic = collection
? cannot(user, "read", collection)
: cannot(user, "read", document);
if (share.documentId && !share?.includeChildDocuments) {
throw InvalidRequestError("Child documents cannot be searched");
}
teamId = share.teamId;
const team = await share.$get("team");
invariant(team, "Share must belong to a team");
response = await SearchProviderManager.getProvider().searchForTeam(team, {
query,
collectionId: collection?.id || document?.collectionId,
share,
dateFilter,
statusFilter,
offset,
limit,
snippetMinWords,
snippetMaxWords,
sort: sort as SortFilter,
direction: direction as DirectionFilter,
usePopularityBoost: false,
});
} else {
if (!user) {
throw AuthenticationError("Authentication error");
}
teamId = user.teamId;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
let documentIds = undefined;
if (documentId) {
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
documentIds = [
documentId,
...(await document.findAllChildDocumentIds()),
];
}
let collaboratorIds = undefined;
if (userId) {
collaboratorIds = [userId];
}
response = await SearchProviderManager.getProvider().searchForUser(user, {
query,
collaboratorIds,
collectionId,
documentIds,
dateFilter,
statusFilter,
offset,
limit,
snippetMinWords,
snippetMaxWords,
sort: sort as SortFilter,
direction: direction as DirectionFilter,
});
}
const { results, total } = response;
const documents = results.map((result) => result.document);
const data = await Promise.all(
results.map(async (result) => {
const document = await presentDocument(ctx, result.document, {
isPublic,
shareId,
});
return { ...result, document };
})
);
// When requesting subsequent pages of search results we don't want to record
// duplicate search query records
if (query && offset === 0) {
const duration = Date.now() - searchStartedAt;
await SearchQuery.record({
userId: user?.id,
teamId,
shareId: share?.id,
// auth.type values are a subset of search sources; unauthenticated share searches default to "app"
source:
(ctx.state.auth.type as unknown as SearchQuerySource) ||
SearchQuerySource.App,
query,
results: total,
duration,
});
}
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data,
policies: user ? presentPolicies(user, documents) : null,
};
}
);
router.post(
"documents.templatize",
auth({ role: UserRole.Member }),
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
validate(T.DocumentsTemplatizeSchema),
transaction(),
async (ctx: APIContext<T.DocumentsTemplatizeReq>) => {
const { id, collectionId, publish } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const original = await Document.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "update", original);
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "createTemplate", collection);
} else {
authorize(user, "createTemplate", user.team);
}
const template = await Template.createWithCtx(ctx, {
editorVersion: original.editorVersion,
collectionId,
teamId: user.teamId,
publishedAt: publish ? new Date() : null,
lastModifiedById: user.id,
createdById: user.id,
icon: original.icon,
color: original.color,
title: original.title,
content: original.content,
});
// reload to get all of the data needed to present (user, collection etc)
const reloaded = await Template.findByPk(template.id, {
userId: user.id,
transaction,
});
invariant(reloaded, "template not found");
ctx.body = {
data: presentTemplate(reloaded),
policies: presentPolicies(user, [reloaded]),
};
}
);
router.post(
"documents.update",
auth(),
validate(T.DocumentsUpdateSchema),
transaction(),
async (ctx: APIContext<T.DocumentsUpdateReq>) => {
const { transaction } = ctx.state;
const { id, insightsEnabled, publish, collectionId, ...input } =
ctx.input.body;
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
const { user } = ctx.state.auth;
let collection: Collection | null | undefined;
let document = await Document.findByPk(id, {
userId: user.id,
includeState: true,
transaction,
});
collection = document?.collection;
authorize(user, "update", document);
if (collection && insightsEnabled !== undefined) {
authorize(user, "updateInsights", document);
}
if (publish) {
await authorizeDocumentPublish(ctx, document, collectionId);
}
document = await documentUpdater(ctx, {
document,
...input,
publish,
collectionId,
insightsEnabled,
editorVersion,
});
ctx.body = {
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
);
router.post(
"documents.duplicate",
auth(),
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
validate(T.DocumentsDuplicateSchema),
transaction(),
async (ctx: APIContext<T.DocumentsDuplicateReq>) => {
const { transaction } = ctx.state;
const { id, title, publish, recursive, collectionId, parentDocumentId } =
ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "duplicate", document);
let collection: Collection | null | undefined;
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
userId: user.id,
transaction,
});
authorize(user, "update", parent);
if (!parent.publishedAt) {
throw InvalidRequestError("Cannot duplicate document inside a draft");
}
if (collectionId && collectionId !== parent.collectionId) {
throw InvalidRequestError(
"collectionId must match the collection of the parent document"
);
}
// The copy is nested under the parent, so it belongs to the parent's
// collection.
collection = parent.collectionId
? await Collection.findByPk(parent.collectionId, {
userId: user.id,
transaction,
})
: undefined;
} else {
collection = collectionId
? await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
})
: document?.collection;
// The copy is created in the destination collection, so create permission
// is required there rather than on the source.
if (collection) {
authorize(user, "createDocument", collection);
}
}
const response = await documentDuplicator(ctx, {
collection,
document,
title,
publish,
recursive,
parentDocumentId,
});
ctx.body = {
data: {
documents: await presentDocuments(ctx, response),
},
policies: presentPolicies(user, response),
};
}
);
router.post(
"documents.move",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsMoveSchema),
transaction(),
async (ctx: APIContext<T.DocumentsMoveReq>) => {
const { transaction } = ctx.state;
const { id, parentDocumentId, index } = ctx.input.body;
let collectionId = ctx.input.body.collectionId;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "move", document);
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
userId: user.id,
transaction,
});
authorize(user, "update", parent);
collectionId = parent.collectionId;
if (!parent.publishedAt) {
throw InvalidRequestError("Cannot move document inside a draft");
}
} else if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "updateDocument", collection);
} else {
throw InvalidRequestError("collectionId is required to move a document");
}
const { documents, collectionChanged } = await documentMover(ctx, {
document,
collectionId: collectionId ?? null,
parentDocumentId,
index,
});
ctx.body = {
data: {
documents: await presentDocuments(ctx, documents),
// Included for backwards compatibility
collections: [],
},
policies: collectionChanged ? presentPolicies(user, documents) : [],
};
}
);
router.post(
"documents.archive",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsArchiveSchema),
transaction(),
async (ctx: APIContext<T.DocumentsArchiveReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const document = await Document.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
authorize(user, "archive", document);
await document.archiveWithCtx(ctx);
ctx.body = {
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
);
router.post(
"documents.delete",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsDeleteSchema),
transaction(),
async (ctx: APIContext<T.DocumentsDeleteReq>) => {
const { transaction } = ctx.state;
const { id, permanent } = ctx.input.body;
const { user } = ctx.state.auth;
if (permanent) {
const document = await Document.findByPk(id, {
userId: user.id,
paranoid: false,
transaction,
});
authorize(user, "permanentDelete", document);
await documentPermanentDeleter([document]);
await Event.createFromContext(ctx, {
name: "documents.permanent_delete",
documentId: document.id,
collectionId: document.collectionId,
data: {
title: document.title,
},
});
} else {
const document = await Document.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "delete", document);
await document.destroyWithCtx(ctx);
}
ctx.body = {
success: true,
};
}
);
router.post(
"documents.unpublish",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsUnpublishSchema),
transaction(),
async (ctx: APIContext<T.DocumentsUnpublishReq>) => {
const { id, detach } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, {
userId: user.id,
});
authorize(user, "unpublish", document);
await document.unpublishWithCtx(ctx, { detach });
ctx.body = {
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
);
router.post(
"documents.import",
auth(),
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
multipart({
maximumFileSize: env.FILE_STORAGE_IMPORT_MAX_SIZE,
optional: true,
}),
validate(T.DocumentsImportSchema),
async (ctx: APIContext<T.DocumentsImportReq>) => {
const { collectionId, parentDocumentId, publish, attachmentId } =
ctx.input.body;
const { user } = ctx.state.auth;
if (!attachmentId && !ctx.input.file) {
throw ValidationError("one of attachmentId or file is required");
}
let parentDocument: Document | null = null;
let collection: Collection | null = null;
if (parentDocumentId) {
parentDocument = await Document.findByPk(parentDocumentId, {
userId: user.id,
});
if (parentDocument?.collectionId) {
collection = await Collection.findByPk(parentDocument.collectionId, {
userId: user.id,
});
}
authorize(user, "createChildDocument", parentDocument, {
collection,
});
} else if (collectionId) {
collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "createDocument", collection);
}
let key: string;
let fileName: string;
let mimeType: string;
if (attachmentId) {
const attachment = await Attachment.findByPk(attachmentId);
authorize(user, "read", attachment);
key = attachment.key;
fileName = attachment.name;
mimeType = attachment.contentType;
} else {
const file = ctx.input.file!;
const buffer = await fs.readFile(file.filepath);
fileName = file.originalFilename ?? file.newFilename;
mimeType = file.mimetype ?? "";
key = AttachmentHelper.getKey({
id: randomUUID(),
name: fileName,
userId: user.id,
});
await FileStorage.store({
body: buffer,
contentType: mimeType,
contentLength: buffer.length,
key,
acl: "private",
});
}
const document = await DocumentImportTask.scheduleAndWait({
key,
sourceMetadata: {
fileName,
mimeType,
},
userId: user.id,
collectionId: collectionId ?? parentDocument?.collectionId,
parentDocumentId,
publish,
authType: ctx.state.auth.type,
ip: ctx.request.ip,
});
ctx.body = {
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
);
router.post(
"documents.create",
auth(),
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
validate(T.DocumentsCreateSchema),
transaction(),
async (ctx: APIContext<T.DocumentsCreateReq>) => {
const {
id,
title,
text,
icon,
color,
publish,
index,
collectionId,
parentDocumentId,
fullWidth,
templateId,
createdAt,
} = ctx.input.body;
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const { collection } = await authorizeDocumentCreate(ctx, {
collectionId,
parentDocumentId,
});
let template: Template | null | undefined;
if (templateId) {
template = await Template.findByPk(templateId, {
userId: user.id,
transaction,
});
authorize(user, "read", template);
}
// Pre-process text to convert bare embed URLs to markdown link format
const processedText = text ? convertBareUrlsToEmbedMarkdown(text) : text;
const document = await documentCreator(ctx, {
id,
title,
text: processedText
? await TextHelper.replaceImagesWithAttachments(
ctx,
processedText,
user
)
: processedText,
icon,
color,
createdAt,
publish,
index,
collectionId: collection?.id,
parentDocumentId,
template,
fullWidth,
editorVersion,
});
if (collection) {
document.collection = collection;
}
ctx.body = {
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
);
router.post(
"documents.add_user",
auth(),
validate(T.DocumentsAddUserSchema),
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
transaction(),
async (ctx: APIContext<T.DocumentsAddUserReq>) => {
const { transaction } = ctx.state;
const { user: actor } = ctx.state.auth;
const { id, userId, permission } = ctx.input.body;
if (userId === actor.id) {
throw ValidationError("You cannot invite yourself");
}
const [document, user] = await Promise.all([
Document.findByPk(id, {
userId: actor.id,
rejectOnEmpty: true,
transaction,
}),
User.findByPk(userId, {
rejectOnEmpty: true,
transaction,
}),
]);
authorize(actor, "manageUsers", document);
authorize(actor, "read", user);
const UserMemberships = await UserMembership.findAll({
where: {
userId,
},
attributes: ["id", "index", "updatedAt"],
limit: 1,
order: [
// using LC_COLLATE:"C" because we need byte order to drive the sorting
// find only the first star so we can create an index before it
Sequelize.literal('"user_permission"."index" collate "C"'),
["updatedAt", "DESC"],
],
transaction,
});
// create membership at the beginning of their "Shared with me" section
const index = fractionalIndex(
null,
UserMemberships.length ? UserMemberships[0].index : null
);
let membership = await UserMembership.findOne({
where: {
documentId: id,
userId,
},
lock: transaction.LOCK.UPDATE,
...ctx.context,
});
if (membership) {
if (permission) {
membership.permission = permission;
// disconnect from the source if the permission is manually updated
membership.sourceId = null;
await membership.save(ctx.context);
}
} else {
membership = await UserMembership.create(
{
documentId: id,
userId,
index,
permission: permission || user.defaultDocumentPermission,
createdById: actor.id,
},
ctx.context
);
}
ctx.body = {
data: {
users: [presentUser(user)],
memberships: [presentMembership(membership)],
},
};
}
);
router.post(
"documents.remove_user",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
validate(T.DocumentsRemoveUserSchema),
transaction(),
async (ctx: APIContext<T.DocumentsRemoveUserReq>) => {
const { transaction } = ctx.state;
const { user: actor } = ctx.state.auth;
const { id, userId } = ctx.input.body;
const [document, user] = await Promise.all([
Document.findByPk(id, {
userId: actor.id,
rejectOnEmpty: true,
transaction,
}),
User.findByPk(userId, {
rejectOnEmpty: true,
transaction,
}),
]);
if (actor.id !== userId) {
authorize(actor, "manageUsers", document);
authorize(actor, "read", user);
}
const membership = await UserMembership.findOne({
where: {
documentId: id,
userId,
},
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
await membership.destroy(ctx.context);
ctx.body = {
success: true,
};
}
);
router.post(
"documents.add_group",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
validate(T.DocumentsAddGroupSchema),
transaction(),
async (ctx: APIContext<T.DocumentsAddGroupsReq>) => {
const { id, groupId, permission } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const [document, group] = await Promise.all([
Document.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
}),
Group.findByPk(groupId, {
rejectOnEmpty: true,
transaction,
}),
]);
authorize(user, "manageUsers", document);
authorize(user, "read", group);
let membership = await GroupMembership.findOne({
where: {
documentId: id,
groupId,
},
lock: transaction.LOCK.UPDATE,
...ctx.context,
});
if (membership) {
if (permission) {
membership.permission = permission;
// disconnect from the source if the permission is manually updated
membership.sourceId = null;
await membership.save(ctx.context);
}
} else {
membership = await GroupMembership.create(
{
documentId: id,
groupId,
permission: permission || user.defaultDocumentPermission,
createdById: user.id,
},
ctx.context
);
}
ctx.body = {
data: {
groupMemberships: [presentGroupMembership(membership)],
},
};
}
);
router.post(
"documents.remove_group",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
validate(T.DocumentsRemoveGroupSchema),
transaction(),
async (ctx: APIContext<T.DocumentsRemoveGroupReq>) => {
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const { id, groupId } = ctx.input.body;
const [document, group] = await Promise.all([
Document.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
}),
Group.findByPk(groupId, {
rejectOnEmpty: true,
transaction,
}),
]);
authorize(user, "manageUsers", document);
authorize(user, "read", group);
const membership = await GroupMembership.findOne({
where: {
documentId: id,
groupId,
},
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
await membership.destroy(ctx.context);
ctx.body = {
success: true,
};
}
);
router.post(
"documents.memberships",
auth(),
pagination(),
validate(T.DocumentsMembershipsSchema),
async (ctx: APIContext<T.DocumentsMembershipsReq>) => {
const { id, query, permission } = ctx.input.body;
const { user: actor } = ctx.state.auth;
const document = await Document.findByPk(id, { userId: actor.id });
authorize(actor, "update", document);
let where: WhereOptions<UserMembership> = {
documentId: id,
};
let userWhere;
if (query) {
userWhere = {
name: { [Op.iLike]: QueryHelper.likeContains(query) },
};
}
if (permission) {
where = { ...where, permission };
}
const options = {
where,
include: [
{
model: User,
as: "user",
where: userWhere,
required: true,
},
],
};
const { results: memberships, pagination } = await paginateQuery(
ctx,
(opts) =>
UserMembership.findAll({
...options,
order: [["createdAt", "DESC"]],
...opts,
}),
() => UserMembership.count(options)
);
ctx.body = {
pagination,
data: {
memberships: memberships.map(presentMembership),
users: memberships.map((membership) => presentUser(membership.user)),
},
};
}
);
router.post(
"documents.group_memberships",
auth(),
pagination(),
validate(T.DocumentsMembershipsSchema),
async (ctx: APIContext<T.DocumentsMembershipsReq>) => {
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "update", document);
let where: WhereOptions<GroupMembership> = {
documentId: id,
};
let groupWhere;
if (query) {
groupWhere = {
name: { [Op.iLike]: QueryHelper.likeContains(query) },
};
}
if (permission) {
where = { ...where, permission };
}
const options = {
where,
include: [
{
model: Group,
as: "group",
where: groupWhere,
required: true,
},
],
};
const { results: memberships, pagination } = await paginateQuery(
ctx,
(opts) =>
GroupMembership.findAll({
...options,
order: [["createdAt", "DESC"]],
...opts,
}),
() => GroupMembership.count(options)
);
const groupMemberships = memberships.map(presentGroupMembership);
ctx.body = {
pagination,
data: {
groupMemberships,
groups: await Promise.all(
memberships.map((membership) => presentGroup(membership.group))
),
},
};
}
);
router.post(
"documents.empty_trash",
auth({ role: UserRole.Admin }),
rateLimiter(RateLimiterStrategy.TenPerHour),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
const collectionIds = await user.collectionIds({
paranoid: false,
});
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id"],
where: {
deletedAt: {
[Op.ne]: null,
},
[Op.or]: [
{
collectionId: {
[Op.in]: collectionIds,
},
},
{
createdById: user.id,
collectionId: {
[Op.is]: null,
},
},
],
},
paranoid: false,
});
if (documents.length) {
await new EmptyTrashTask().schedule({
documentIds: documents.map((doc) => doc.id),
});
}
await Event.createFromContext(ctx, {
name: "documents.empty_trash",
});
ctx.body = {
success: true,
};
}
);
// Remove this helper once apiVersion is removed (#6175)
function getAPIVersion(ctx: APIContext) {
return Number(
ctx.headers["x-api-version"] ??
(typeof ctx.input.body === "object" &&
ctx.input.body &&
"apiVersion" in ctx.input.body &&
ctx.input.body.apiVersion) ??
0
);
}
export default router;