From a6125be6f19c709830d474a179a60582a7eb887f Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Thu, 24 Nov 2022 10:11:43 +0530 Subject: [PATCH] Introduce zod for server-side validations (#4397) * chore(server): use zod for validations * fix(server): use ctx.input for documents.list * fix(server): schema for documents.archived * fix(server): documents.deleted, documents.viewed & documents.drafts * fix(server): documents.info * fix(server): documents.export & documents.restore * fix(server): documents.search_titles & documents.search * fix(server): documents.templatize * fix(server): replace nullish() with optional() * fix(server): documents.update * fix(server): documents.move * fix(server): remaining * fix(server): add validation for snippet min and max words * fix(server): fix update types * fix(server): remove DocumentSchema * fix(server): collate duplicate schemas * fix: typos * fix: reviews * chore: Fixed case of Metrics import * fix: restructure /api * fix: loosen validation for id as it can be a slug too * Add test for query by slug Simplify import Co-authored-by: Tom Moor --- package.json | 3 +- server/commands/documentCreator.ts | 3 +- server/commands/documentUpdater.ts | 2 +- server/middlewares/validate.ts | 17 + server/routes/api/documents.ts | 1296 ----------------- .../__snapshots__/documents.test.ts.snap | 4 +- .../api/{ => documents}/documents.test.ts | 362 ++++- server/routes/api/documents/documents.ts | 1236 ++++++++++++++++ server/routes/api/documents/index.ts | 1 + server/routes/api/documents/schema.ts | 279 ++++ server/types.ts | 6 + yarn.lock | 5 + 12 files changed, 1901 insertions(+), 1313 deletions(-) create mode 100644 server/middlewares/validate.ts delete mode 100644 server/routes/api/documents.ts rename server/routes/api/{ => documents}/__snapshots__/documents.test.ts.snap (94%) rename server/routes/api/{ => documents}/documents.test.ts (88%) create mode 100644 server/routes/api/documents/documents.ts create mode 100644 server/routes/api/documents/index.ts create mode 100644 server/routes/api/documents/schema.ts diff --git a/package.json b/package.json index 8c90ba572..10e7d7368 100644 --- a/package.json +++ b/package.json @@ -220,7 +220,8 @@ "winston": "^3.3.3", "ws": "^7.5.3", "y-indexeddb": "^9.0.6", - "yjs": "^13.5.39" + "yjs": "^13.5.39", + "zod": "^3.19.1" }, "devDependencies": { "@babel/cli": "^7.10.5", diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index 935ffe327..c000024b2 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -25,7 +25,7 @@ export default async function documentCreator({ title: string; text: string; publish?: boolean; - collectionId: string; + collectionId?: string; parentDocumentId?: string; importId?: string; templateDocument?: Document | null; @@ -33,7 +33,6 @@ export default async function documentCreator({ template?: boolean; createdAt?: Date; updatedAt?: Date; - index?: number; user: User; editorVersion?: string; source?: "import"; diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index 5c3f47fc0..2253fbbcb 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -14,7 +14,7 @@ type Props = { /** The version of the client editor that was used */ editorVersion?: string; /** The ID of the template that was used */ - templateId?: string; + templateId?: string | null; /** If the document should be displayed full-width on the screen */ fullWidth?: boolean; /** Whether the text be appended to the end instead of replace */ diff --git a/server/middlewares/validate.ts b/server/middlewares/validate.ts new file mode 100644 index 000000000..2c0684352 --- /dev/null +++ b/server/middlewares/validate.ts @@ -0,0 +1,17 @@ +import { Next } from "koa"; +import { z } from "zod"; +import { ValidationError } from "@server/errors"; +import { APIContext } from "@server/types"; + +export default function validate(schema: T) { + return async function validateMiddleware(ctx: APIContext, next: Next) { + try { + ctx.input = schema.parse(ctx.request.body); + } catch (err) { + const { path, message } = err.issues[0]; + const [prefix = "ValidationError"] = path; + throw ValidationError(`${prefix}: ${message}`); + } + return next(); + }; +} diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts deleted file mode 100644 index f008cd859..000000000 --- a/server/routes/api/documents.ts +++ /dev/null @@ -1,1296 +0,0 @@ -import fs from "fs-extra"; -import invariant from "invariant"; -import Router from "koa-router"; -import { pick } from "lodash"; -import mime from "mime-types"; -import { Op, ScopeOptions, WhereOptions } from "sequelize"; -import { TeamPreference } from "@shared/types"; -import { subtractDate } from "@shared/utils/date"; -import { bytesToHumanReadable } from "@shared/utils/files"; -import documentCreator from "@server/commands/documentCreator"; -import documentImporter from "@server/commands/documentImporter"; -import documentLoader from "@server/commands/documentLoader"; -import documentMover from "@server/commands/documentMover"; -import documentPermanentDeleter from "@server/commands/documentPermanentDeleter"; -import documentUpdater from "@server/commands/documentUpdater"; -import { sequelize } from "@server/database/sequelize"; -import { - NotFoundError, - InvalidRequestError, - AuthenticationError, - ValidationError, -} from "@server/errors"; -import auth from "@server/middlewares/authentication"; -import { - Backlink, - Collection, - Document, - Event, - Revision, - SearchQuery, - User, - View, -} from "@server/models"; -import DocumentHelper from "@server/models/helpers/DocumentHelper"; -import SearchHelper from "@server/models/helpers/SearchHelper"; -import { authorize, cannot } from "@server/policies"; -import { - presentCollection, - presentDocument, - presentPolicies, -} from "@server/presenters"; -import slugify from "@server/utils/slugify"; -import { - assertUuid, - assertSort, - assertIn, - assertPresent, - assertPositiveInteger, - assertNotEmpty, - assertBoolean, -} from "@server/validation"; -import env from "../../env"; -import pagination from "./middlewares/pagination"; - -const router = new Router(); - -router.post("documents.list", auth(), pagination(), async (ctx) => { - let { sort = "updatedAt" } = ctx.request.body; - const { template, backlinkDocumentId, parentDocumentId } = ctx.request.body; - // collection and user are here for backwards compatibility - const collectionId = - ctx.request.body.collectionId || ctx.request.body.collection; - const createdById = ctx.request.body.userId || ctx.request.body.user; - let direction = ctx.request.body.direction; - if (direction !== "ASC") { - direction = "DESC"; - } - // always filter by the current team - const { user } = ctx.state; - let where: WhereOptions = { - teamId: user.teamId, - archivedAt: { - [Op.is]: null, - }, - }; - - if (template) { - where = { ...where, template: true }; - } - - // 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) { - assertUuid(createdById, "user must be a UUID"); - where = { ...where, createdById }; - } - - let documentIds: string[] = []; - - // if a specific collection is passed then we need to check auth to view it - if (collectionId) { - assertUuid(collectionId, "collection must be a UUID"); - where = { ...where, collectionId }; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "read", 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") { - documentIds = (collection?.documentStructure || []) - .map((node) => node.id) - .slice(ctx.state.pagination.offset, ctx.state.pagination.limit); - where = { ...where, id: documentIds }; - } // otherwise, filter by all collections the user has access to - } else { - const collectionIds = await user.collectionIds(); - where = { ...where, collectionId: collectionIds }; - } - - if (parentDocumentId) { - assertUuid(parentDocumentId, "parentDocumentId must be a UUID"); - where = { ...where, 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 = { - ...where, - parentDocumentId: { - [Op.is]: null, - }, - }; - } - - if (backlinkDocumentId) { - assertUuid(backlinkDocumentId, "backlinkDocumentId must be a UUID"); - const backlinks = await Backlink.findAll({ - attributes: ["reverseDocumentId"], - where: { - documentId: backlinkDocumentId, - }, - }); - where = { - ...where, - id: backlinks.map((backlink) => backlink.reverseDocumentId), - }; - } - - if (sort === "index") { - sort = "updatedAt"; - } - - assertSort(sort, Document); - - const documents = await Document.defaultScopeWithUser(user.id).findAll({ - where, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - - // index sort is special because it uses the order of the documents in the - // collection.documentStructure rather than a database column - if (documentIds.length) { - documents.sort( - (a, b) => documentIds.indexOf(a.id) - documentIds.indexOf(b.id) - ); - } - - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); - -router.post( - "documents.archived", - auth({ member: true }), - pagination(), - async (ctx) => { - const { sort = "updatedAt" } = ctx.request.body; - - assertSort(sort, Document); - let direction = ctx.request.body.direction; - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; - const collectionIds = await user.collectionIds(); - const collectionScope: Readonly = { - method: ["withCollectionPermissions", user.id], - }; - const viewScope: Readonly = { - method: ["withViews", user.id], - }; - const documents = await Document.scope([ - "defaultScope", - collectionScope, - viewScope, - ]).findAll({ - where: { - teamId: user.teamId, - collectionId: collectionIds, - archivedAt: { - [Op.ne]: null, - }, - }, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; - } -); - -router.post( - "documents.deleted", - auth({ member: true }), - pagination(), - async (ctx) => { - const { sort = "deletedAt" } = ctx.request.body; - - assertSort(sort, Document); - let direction = ctx.request.body.direction; - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; - const collectionIds = await user.collectionIds({ - paranoid: false, - }); - const collectionScope: Readonly = { - method: ["withCollectionPermissions", user.id], - }; - const viewScope: Readonly = { - method: ["withViews", user.id], - }; - const documents = await Document.scope([ - collectionScope, - viewScope, - ]).findAll({ - where: { - teamId: user.teamId, - collectionId: { - [Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }], - }, - deletedAt: { - [Op.ne]: null, - }, - }, - include: [ - { - model: User, - as: "createdBy", - paranoid: false, - }, - { - model: User, - as: "updatedBy", - paranoid: false, - }, - ], - paranoid: false, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; - } -); - -router.post("documents.viewed", auth(), pagination(), async (ctx) => { - let { direction } = ctx.request.body; - const { sort = "updatedAt" } = ctx.request.body; - - assertSort(sort, Document); - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; - const collectionIds = await user.collectionIds(); - const userId = user.id; - const views = await View.findAll({ - where: { - userId, - }, - order: [[sort, direction]], - include: [ - { - model: Document, - required: true, - where: { - collectionId: collectionIds, - }, - include: [ - { - model: Collection.scope({ - method: ["withMembership", userId], - }), - as: "collection", - }, - ], - }, - ], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const documents = views.map((view) => { - const document = view.document; - document.views = [view]; - return document; - }); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); - -router.post("documents.drafts", auth(), pagination(), async (ctx) => { - let { direction } = ctx.request.body; - const { collectionId, dateFilter, sort = "updatedAt" } = ctx.request.body; - - assertSort(sort, Document); - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; - - if (collectionId) { - assertUuid(collectionId, "collectionId must be a UUID"); - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "read", collection); - } - - const collectionIds = collectionId - ? [collectionId] - : await user.collectionIds(); - const where: WhereOptions = { - createdById: user.id, - collectionId: { - [Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }], - }, - publishedAt: { - [Op.is]: null, - }, - }; - - if (dateFilter) { - assertIn( - dateFilter, - ["day", "week", "month", "year"], - "dateFilter must be one of day,week,month,year" - ); - where.updatedAt = { - [Op.gte]: subtractDate(new Date(), dateFilter), - }; - } else { - delete where.updatedAt; - } - - const collectionScope: Readonly = { - method: ["withCollectionPermissions", user.id], - }; - const documents = await Document.scope([ - "defaultScope", - collectionScope, - ]).findAll({ - where, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); - -router.post( - "documents.info", - auth({ - optional: true, - }), - async (ctx) => { - const { id, shareId, apiVersion } = ctx.request.body; - assertPresent(id || shareId, "id or shareId is required"); - const { user } = ctx.state; - const { document, share, collection } = await documentLoader({ - id, - shareId, - user, - }); - const isPublic = cannot(user, "read", document); - const serializedDocument = await presentDocument(document, { - isPublic, - }); - - const team = await document.$get("team"); - - // Passing apiVersion=2 has a single effect, to change the response payload to - // include top level keys for document, sharedTree, and team. - const data = - apiVersion === 2 - ? { - document: serializedDocument, - team: team?.getPreference(TeamPreference.PublicBranding) - ? pick(team, ["avatarUrl", "name"]) - : undefined, - sharedTree: - share && share.includeChildDocuments - ? collection?.getDocumentTree(share.documentId) - : undefined, - } - : serializedDocument; - ctx.body = { - data, - policies: isPublic ? undefined : presentPolicies(user, [document]), - }; - } -); - -router.post( - "documents.export", - auth({ - optional: true, - }), - async (ctx) => { - const { id, shareId } = ctx.request.body; - assertPresent(id || shareId, "id or shareId is required"); - - const { user } = ctx.state; - const accept = ctx.request.headers["accept"]; - - const { document } = await documentLoader({ - id, - shareId, - user, - // We need the collaborative state to generate HTML. - includeState: accept === "text/html", - }); - - let contentType; - let content; - - if (accept?.includes("text/html")) { - contentType = "text/html"; - content = DocumentHelper.toHTML(document); - } else if (accept?.includes("text/markdown")) { - contentType = "text/markdown"; - content = DocumentHelper.toMarkdown(document); - } else { - contentType = "application/json"; - content = DocumentHelper.toMarkdown(document); - } - - if (contentType !== "application/json") { - ctx.set("Content-Type", contentType); - ctx.set( - "Content-Disposition", - `attachment; filename="${slugify( - document.titleWithDefault - )}.${mime.extension(contentType)}"` - ); - ctx.body = content; - return; - } - - ctx.body = { - data: content, - }; - } -); - -router.post("documents.restore", auth({ member: true }), async (ctx) => { - const { id, collectionId, revisionId } = ctx.request.body; - assertPresent(id, "id is required"); - const { user } = ctx.state; - const document = await Document.findByPk(id, { - userId: user.id, - paranoid: false, - }); - - if (!document) { - throw NotFoundError(); - } - - // Passing collectionId allows restoring to a different collection than the - // document was originally within - if (collectionId) { - assertUuid(collectionId, "collectionId must be a uuid"); - document.collectionId = collectionId; - } - - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(document.collectionId); - - // if the collectionId was provided in the request and isn't valid then it will - // be caught as a 403 on the authorize call below. Otherwise we're checking here - // that the original collection still exists and advising to pass collectionId - // if not. - if (document.collection && !collectionId && !collection) { - throw ValidationError( - "Unable to restore to original collection, it may have been deleted" - ); - } - - if (document.collection) { - authorize(user, "update", collection); - } - - if (document.deletedAt) { - authorize(user, "restore", document); - // restore a previously deleted document - await document.unarchive(user.id); - await Event.create({ - name: "documents.restore", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - } else if (document.archivedAt) { - authorize(user, "unarchive", document); - // restore a previously archived document - await document.unarchive(user.id); - await Event.create({ - name: "documents.unarchive", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - } else if (revisionId) { - // restore a document to a specific revision - authorize(user, "update", document); - const revision = await Revision.findByPk(revisionId); - - authorize(document, "restore", revision); - - document.text = revision.text; - document.title = revision.title; - await document.save(); - await Event.create({ - name: "documents.restore", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - } else { - assertPresent(revisionId, "revisionId is required"); - } - - ctx.body = { - data: await presentDocument(document), - policies: presentPolicies(user, [document]), - }; -}); - -router.post("documents.search_titles", auth(), pagination(), async (ctx) => { - const { query } = ctx.request.body; - const { offset, limit } = ctx.state.pagination; - const { user } = ctx.state; - - assertPresent(query, "query is required"); - const collectionIds = await user.collectionIds(); - const documents = await Document.scope([ - { - method: ["withViews", user.id], - }, - { - method: ["withCollectionPermissions", user.id], - }, - ]).findAll({ - where: { - title: { - [Op.iLike]: `%${query}%`, - }, - collectionId: collectionIds, - archivedAt: { - [Op.is]: null, - }, - }, - order: [["updatedAt", "DESC"]], - include: [ - { - model: User, - as: "createdBy", - paranoid: false, - }, - { - model: User, - as: "updatedBy", - paranoid: false, - }, - ], - offset, - limit, - }); - const policies = presentPolicies(user, documents); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); - -router.post( - "documents.search", - auth({ - optional: true, - }), - pagination(), - async (ctx) => { - const { - query, - includeArchived, - includeDrafts, - collectionId, - userId, - dateFilter, - shareId, - } = ctx.request.body; - assertNotEmpty(query, "query is required"); - - if (includeDrafts) { - assertBoolean(includeDrafts); - } - - if (includeArchived) { - assertBoolean(includeArchived); - } - - const { offset, limit } = ctx.state.pagination; - const snippetMinWords = parseInt( - ctx.request.body.snippetMinWords || 20, - 10 - ); - const snippetMaxWords = parseInt( - ctx.request.body.snippetMaxWords || 30, - 10 - ); - - // this typing is a bit ugly, would be better to use a type like ContextWithState - // but that doesn't adequately handle cases when auth is optional - const { user }: { user: User | undefined } = ctx.state; - - let teamId; - let response; - - if (shareId) { - const { share, document } = await documentLoader({ - shareId, - user, - }); - - if (!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 SearchHelper.searchForTeam(team, query, { - includeArchived, - includeDrafts, - collectionId: document.collectionId, - share, - dateFilter, - offset, - limit, - snippetMinWords, - snippetMaxWords, - }); - } else { - if (!user) { - throw AuthenticationError("Authentication error"); - } - - teamId = user.teamId; - - if (collectionId) { - assertUuid(collectionId, "collectionId must be a UUID"); - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "read", collection); - } - - let collaboratorIds = undefined; - - if (userId) { - assertUuid(userId, "userId must be a UUID"); - collaboratorIds = [userId]; - } - - if (dateFilter) { - assertIn( - dateFilter, - ["day", "week", "month", "year"], - "dateFilter must be one of day,week,month,year" - ); - } - - response = await SearchHelper.searchForUser(user, query, { - includeArchived, - includeDrafts, - collaboratorIds, - collectionId, - dateFilter, - offset, - limit, - snippetMinWords, - snippetMaxWords, - }); - } - - const { results, totalCount } = response; - const documents = results.map((result) => result.document); - - const data = await Promise.all( - results.map(async (result) => { - const document = await presentDocument(result.document); - return { ...result, document }; - }) - ); - - // When requesting subsequent pages of search results we don't want to record - // duplicate search query records - if (offset === 0) { - SearchQuery.create({ - userId: user?.id, - teamId, - shareId, - source: ctx.state.authType || "app", // we'll consider anything that isn't "api" to be "app" - query, - results: totalCount, - }); - } - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies: user ? presentPolicies(user, documents) : null, - }; - } -); - -router.post("documents.templatize", auth({ member: true }), async (ctx) => { - const { id } = ctx.request.body; - assertPresent(id, "id is required"); - const { user } = ctx.state; - - const original = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "update", original); - - const document = await Document.create({ - editorVersion: original.editorVersion, - collectionId: original.collectionId, - teamId: original.teamId, - userId: user.id, - publishedAt: new Date(), - lastModifiedById: user.id, - createdById: user.id, - template: true, - title: original.title, - text: original.text, - }); - await Event.create({ - name: "documents.create", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - template: true, - }, - ip: ctx.request.ip, - }); - - // reload to get all of the data needed to present (user, collection etc) - const reloaded = await Document.findByPk(document.id, { - userId: user.id, - }); - invariant(reloaded, "document not found"); - - ctx.body = { - data: await presentDocument(reloaded), - policies: presentPolicies(user, [reloaded]), - }; -}); - -router.post("documents.update", auth(), async (ctx) => { - const { - id, - title, - text, - fullWidth, - publish, - lastRevision, - templateId, - collectionId, - append, - } = ctx.request.body; - const editorVersion = ctx.headers["x-editor-version"] as string | undefined; - assertPresent(id, "id is required"); - if (append) { - assertPresent(text, "Text is required while appending"); - } - - if (collectionId) { - assertUuid(collectionId, "collectionId must be an uuid"); - } - - const { user } = ctx.state; - - let collection: Collection | null | undefined; - - const document = await Document.findByPk(id, { - userId: user.id, - includeState: true, - }); - collection = document?.collection; - authorize(user, "update", document); - - if (publish) { - if (!document.collectionId) { - assertPresent( - collectionId, - "collectionId is required to publish a draft without collection" - ); - collection = await Collection.findByPk(collectionId); - } else { - collection = document.collection; - } - authorize(user, "publish", collection); - } - - if (lastRevision && lastRevision !== document.revisionCount) { - throw InvalidRequestError("Document has changed since last revision"); - } - - const updatedDocument = await sequelize.transaction(async (transaction) => { - return documentUpdater({ - document, - user, - title, - text, - fullWidth, - publish, - collectionId, - append, - templateId, - editorVersion, - transaction, - ip: ctx.request.ip, - }); - }); - - updatedDocument.updatedBy = user; - updatedDocument.collection = collection; - - ctx.body = { - data: await presentDocument(updatedDocument), - policies: presentPolicies(user, [updatedDocument]), - }; -}); - -router.post("documents.move", auth(), async (ctx) => { - const { id, collectionId, parentDocumentId, index } = ctx.request.body; - assertUuid(id, "id must be a uuid"); - assertUuid(collectionId, "collectionId must be a uuid"); - - if (parentDocumentId) { - assertUuid(parentDocumentId, "parentDocumentId must be a uuid"); - } - - if (index) { - assertPositiveInteger(index, "index must be a positive integer"); - } - - if (parentDocumentId === id) { - throw InvalidRequestError( - "Infinite loop detected, cannot nest a document inside itself" - ); - } - - const { user } = ctx.state; - const document = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "move", document); - - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "update", collection); - - if (parentDocumentId) { - const parent = await Document.findByPk(parentDocumentId, { - userId: user.id, - }); - authorize(user, "update", parent); - } - - const { - documents, - collections, - collectionChanged, - } = await sequelize.transaction(async (transaction) => - documentMover({ - user, - document, - collectionId, - parentDocumentId, - index, - ip: ctx.request.ip, - transaction, - }) - ); - - ctx.body = { - data: { - documents: await Promise.all( - documents.map((document) => presentDocument(document)) - ), - collections: await Promise.all( - collections.map((collection) => presentCollection(collection)) - ), - }, - policies: collectionChanged ? presentPolicies(user, documents) : [], - }; -}); - -router.post("documents.archive", auth(), async (ctx) => { - const { id } = ctx.request.body; - assertPresent(id, "id is required"); - const { user } = ctx.state; - - const document = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "archive", document); - - await document.archive(user.id); - await Event.create({ - name: "documents.archive", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - - ctx.body = { - data: await presentDocument(document), - policies: presentPolicies(user, [document]), - }; -}); - -router.post("documents.delete", auth(), async (ctx) => { - const { id, permanent } = ctx.request.body; - assertPresent(id, "id is required"); - const { user } = ctx.state; - - if (permanent) { - const document = await Document.findByPk(id, { - userId: user.id, - paranoid: false, - }); - authorize(user, "permanentDelete", document); - - await Document.update( - { - parentDocumentId: null, - }, - { - where: { - parentDocumentId: document.id, - }, - paranoid: false, - } - ); - await documentPermanentDeleter([document]); - await Event.create({ - name: "documents.permanent_delete", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - } else { - const document = await Document.findByPk(id, { - userId: user.id, - }); - - authorize(user, "delete", document); - - await document.delete(user.id); - await Event.create({ - name: "documents.delete", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - } - - ctx.body = { - success: true, - }; -}); - -router.post("documents.unpublish", auth(), async (ctx) => { - const { id } = ctx.request.body; - assertPresent(id, "id is required"); - const { user } = ctx.state; - - const document = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "unpublish", document); - - const childDocumentIds = await document.getChildDocumentIds(); - if (childDocumentIds.length > 0) { - throw InvalidRequestError("Cannot unpublish document with child documents"); - } - - await document.unpublish(user.id); - await Event.create({ - name: "documents.unpublish", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - - ctx.body = { - data: await presentDocument(document), - policies: presentPolicies(user, [document]), - }; -}); - -router.post("documents.import", auth(), async (ctx) => { - const { publish, collectionId, parentDocumentId, index } = ctx.request.body; - - if (!ctx.is("multipart/form-data")) { - throw InvalidRequestError("Request type must be multipart/form-data"); - } - - const file = ctx.request.files - ? Object.values(ctx.request.files)[0] - : undefined; - if (!file) { - throw InvalidRequestError("Request must include a file parameter"); - } - - if (file.size > env.AWS_S3_UPLOAD_MAX_SIZE) { - throw InvalidRequestError( - `The selected file was larger than the ${bytesToHumanReadable( - env.AWS_S3_UPLOAD_MAX_SIZE - )} maximum size` - ); - } - - assertUuid(collectionId, "collectionId must be an uuid"); - - if (parentDocumentId) { - assertUuid(parentDocumentId, "parentDocumentId must be an uuid"); - } - - if (index) { - assertPositiveInteger(index, "index must be an integer (>=0)"); - } - const { user } = ctx.state; - - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findOne({ - where: { - id: collectionId, - teamId: user.teamId, - }, - }); - authorize(user, "publish", collection); - let parentDocument; - - if (parentDocumentId) { - parentDocument = await Document.findOne({ - where: { - id: parentDocumentId, - collectionId: collection.id, - }, - }); - authorize(user, "read", parentDocument, { - collection, - }); - } - - const content = await fs.readFile(file.path); - const document = await sequelize.transaction(async (transaction) => { - const { text, title } = await documentImporter({ - user, - fileName: file.name, - mimeType: file.type, - content, - ip: ctx.request.ip, - transaction, - }); - - return documentCreator({ - source: "import", - title, - text, - publish, - collectionId, - parentDocumentId, - index, - user, - ip: ctx.request.ip, - transaction, - }); - }); - - document.collection = collection; - - return (ctx.body = { - data: await presentDocument(document), - policies: presentPolicies(user, [document]), - }); -}); - -router.post("documents.create", auth(), async (ctx) => { - const { - title = "", - text = "", - publish, - collectionId, - parentDocumentId, - templateId, - template, - index, - } = ctx.request.body; - const editorVersion = ctx.headers["x-editor-version"] as string | undefined; - - if (parentDocumentId || template || publish) { - assertPresent( - collectionId, - publish - ? "collectionId is required to publish a draft without collection" - : "collectionId is required to create a nested doc or a template" - ); - } - - if (collectionId) { - assertUuid(collectionId, "collectionId must be an uuid"); - } - - if (parentDocumentId) { - assertUuid(parentDocumentId, "parentDocumentId must be an uuid"); - } - - if (index) { - assertPositiveInteger(index, "index must be an integer (>=0)"); - } - const { user } = ctx.state; - - let collection; - - if (collectionId) { - collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findOne({ - where: { - id: collectionId, - teamId: user.teamId, - }, - }); - authorize(user, "publish", collection); - } - - let parentDocument; - - if (parentDocumentId) { - parentDocument = await Document.findOne({ - where: { - id: parentDocumentId, - collectionId: collection?.id, - }, - }); - authorize(user, "read", parentDocument, { - collection, - }); - } - - let templateDocument: Document | null | undefined; - - if (templateId) { - templateDocument = await Document.findByPk(templateId, { - userId: user.id, - }); - authorize(user, "read", templateDocument); - } - - const document = await sequelize.transaction(async (transaction) => { - return documentCreator({ - title, - text, - publish, - collectionId, - parentDocumentId, - templateDocument, - template, - index, - user, - editorVersion, - ip: ctx.request.ip, - transaction, - }); - }); - - document.collection = collection; - - return (ctx.body = { - data: await presentDocument(document), - policies: presentPolicies(user, [document]), - }); -}); - -export default router; diff --git a/server/routes/api/__snapshots__/documents.test.ts.snap b/server/routes/api/documents/__snapshots__/documents.test.ts.snap similarity index 94% rename from server/routes/api/__snapshots__/documents.test.ts.snap rename to server/routes/api/documents/__snapshots__/documents.test.ts.snap index b9386a3cc..eaa8fccbd 100644 --- a/server/routes/api/__snapshots__/documents.test.ts.snap +++ b/server/routes/api/documents/__snapshots__/documents.test.ts.snap @@ -64,8 +64,8 @@ Object { exports[`#documents.update should require text while appending 1`] = ` Object { - "error": "param_required", - "message": "Text is required while appending", + "error": "validation_error", + "message": "ValidationError: text is required while appending", "ok": false, "status": 400, } diff --git a/server/routes/api/documents.test.ts b/server/routes/api/documents/documents.test.ts similarity index 88% rename from server/routes/api/documents.test.ts rename to server/routes/api/documents/documents.test.ts index 5968f5b0c..0a2b79618 100644 --- a/server/routes/api/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -23,6 +23,17 @@ import { seed, getTestServer } from "@server/test/support"; const server = getTestServer(); describe("#documents.info", () => { + it("should fail if both id and shareId are absent", async () => { + const res = await server.post("/api/documents.info", { + body: {}, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual( + "ValidationError: one of id or shareId is required" + ); + }); + it("should return published document", async () => { const { user, document } = await seed(); const res = await server.post("/api/documents.info", { @@ -36,6 +47,19 @@ describe("#documents.info", () => { expect(body.data.id).toEqual(document.id); }); + it("should return published document for urlId", async () => { + const { user, document } = await seed(); + const res = await server.post("/api/documents.info", { + body: { + token: user.getJwtToken(), + id: document.urlId, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(document.id); + }); + it("should return archived document", async () => { const { user, document } = await seed(); await document.archive(user.id); @@ -411,7 +435,7 @@ describe("#documents.info", () => { const res = await server.post("/api/documents.info", { body: { token: user.getJwtToken(), - id: "test", + id: "9bcbf864-1090-4eb6-ba05-4da0c3a5c58e", }, }); expect(res.status).toEqual(404); @@ -665,6 +689,58 @@ describe("#documents.export", () => { }); describe("#documents.list", () => { + it("should fail for invalid userId", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + userId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("userId: Invalid uuid"); + }); + + it("should fail for invalid collectionId", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + collectionId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("collectionId: Invalid uuid"); + }); + + it("should fail for invalid parentDocumentId", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + parentDocumentId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("parentDocumentId: Invalid uuid"); + }); + + it("should fail for invalid backlinkDocumentId", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + backlinkDocumentId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("backlinkDocumentId: Invalid uuid"); + }); + it("should return documents", async () => { const { user, document } = await seed(); const res = await server.post("/api/documents.list", { @@ -853,6 +929,36 @@ describe("#documents.list", () => { }); describe("#documents.drafts", () => { + it("should fail for invalid collectionId", async () => { + const { user, document } = await seed(); + document.publishedAt = null; + await document.save(); + const res = await server.post("/api/documents.drafts", { + body: { + token: user.getJwtToken(), + collectionId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("collectionId: Invalid uuid"); + }); + + it("should fail for invalid dateFilter", async () => { + const { user, document } = await seed(); + document.publishedAt = null; + await document.save(); + const res = await server.post("/api/documents.drafts", { + body: { + token: user.getJwtToken(), + dateFilter: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("dateFilter: Invalid input"); + }); + it("should return unpublished documents", async () => { const { user, document } = await seed(); document.publishedAt = null; @@ -909,6 +1015,18 @@ describe("#documents.drafts", () => { }); describe("#documents.search_titles", () => { + it("should fail without query", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("query: Required"); + }); + it("should return case insensitive results for partial query", async () => { const user = await buildUser(); const document = await buildDocument({ @@ -960,6 +1078,20 @@ describe("#documents.search_titles", () => { }); describe("#documents.search", () => { + it("should fail for invalid shareId", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "much", + shareId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("shareId: Invalid uuid"); + }); + it("should return results", async () => { const { user } = await seed(); const res = await server.post("/api/documents.search", { @@ -1451,6 +1583,20 @@ describe("#documents.search", () => { }); }); +describe("#documents.templatize", () => { + it("should require id", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.templatize", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.message).toBe("id: Required"); + }); +}); + describe("#documents.archived", () => { it("should return archived documents", async () => { const { user } = await seed(); @@ -1675,6 +1821,64 @@ describe("#documents.viewed", () => { }); describe("#documents.move", () => { + it("should fail if attempting to nest doc within itself", async () => { + const { user, document } = await seed(); + const collection = await buildCollection(); + const res = await server.post("/api/documents.move", { + body: { + id: document.id, + collectionId: collection.id, + parentDocumentId: document.id, + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual( + "ValidationError: infinite loop detected, cannot nest a document inside itself" + ); + }); + + it("should require id", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.move", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + + it("should require collectionId", async () => { + const { user, document } = await seed(); + const res = await server.post("/api/documents.move", { + body: { + token: user.getJwtToken(), + id: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("collectionId: Required"); + }); + + it("should fail for invalid index", async () => { + const { user, document, collection } = await seed(); + const res = await server.post("/api/documents.move", { + body: { + token: user.getJwtToken(), + id: document.id, + collectionId: collection.id, + index: -1, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("index: Number must be greater than 0"); + }); + it("should move the document", async () => { const { user, document } = await seed(); const collection = await buildCollection({ @@ -1726,6 +1930,34 @@ describe("#documents.move", () => { }); describe("#documents.restore", () => { + it("should require id", async () => { + const { user, document } = await seed(); + await document.destroy(); + const res = await server.post("/api/documents.restore", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + + it("should fail for invalid collectionId", async () => { + const { user, document } = await seed(); + await document.destroy(); + const res = await server.post("/api/documents.restore", { + body: { + token: user.getJwtToken(), + id: document.id, + collectionId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("collectionId: Invalid uuid"); + }); + it("should allow restore of trashed documents", async () => { const { user, document } = await seed(); await document.destroy(); @@ -1905,7 +2137,7 @@ describe("#documents.restore", () => { const res = await server.post("/api/documents.restore", { body: { token: user.getJwtToken(), - id: "test", + id: "76fe8ba4-4e6a-4a75-8a10-9bf57330b24c", }, }); expect(res.status).toEqual(404); @@ -1935,6 +2167,18 @@ describe("#documents.restore", () => { }); describe("#documents.import", () => { + it("should require collectionId", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.import", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("collectionId: Required"); + }); + it("should error if no file is passed", async () => { const user = await buildUser(); const res = await server.post("/api/documents.import", { @@ -1957,6 +2201,37 @@ describe("#documents.import", () => { }); describe("#documents.create", () => { + it("should fail for invalid collectionId", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + collectionId: "invalid", + title: "new document", + text: "hello", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("collectionId: Invalid uuid"); + }); + + it("should fail for invalid parentDocumentId", async () => { + const { user, collection } = await seed(); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + collectionId: collection.id, + parentDocumentId: "invalid", + title: "new document", + text: "hello", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("parentDocumentId: Invalid uuid"); + }); + it("should create as a new document", async () => { const { user, collection } = await seed(); const res = await server.post("/api/documents.create", { @@ -2007,10 +2282,10 @@ describe("#documents.create", () => { }); const body = await res.json(); - expect(body.message).toBe( - "collectionId is required to create a nested doc or a template" - ); expect(res.status).toEqual(400); + expect(body.message).toBe( + "ValidationError: collectionId is required to create a template document" + ); }); it("should not allow publishing without specifying the collection", async () => { @@ -2026,10 +2301,10 @@ describe("#documents.create", () => { }); const body = await res.json(); - expect(body.message).toBe( - "collectionId is required to publish a draft without collection" - ); expect(res.status).toEqual(400); + expect(body.message).toBe( + "ValidationError: collectionId is required to publish" + ); }); it("should not allow creating a nested doc without a collection", async () => { @@ -2045,10 +2320,10 @@ describe("#documents.create", () => { }); const body = await res.json(); - expect(body.message).toBe( - "collectionId is required to create a nested doc or a template" - ); expect(res.status).toEqual(400); + expect(body.message).toBe( + "ValidationError: collectionId is required to create a nested document" + ); }); it("should not allow very long titles", async () => { @@ -2494,9 +2769,50 @@ describe("#documents.update", () => { }); expect(res.status).toEqual(403); }); + + it("should fail for invalid collectionId", async () => { + const { document } = await seed(); + const user = await buildUser(); + const res = await server.post("/api/documents.update", { + body: { + token: user.getJwtToken(), + id: document.id, + text: "Updated", + collectionId: "invalid", + }, + }); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.message).toBe("collectionId: Invalid uuid"); + }); + + it("should require id", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.update", { + body: { + token: user.getJwtToken(), + text: "Updated", + }, + }); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.message).toBe("id: Required"); + }); }); describe("#documents.archive", () => { + it("should require id", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.archive", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + it("should allow archiving document", async () => { const { user, document } = await seed(); const res = await server.post("/api/documents.archive", { @@ -2523,6 +2839,18 @@ describe("#documents.archive", () => { }); describe("#documents.delete", () => { + it("should require id", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.delete", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + it("should allow deleting document", async () => { const { user, document } = await seed(); const res = await server.post("/api/documents.delete", { @@ -2616,6 +2944,18 @@ describe("#documents.delete", () => { }); describe("#documents.unpublish", () => { + it("should require id", async () => { + const { user } = await seed(); + const res = await server.post("/api/documents.unpublish", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + it("should unpublish a document", async () => { const { user, document } = await seed(); const res = await server.post("/api/documents.unpublish", { diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts new file mode 100644 index 000000000..24668f10b --- /dev/null +++ b/server/routes/api/documents/documents.ts @@ -0,0 +1,1236 @@ +import fs from "fs-extra"; +import invariant from "invariant"; +import Router from "koa-router"; +import { pick } from "lodash"; +import mime from "mime-types"; +import { Op, ScopeOptions, WhereOptions } from "sequelize"; +import { TeamPreference } from "@shared/types"; +import { subtractDate } from "@shared/utils/date"; +import { bytesToHumanReadable } from "@shared/utils/files"; +import documentCreator from "@server/commands/documentCreator"; +import documentImporter from "@server/commands/documentImporter"; +import documentLoader from "@server/commands/documentLoader"; +import documentMover from "@server/commands/documentMover"; +import documentPermanentDeleter from "@server/commands/documentPermanentDeleter"; +import documentUpdater from "@server/commands/documentUpdater"; +import { sequelize } from "@server/database/sequelize"; +import { + NotFoundError, + InvalidRequestError, + AuthenticationError, + ValidationError, +} from "@server/errors"; +import auth from "@server/middlewares/authentication"; +import validate from "@server/middlewares/validate"; +import { + Backlink, + Collection, + Document, + Event, + Revision, + SearchQuery, + User, + View, +} from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; +import SearchHelper from "@server/models/helpers/SearchHelper"; +import { authorize, cannot } from "@server/policies"; +import { + presentCollection, + presentDocument, + presentPolicies, +} from "@server/presenters"; +import { APIContext } from "@server/types"; +import slugify from "@server/utils/slugify"; +import { assertPresent } from "@server/validation"; +import env from "../../../env"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "documents.list", + auth(), + pagination(), + validate(T.DocumentsListSchema), + async (ctx: APIContext) => { + let { sort } = ctx.input; + const { + direction, + template, + collectionId, + backlinkDocumentId, + parentDocumentId, + userId: createdById, + } = ctx.input; + + // always filter by the current team + const { user } = ctx.state; + let where: WhereOptions = { + teamId: user.teamId, + archivedAt: { + [Op.is]: null, + }, + }; + + if (template) { + where = { ...where, template: true }; + } + + // 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 = { ...where, createdById }; + } + + let documentIds: string[] = []; + + // if a specific collection is passed then we need to check auth to view it + if (collectionId) { + where = { ...where, collectionId }; + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "read", 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") { + documentIds = (collection?.documentStructure || []) + .map((node) => node.id) + .slice(ctx.state.pagination.offset, ctx.state.pagination.limit); + where = { ...where, id: documentIds }; + } // otherwise, filter by all collections the user has access to + } else { + const collectionIds = await user.collectionIds(); + where = { ...where, collectionId: collectionIds }; + } + + if (parentDocumentId) { + where = { ...where, 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 = { + ...where, + parentDocumentId: { + [Op.is]: null, + }, + }; + } + + if (backlinkDocumentId) { + const backlinks = await Backlink.findAll({ + attributes: ["reverseDocumentId"], + where: { + documentId: backlinkDocumentId, + }, + }); + where = { + ...where, + id: backlinks.map((backlink) => backlink.reverseDocumentId), + }; + } + + if (sort === "index") { + sort = "updatedAt"; + } + + const documents = await Document.defaultScopeWithUser(user.id).findAll({ + where, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + // index sort is special because it uses the order of the documents in the + // collection.documentStructure rather than a database column + if (documentIds.length) { + documents.sort( + (a, b) => documentIds.indexOf(a.id) - documentIds.indexOf(b.id) + ); + } + + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + const policies = presentPolicies(user, documents); + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, + }; + } +); + +router.post( + "documents.archived", + auth({ member: true }), + pagination(), + validate(T.DocumentsArchivedSchema), + async (ctx: APIContext) => { + const { sort, direction } = ctx.input; + const { user } = ctx.state; + const collectionIds = await user.collectionIds(); + const collectionScope: Readonly = { + method: ["withCollectionPermissions", user.id], + }; + const viewScope: Readonly = { + method: ["withViews", user.id], + }; + const documents = await Document.scope([ + "defaultScope", + collectionScope, + viewScope, + ]).findAll({ + where: { + teamId: user.teamId, + collectionId: collectionIds, + archivedAt: { + [Op.ne]: null, + }, + }, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + const policies = presentPolicies(user, documents); + + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, + }; + } +); + +router.post( + "documents.deleted", + auth({ member: true }), + pagination(), + validate(T.DocumentsDeletedSchema), + async (ctx: APIContext) => { + const { sort, direction } = ctx.input; + const { user } = ctx.state; + const collectionIds = await user.collectionIds({ + paranoid: false, + }); + const collectionScope: Readonly = { + method: ["withCollectionPermissions", user.id], + }; + const viewScope: Readonly = { + method: ["withViews", user.id], + }; + const documents = await Document.scope([ + collectionScope, + viewScope, + ]).findAll({ + where: { + teamId: user.teamId, + collectionId: { + [Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }], + }, + deletedAt: { + [Op.ne]: null, + }, + }, + include: [ + { + model: User, + as: "createdBy", + paranoid: false, + }, + { + model: User, + as: "updatedBy", + paranoid: false, + }, + ], + paranoid: false, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + 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) => { + const { sort, direction } = ctx.input; + const { user } = ctx.state; + const collectionIds = await user.collectionIds(); + const userId = user.id; + const views = await View.findAll({ + where: { + userId, + }, + order: [[sort, direction]], + include: [ + { + model: Document, + required: true, + where: { + collectionId: collectionIds, + }, + include: [ + { + model: Collection.scope({ + method: ["withMembership", userId], + }), + as: "collection", + }, + ], + }, + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const documents = views.map((view) => { + const document = view.document; + document.views = [view]; + return document; + }); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + 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) => { + const { collectionId, dateFilter, direction, sort } = ctx.input; + const { user } = ctx.state; + + if (collectionId) { + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "read", collection); + } + + const collectionIds = collectionId + ? [collectionId] + : await user.collectionIds(); + const where: WhereOptions = { + 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 collectionScope: Readonly = { + method: ["withCollectionPermissions", user.id], + }; + const documents = await Document.scope([ + "defaultScope", + collectionScope, + ]).findAll({ + where, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + 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) => { + const { id, shareId, apiVersion } = ctx.input; + const { user } = ctx.state; + const { document, share, collection } = await documentLoader({ + id, + shareId, + user, + }); + const isPublic = cannot(user, "read", document); + const serializedDocument = await presentDocument(document, { + isPublic, + }); + + const team = await document.$get("team"); + + // Passing apiVersion=2 has a single effect, to change the response payload to + // include top level keys for document, sharedTree, and team. + const data = + apiVersion === 2 + ? { + document: serializedDocument, + team: team?.getPreference(TeamPreference.PublicBranding) + ? pick(team, ["avatarUrl", "name"]) + : undefined, + sharedTree: + share && share.includeChildDocuments + ? collection?.getDocumentTree(share.documentId) + : undefined, + } + : serializedDocument; + ctx.body = { + data, + policies: isPublic ? undefined : presentPolicies(user, [document]), + }; + } +); + +router.post( + "documents.export", + auth({ + optional: true, + }), + validate(T.DocumentsExportSchema), + async (ctx: APIContext) => { + const { id, shareId } = ctx.input; + const { user } = ctx.state; + const accept = ctx.request.headers["accept"]; + + const { document } = await documentLoader({ + id, + shareId, + user, + // We need the collaborative state to generate HTML. + includeState: accept === "text/html", + }); + + let contentType; + let content; + + if (accept?.includes("text/html")) { + contentType = "text/html"; + content = DocumentHelper.toHTML(document); + } else if (accept?.includes("text/markdown")) { + contentType = "text/markdown"; + content = DocumentHelper.toMarkdown(document); + } else { + contentType = "application/json"; + content = DocumentHelper.toMarkdown(document); + } + + if (contentType !== "application/json") { + ctx.set("Content-Type", contentType); + ctx.set( + "Content-Disposition", + `attachment; filename="${slugify( + document.titleWithDefault + )}.${mime.extension(contentType)}"` + ); + ctx.body = content; + return; + } + + ctx.body = { + data: content, + }; + } +); + +router.post( + "documents.restore", + auth({ member: true }), + validate(T.DocumentsRestoreSchema), + async (ctx: APIContext) => { + const { id, collectionId, revisionId } = ctx.input; + const { user } = ctx.state; + const document = await Document.findByPk(id, { + userId: user.id, + paranoid: false, + }); + + if (!document) { + throw NotFoundError(); + } + + // Passing collectionId allows restoring to a different collection than the + // document was originally within + if (collectionId) { + document.collectionId = collectionId; + } + + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(document.collectionId); + + // if the collectionId was provided in the request and isn't valid then it will + // be caught as a 403 on the authorize call below. Otherwise we're checking here + // that the original collection still exists and advising to pass collectionId + // if not. + if (document.collection && !collectionId && !collection) { + throw ValidationError( + "Unable to restore to original collection, it may have been deleted" + ); + } + + if (document.collection) { + authorize(user, "update", collection); + } + + if (document.deletedAt) { + authorize(user, "restore", document); + // restore a previously deleted document + await document.unarchive(user.id); + await Event.create({ + name: "documents.restore", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + }, + ip: ctx.request.ip, + }); + } else if (document.archivedAt) { + authorize(user, "unarchive", document); + // restore a previously archived document + await document.unarchive(user.id); + await Event.create({ + name: "documents.unarchive", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + }, + ip: ctx.request.ip, + }); + } else if (revisionId) { + // restore a document to a specific revision + authorize(user, "update", document); + const revision = await Revision.findByPk(revisionId); + + authorize(document, "restore", revision); + + document.text = revision.text; + document.title = revision.title; + await document.save(); + await Event.create({ + name: "documents.restore", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + }, + ip: ctx.request.ip, + }); + } else { + assertPresent(revisionId, "revisionId is required"); + } + + ctx.body = { + data: await presentDocument(document), + policies: presentPolicies(user, [document]), + }; + } +); + +router.post( + "documents.search_titles", + auth(), + pagination(), + validate(T.DocumentsSearchTitlesSchema), + async (ctx: APIContext) => { + const { query } = ctx.input; + const { offset, limit } = ctx.state.pagination; + const { user } = ctx.state; + + const collectionIds = await user.collectionIds(); + const documents = await Document.scope([ + { + method: ["withViews", user.id], + }, + { + method: ["withCollectionPermissions", user.id], + }, + ]).findAll({ + where: { + title: { + [Op.iLike]: `%${query}%`, + }, + collectionId: collectionIds, + archivedAt: { + [Op.is]: null, + }, + }, + order: [["updatedAt", "DESC"]], + include: [ + { + model: User, + as: "createdBy", + paranoid: false, + }, + { + model: User, + as: "updatedBy", + paranoid: false, + }, + ], + offset, + limit, + }); + const policies = presentPolicies(user, documents); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, + }; + } +); + +router.post( + "documents.search", + auth({ + optional: true, + }), + pagination(), + validate(T.DocumentsSearchSchema), + async (ctx: APIContext) => { + const { + query, + includeArchived, + includeDrafts, + collectionId, + userId, + dateFilter, + shareId, + snippetMinWords, + snippetMaxWords, + } = ctx.input; + const { offset, limit } = ctx.state.pagination; + + // this typing is a bit ugly, would be better to use a type like ContextWithState + // but that doesn't adequately handle cases when auth is optional + const { user }: { user: User | undefined } = ctx.state; + + let teamId; + let response; + + if (shareId) { + const { share, document } = await documentLoader({ + shareId, + user, + }); + + if (!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 SearchHelper.searchForTeam(team, query, { + includeArchived, + includeDrafts, + collectionId: document.collectionId, + share, + dateFilter, + offset, + limit, + snippetMinWords, + snippetMaxWords, + }); + } else { + if (!user) { + throw AuthenticationError("Authentication error"); + } + + teamId = user.teamId; + + if (collectionId) { + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "read", collection); + } + + let collaboratorIds = undefined; + + if (userId) { + collaboratorIds = [userId]; + } + + response = await SearchHelper.searchForUser(user, query, { + includeArchived, + includeDrafts, + collaboratorIds, + collectionId, + dateFilter, + offset, + limit, + snippetMinWords, + snippetMaxWords, + }); + } + + const { results, totalCount } = response; + const documents = results.map((result) => result.document); + + const data = await Promise.all( + results.map(async (result) => { + const document = await presentDocument(result.document); + return { ...result, document }; + }) + ); + + // When requesting subsequent pages of search results we don't want to record + // duplicate search query records + if (offset === 0) { + SearchQuery.create({ + userId: user?.id, + teamId, + shareId, + source: ctx.state.authType || "app", // we'll consider anything that isn't "api" to be "app" + query, + results: totalCount, + }); + } + + ctx.body = { + pagination: ctx.state.pagination, + data, + policies: user ? presentPolicies(user, documents) : null, + }; + } +); + +router.post( + "documents.templatize", + auth({ member: true }), + validate(T.DocumentsTemplatizeSchema), + async (ctx: APIContext) => { + const { id } = ctx.input; + const { user } = ctx.state; + + const original = await Document.findByPk(id, { + userId: user.id, + }); + authorize(user, "update", original); + + const document = await Document.create({ + editorVersion: original.editorVersion, + collectionId: original.collectionId, + teamId: original.teamId, + userId: user.id, + publishedAt: new Date(), + lastModifiedById: user.id, + createdById: user.id, + template: true, + title: original.title, + text: original.text, + }); + await Event.create({ + name: "documents.create", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + template: true, + }, + ip: ctx.request.ip, + }); + + // reload to get all of the data needed to present (user, collection etc) + const reloaded = await Document.findByPk(document.id, { + userId: user.id, + }); + invariant(reloaded, "document not found"); + + ctx.body = { + data: await presentDocument(reloaded), + policies: presentPolicies(user, [reloaded]), + }; + } +); + +router.post( + "documents.update", + auth(), + validate(T.DocumentsUpdateSchema), + async (ctx: APIContext) => { + const { + id, + title, + text, + fullWidth, + publish, + lastRevision, + templateId, + collectionId, + append, + } = ctx.input; + const editorVersion = ctx.headers["x-editor-version"] as string | undefined; + const { user } = ctx.state; + let collection: Collection | null | undefined; + + const document = await Document.findByPk(id, { + userId: user.id, + includeState: true, + }); + collection = document?.collection; + authorize(user, "update", document); + + if (publish) { + if (!document.collectionId) { + assertPresent( + collectionId, + "collectionId is required to publish a draft without collection" + ); + collection = await Collection.findByPk(collectionId as string); + } else { + collection = document.collection; + } + authorize(user, "publish", collection); + } + + if (lastRevision && lastRevision !== document.revisionCount) { + throw InvalidRequestError("Document has changed since last revision"); + } + + const updatedDocument = await sequelize.transaction(async (transaction) => { + return documentUpdater({ + document, + user, + title, + text, + fullWidth, + publish, + collectionId, + append, + templateId, + editorVersion, + transaction, + ip: ctx.request.ip, + }); + }); + + updatedDocument.updatedBy = user; + updatedDocument.collection = collection; + + ctx.body = { + data: await presentDocument(updatedDocument), + policies: presentPolicies(user, [updatedDocument]), + }; + } +); + +router.post( + "documents.move", + auth(), + validate(T.DocumentsMoveSchema), + async (ctx: APIContext) => { + const { id, collectionId, parentDocumentId, index } = ctx.input; + const { user } = ctx.state; + const document = await Document.findByPk(id, { + userId: user.id, + }); + authorize(user, "move", document); + + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "update", collection); + + if (parentDocumentId) { + const parent = await Document.findByPk(parentDocumentId, { + userId: user.id, + }); + authorize(user, "update", parent); + } + + const { + documents, + collections, + collectionChanged, + } = await sequelize.transaction(async (transaction) => + documentMover({ + user, + document, + collectionId, + parentDocumentId, + index, + ip: ctx.request.ip, + transaction, + }) + ); + + ctx.body = { + data: { + documents: await Promise.all( + documents.map((document) => presentDocument(document)) + ), + collections: await Promise.all( + collections.map((collection) => presentCollection(collection)) + ), + }, + policies: collectionChanged ? presentPolicies(user, documents) : [], + }; + } +); + +router.post( + "documents.archive", + auth(), + validate(T.DocumentsArchiveSchema), + async (ctx: APIContext) => { + const { id } = ctx.input; + const { user } = ctx.state; + + const document = await Document.findByPk(id, { + userId: user.id, + }); + authorize(user, "archive", document); + + await document.archive(user.id); + await Event.create({ + name: "documents.archive", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + }, + ip: ctx.request.ip, + }); + + ctx.body = { + data: await presentDocument(document), + policies: presentPolicies(user, [document]), + }; + } +); + +router.post( + "documents.delete", + auth(), + validate(T.DocumentsDeleteSchema), + async (ctx: APIContext) => { + const { id, permanent } = ctx.input; + const { user } = ctx.state; + + if (permanent) { + const document = await Document.findByPk(id, { + userId: user.id, + paranoid: false, + }); + authorize(user, "permanentDelete", document); + + await Document.update( + { + parentDocumentId: null, + }, + { + where: { + parentDocumentId: document.id, + }, + paranoid: false, + } + ); + await documentPermanentDeleter([document]); + await Event.create({ + name: "documents.permanent_delete", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + }, + ip: ctx.request.ip, + }); + } else { + const document = await Document.findByPk(id, { + userId: user.id, + }); + + authorize(user, "delete", document); + + await document.delete(user.id); + await Event.create({ + name: "documents.delete", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + }, + ip: ctx.request.ip, + }); + } + + ctx.body = { + success: true, + }; + } +); + +router.post( + "documents.unpublish", + auth(), + validate(T.DocumentsUnpublishSchema), + async (ctx: APIContext) => { + const { id } = ctx.input; + const { user } = ctx.state; + + const document = await Document.findByPk(id, { + userId: user.id, + }); + authorize(user, "unpublish", document); + + const childDocumentIds = await document.getChildDocumentIds(); + if (childDocumentIds.length > 0) { + throw InvalidRequestError( + "Cannot unpublish document with child documents" + ); + } + + await document.unpublish(user.id); + await Event.create({ + name: "documents.unpublish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + title: document.title, + }, + ip: ctx.request.ip, + }); + + ctx.body = { + data: await presentDocument(document), + policies: presentPolicies(user, [document]), + }; + } +); + +router.post( + "documents.import", + auth(), + validate(T.DocumentsImportSchema), + async (ctx: APIContext) => { + const { publish, collectionId, parentDocumentId } = ctx.input; + + if (!ctx.is("multipart/form-data")) { + throw InvalidRequestError("Request type must be multipart/form-data"); + } + + const file = ctx.request.files + ? Object.values(ctx.request.files)[0] + : undefined; + if (!file) { + throw InvalidRequestError("Request must include a file parameter"); + } + + if (env.MAXIMUM_IMPORT_SIZE && file.size > env.MAXIMUM_IMPORT_SIZE) { + throw InvalidRequestError( + `The selected file was larger than the ${bytesToHumanReadable( + env.MAXIMUM_IMPORT_SIZE + )} maximum size` + ); + } + + const { user } = ctx.state; + + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findOne({ + where: { + id: collectionId, + teamId: user.teamId, + }, + }); + authorize(user, "publish", collection); + let parentDocument; + + if (parentDocumentId) { + parentDocument = await Document.findOne({ + where: { + id: parentDocumentId, + collectionId: collection.id, + }, + }); + authorize(user, "read", parentDocument, { + collection, + }); + } + + const content = await fs.readFile(file.path); + const document = await sequelize.transaction(async (transaction) => { + const { text, title } = await documentImporter({ + user, + fileName: file.name, + mimeType: file.type, + content, + ip: ctx.request.ip, + transaction, + }); + + return documentCreator({ + source: "import", + title, + text, + publish, + collectionId, + parentDocumentId, + user, + ip: ctx.request.ip, + transaction, + }); + }); + + document.collection = collection; + + return (ctx.body = { + data: await presentDocument(document), + policies: presentPolicies(user, [document]), + }); + } +); + +router.post( + "documents.create", + auth(), + validate(T.DocumentsCreateSchema), + async (ctx: APIContext) => { + const { + title = "", + text = "", + publish, + collectionId, + parentDocumentId, + templateId, + template, + } = ctx.input; + const editorVersion = ctx.headers["x-editor-version"] as string | undefined; + + const { user } = ctx.state; + + let collection; + + if (collectionId) { + collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findOne({ + where: { + id: collectionId, + teamId: user.teamId, + }, + }); + authorize(user, "publish", collection); + } + + let parentDocument; + + if (parentDocumentId) { + parentDocument = await Document.findOne({ + where: { + id: parentDocumentId, + collectionId: collection?.id, + }, + }); + authorize(user, "read", parentDocument, { + collection, + }); + } + + let templateDocument: Document | null | undefined; + + if (templateId) { + templateDocument = await Document.findByPk(templateId, { + userId: user.id, + }); + authorize(user, "read", templateDocument); + } + + const document = await sequelize.transaction(async (transaction) => { + return documentCreator({ + title, + text, + publish, + collectionId, + parentDocumentId, + templateDocument, + template, + user, + editorVersion, + ip: ctx.request.ip, + transaction, + }); + }); + + document.collection = collection; + + return (ctx.body = { + data: await presentDocument(document), + policies: presentPolicies(user, [document]), + }); + } +); + +export default router; diff --git a/server/routes/api/documents/index.ts b/server/routes/api/documents/index.ts new file mode 100644 index 000000000..72b1775b5 --- /dev/null +++ b/server/routes/api/documents/index.ts @@ -0,0 +1 @@ +export { default } from "./documents"; diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts new file mode 100644 index 000000000..67d1f5d03 --- /dev/null +++ b/server/routes/api/documents/schema.ts @@ -0,0 +1,279 @@ +import { isEmpty } from "lodash"; +import { z } from "zod"; + +const DocumentsSortParamsSchema = z.object({ + /** Specifies the attributes by which documents will be sorted in the list */ + sort: z + .string() + .refine((val) => ["createdAt", "updatedAt", "index"].includes(val)) + .default("updatedAt"), + + /** Specifies the sort order with respect to sort field */ + direction: z + .string() + .optional() + .transform((val) => (val !== "ASC" ? "DESC" : val)), +}); + +const DateFilterSchema = z.object({ + /** Date filter */ + dateFilter: z + .union([ + z.literal("day"), + z.literal("week"), + z.literal("month"), + z.literal("year"), + ]) + .optional(), +}); + +const SearchQuerySchema = z.object({ + /** Query for search */ + query: z.string().refine((v) => v.trim() !== ""), +}); + +const BaseIdSchema = z.object({ + /** Id of the entity */ + id: z.string().uuid(), +}); + +export const DocumentsListSchema = DocumentsSortParamsSchema.extend({ + /** Id of the user who created the doc */ + userId: z.string().uuid().optional(), + + /** Alias for userId - kept for backwards compatibility */ + user: z.string().uuid().optional(), + + /** Id of the collection to which the document belongs */ + collectionId: z.string().uuid().optional(), + + /** Alias for collectionId - kept for backwards compatibility */ + collection: z.string().uuid().optional(), + + /** Id of the backlinked document */ + backlinkDocumentId: z.string().uuid().optional(), + + /** Id of the parent document to which the document belongs */ + parentDocumentId: z.string().uuid().nullish(), + + /** Boolean which denotes whether the document is a template */ + template: z.boolean().optional(), +}) + // Maintains backwards compatibility + .transform((doc) => { + doc.collectionId = doc.collectionId || doc.collection; + doc.userId = doc.userId || doc.user; + delete doc.collection; + delete doc.user; + + return doc; + }); + +export type DocumentsListReq = z.infer; + +export const DocumentsArchivedSchema = DocumentsSortParamsSchema.extend({}); + +export type DocumentsArchivedReq = z.infer; + +export const DocumentsDeletedSchema = DocumentsSortParamsSchema.extend({}); + +export type DocumentsDeletedReq = z.infer; + +export const DocumentsViewedSchema = DocumentsSortParamsSchema.extend({}); + +export type DocumentsViewedReq = z.infer; + +export const DocumentsDraftsSchema = DocumentsSortParamsSchema.merge( + DateFilterSchema +).extend({ + /** Id of the collection to which the document belongs */ + collectionId: z.string().uuid().optional(), +}); + +export type DocumentsDraftsReq = z.infer; + +export const DocumentsInfoSchema = z + .object({ + /** Id of the document to be retrieved */ + id: z.string().optional(), + + /** Share Id, if available */ + shareId: z.string().uuid().optional(), + + /** Version of the API to be used */ + apiVersion: z.number().optional(), + }) + .refine((obj) => !(isEmpty(obj.id) && isEmpty(obj.shareId)), { + message: "one of id or shareId is required", + }); + +export type DocumentsInfoReq = z.infer; + +export const DocumentsExportSchema = z + .object({ + /** Id of the document to be exported */ + id: z.string().uuid().optional(), + + /** Share Id, if available */ + shareId: z.string().uuid().optional(), + }) + .refine((obj) => !(isEmpty(obj.id) && isEmpty(obj.shareId)), { + message: "one of id or shareId is required", + }); + +export type DocumentsExportReq = z.infer; + +export const DocumentsRestoreSchema = BaseIdSchema.extend({ + /** Id of the collection to which the document belongs */ + collectionId: z.string().uuid().optional(), + + /** Id of document revision */ + revisionId: z.string().uuid().optional(), +}); + +export type DocumentsRestoreReq = z.infer; + +export const DocumentsSearchTitlesSchema = SearchQuerySchema.extend({}); + +export type DocumentsSearchTitlesReq = z.infer< + typeof DocumentsSearchTitlesSchema +>; + +export const DocumentsSearchSchema = SearchQuerySchema.merge( + DateFilterSchema +).extend({ + /** Whether to include archived docs in results */ + includeArchived: z.boolean().optional(), + + /** Whether to include drafts in results */ + includeDrafts: z.boolean().optional(), + + /** Filter results for team based on the collection */ + collectionId: z.string().uuid().optional(), + + /** Filter results based on user */ + userId: z.string().uuid().optional(), + + /** Filter results for the team derived from shareId */ + shareId: z.string().uuid().optional(), + + /** Min words to be shown in the results snippets */ + snippetMinWords: z.number().default(20), + + /** Max words to be accomodated in the results snippets */ + snippetMaxWords: z.number().default(30), +}); + +export type DocumentsSearchReq = z.infer; + +export const DocumentsTemplatizeSchema = BaseIdSchema.extend({}); + +export type DocumentsTemplatizeReq = z.infer; + +export const DocumentsUpdateSchema = BaseIdSchema.extend({ + /** Doc title to be updated */ + title: z.string().optional(), + + /** Doc text to be updated */ + text: z.string().optional(), + + /** Boolean to denote if the doc should occupy full width */ + fullWidth: z.boolean().optional(), + + /** Boolean to denote if the doc should be published */ + publish: z.boolean().optional(), + + /** Revision to compare against document revision count */ + lastRevision: z.number().optional(), + + /** Doc template Id */ + templateId: z.string().uuid().nullish(), + + /** Doc collection Id */ + collectionId: z.string().uuid().optional(), + + /** Boolean to denote if text should be appended */ + append: z.boolean().optional(), +}).refine((obj) => !(obj.append && !obj.text), { + message: "text is required while appending", +}); + +export type DocumentsUpdateReq = z.infer; + +export const DocumentsMoveSchema = BaseIdSchema.extend({ + /** Id of collection to which the doc is supposed to be moved */ + collectionId: z.string().uuid(), + + /** Parent Id, in case if the doc is moved to a new parent */ + parentDocumentId: z.string().uuid().optional(), + + /** Helps evaluate the new index in collection structure upon move */ + index: z.number().positive().optional(), +}).refine((obj) => !(obj.parentDocumentId === obj.id), { + message: "infinite loop detected, cannot nest a document inside itself", +}); + +export type DocumentsMoveReq = z.infer; + +export const DocumentsArchiveSchema = BaseIdSchema.extend({}); + +export type DocumentsArchiveReq = z.infer; + +export const DocumentsDeleteSchema = BaseIdSchema.extend({ + /** Whether to permanently delete the doc as opposed to soft-delete */ + permanent: z.boolean().optional(), +}); + +export type DocumentsDeleteReq = z.infer; + +export const DocumentsUnpublishSchema = BaseIdSchema.extend({}); + +export type DocumentsUnpublishReq = z.infer; + +export const DocumentsImportSchema = z.object({ + /** Whether to publish the imported docs */ + publish: z.boolean().optional(), + + /** Import docs to this collection */ + collectionId: z.string().uuid(), + + /** Import under this parent doc */ + parentDocumentId: z.string().uuid().optional(), +}); + +export type DocumentsImportReq = z.infer; + +export const DocumentsCreateSchema = z + .object({ + /** Doc title */ + title: z.string().default(""), + + /** Doc text */ + text: z.string().default(""), + + /** Boolean to denote if the doc should be published */ + publish: z.boolean().optional(), + + /** Create Doc under this collection */ + collectionId: z.string().uuid().optional(), + + /** Create Doc under this parent */ + parentDocumentId: z.string().uuid().optional(), + + /** Create doc with this template */ + templateId: z.string().uuid().optional(), + + /** Whether to create a template doc */ + template: z.boolean().optional(), + }) + .refine((obj) => !(obj.parentDocumentId && !obj.collectionId), { + message: "collectionId is required to create a nested document", + }) + .refine((obj) => !(obj.template && !obj.collectionId), { + message: "collectionId is required to create a template document", + }) + .refine((obj) => !(obj.publish && !obj.collectionId), { + message: "collectionId is required to publish", + }); + +export type DocumentsCreateReq = z.infer; diff --git a/server/types.ts b/server/types.ts index 88f87ef9a..1c8be0b24 100644 --- a/server/types.ts +++ b/server/types.ts @@ -1,4 +1,5 @@ import { Context } from "koa"; +import { RouterContext } from "koa-router"; import { FileOperation, Team, User } from "./models"; export enum AuthenticationType { @@ -16,6 +17,11 @@ export type ContextWithState = Context & { state: AuthenticatedState; }; +export interface APIContext> + extends RouterContext { + input: ReqT; +} + type BaseEvent = { teamId: string; actorId: string; diff --git a/yarn.lock b/yarn.lock index 47761bfff..0684f19b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16045,3 +16045,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.19.1: + version "3.19.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.1.tgz#112f074a97b50bfc4772d4ad1576814bd8ac4473" + integrity sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==