Files
outline/server/routes/api/documents/documents.ts
T
Tom Moor 492af6683b Add document restore functionality to MCP tools (#12575)
* Add restore_document MCP tool and archived/trashed listing

Closes the delete/restore asymmetry in the MCP server: previously documents
could be archived or trashed via delete_document but never recovered.

- Add restore_document tool to recover archived or trashed documents,
  optionally into a different collection.
- Add a status option ("archived" | "trashed") to list_documents so agents
  can discover what to restore.
- Extract the documents.restore route logic into a shared documentRestorer
  command, used by both the REST endpoint and the MCP tool.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Use type-only import for Document in documentRestorer

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Revert archived/trashed status option on list_documents

Keeps the restore_document tool and shared documentRestorer command;
removes the list_documents status filter and its tests.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-06 15:08:52 -04:00

2103 lines
53 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 type { DirectionFilter, SortFilter } from "@shared/types";
import { type NavigationNode } from "@shared/types";
import {
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 AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
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 type { DocumentImportTaskResponse } from "@server/queues/tasks/DocumentImportTask";
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 { 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: `%${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.
includeState: !accept?.includes("text/markdown"),
});
authorize(user, "download", document);
const format = accept?.includes("text/html")
? FileOperationFormat.HTMLZip
: accept?.includes("text/markdown")
? FileOperationFormat.MarkdownZip
: 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,
});
if (format === FileOperationFormat.HTMLZip) {
contentType = "text/html";
content = await DocumentHelper.toHTML(document, {
centered: true,
includeMermaid: true,
});
} else if (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,
},
})
: [];
if (attachments.length === 0) {
ctx.set("Content-Type", contentType);
ctx.set(
"Content-Disposition",
contentDisposition(`${fileName}.${extension}`, {
type: "attachment",
})
);
ctx.body = content;
return;
}
await streamZipResponse(ctx, `${fileName}.zip`, async (zip) => {
for (const attachment of attachments) {
const location = path.join(
"attachments",
`${attachment.id}.${mime.extension(attachment.contentType)}`
);
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: err.message,
});
buffer = Buffer.from("");
}
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 }),
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;
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) {
await SearchQuery.create({
userId: user?.id,
teamId,
shareId: share?.id,
source: ctx.state.auth.type || "app", // we'll consider anything that isn't "api" to be "app"
query,
results: total,
});
}
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(),
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, "read", document);
const collection = collectionId
? await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
})
: document?.collection;
if (collection) {
authorize(user, "updateDocument", collection);
}
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");
}
}
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(),
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(),
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(),
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(),
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 job = await new DocumentImportTask().schedule({
key,
sourceMetadata: {
fileName,
mimeType,
},
userId: user.id,
collectionId: collectionId ?? parentDocument?.collectionId,
parentDocumentId,
publish,
ip: ctx.request.ip,
});
const response: DocumentImportTaskResponse = await job.finished();
if ("error" in response) {
throw InvalidRequestError(response.error);
}
const document = await Document.findByPk(response.documentId, {
userId: user.id,
rejectOnEmpty: true,
});
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(),
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(),
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(),
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]: `%${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]: `%${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 }),
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;