// @flow import Router from 'koa-router'; import Sequelize from 'sequelize'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import documentMover from '../commands/documentMover'; import { presentDocument, presentCollection, presentRevision, presentPolicies, } from '../presenters'; import { Collection, Document, Event, Share, Star, View, Revision, Backlink, User, } from '../models'; import { InvalidRequestError } from '../errors'; import policy from '../policies'; import { sequelize } from '../sequelize'; const Op = Sequelize.Op; const { authorize, cannot } = policy; const router = new Router(); router.post('documents.list', auth(), pagination(), async ctx => { const { sort = 'updatedAt', backlinkDocumentId, parentDocumentId } = ctx.body; const collectionId = ctx.body.collection; const createdById = ctx.body.user; let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; // always filter by the current team const user = ctx.state.user; let where = { teamId: user.teamId }; // 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) { ctx.assertUuid(createdById, 'user must be a UUID'); where = { ...where, createdById }; } // if a specific collection is passed then we need to check auth to view it if (collectionId) { ctx.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); // otherwise, filter by all collections the user has access to } else { const collectionIds = await user.collectionIds(); where = { ...where, collectionId: collectionIds }; } if (parentDocumentId) { ctx.assertUuid(parentDocumentId, 'parentDocumentId must be a UUID'); where = { ...where, parentDocumentId }; } if (backlinkDocumentId) { ctx.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), }; } // add the users starred state to the response by default const starredScope = { method: ['withStarred', user.id] }; const collectionScope = { method: ['withCollection', user.id] }; const documents = await Document.scope( 'defaultScope', starredScope, 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.pinned', auth(), pagination(), async ctx => { const { collectionId, sort = 'updatedAt' } = ctx.body; let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; ctx.assertUuid(collectionId, 'collectionId is required'); const user = ctx.state.user; const collection = await Collection.scope({ method: ['withMembership', user.id], }).findByPk(collectionId); authorize(user, 'read', collection); const starredScope = { method: ['withStarred', user.id] }; const collectionScope = { method: ['withCollection', user.id] }; const documents = await Document.scope( 'defaultScope', starredScope, collectionScope ).findAll({ where: { teamId: user.teamId, collectionId, pinnedById: { [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.archived', auth(), pagination(), async ctx => { const { sort = 'updatedAt' } = ctx.body; let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; const collectionIds = await user.collectionIds(); const collectionScope = { method: ['withCollection', user.id] }; const documents = await Document.scope( 'defaultScope', collectionScope ).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(), pagination(), async ctx => { const { sort = 'deletedAt' } = ctx.body; let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; const collectionIds = await user.collectionIds(); const collectionScope = { method: ['withCollection', user.id] }; const documents = await Document.scope(collectionScope).findAll({ where: { teamId: user.teamId, collectionId: collectionIds, 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 { sort = 'updatedAt', direction } = ctx.body; if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; const collectionIds = await user.collectionIds(); const views = await View.findAll({ where: { userId: user.id }, order: [[sort, direction]], include: [ { model: Document, required: true, where: { collectionId: collectionIds, }, include: [ { model: Star, as: 'starred', where: { userId: user.id }, required: false, }, ], }, ], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, }); const documents = views.map(view => view.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.starred', auth(), pagination(), async ctx => { let { sort = 'updatedAt', direction } = ctx.body; if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; const collectionIds = await user.collectionIds(); const stars = await Star.findAll({ where: { userId: user.id, }, order: [[sort, direction]], include: [ { model: Document, where: { collectionId: collectionIds, }, include: [ { model: Collection, as: 'collection', }, { model: Star, as: 'starred', where: { userId: user.id, }, }, ], }, ], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, }); const documents = stars.map(star => star.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 { sort = 'updatedAt', direction } = ctx.body; if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; const collectionIds = await user.collectionIds(); const collectionScope = { method: ['withCollection', user.id] }; const documents = await Document.scope( 'defaultScope', collectionScope ).findAll({ where: { userId: user.id, collectionId: collectionIds, publishedAt: { [Op.eq]: 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.info', auth({ required: false }), async ctx => { const { id, shareId } = ctx.body; ctx.assertPresent(id || shareId, 'id or shareId is required'); const user = ctx.state.user; let document; if (shareId) { const share = await Share.findOne({ where: { revokedAt: { [Op.eq]: null }, id: shareId, }, include: [ { // unscoping here allows us to return unpublished documents model: Document.unscoped(), include: [ { model: User, as: 'createdBy', paranoid: false }, { model: User, as: 'updatedBy', paranoid: false }, ], required: true, as: 'document', }, ], }); if (!share || share.document.archivedAt) { throw new InvalidRequestError('Document could not be found for shareId'); } document = share.document; } else { document = await Document.findByPk( id, user ? { userId: user.id } : undefined ); authorize(user, 'read', document); } const isPublic = cannot(user, 'read', document); ctx.body = { data: await presentDocument(document, { isPublic }), policies: isPublic ? undefined : presentPolicies(user, [document]), }; }); router.post('documents.revision', auth(), async ctx => { let { id, revisionId } = ctx.body; ctx.assertPresent(id, 'id is required'); ctx.assertPresent(revisionId, 'revisionId is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'read', document); const revision = await Revision.findOne({ where: { id: revisionId, documentId: document.id, }, }); ctx.body = { pagination: ctx.state.pagination, data: await presentRevision(revision), }; }); router.post('documents.revisions', auth(), pagination(), async ctx => { let { id, sort = 'updatedAt', direction } = ctx.body; if (direction !== 'ASC') direction = 'DESC'; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'read', document); const revisions = await Revision.findAll({ where: { documentId: document.id }, order: [[sort, direction]], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, }); const data = await Promise.all( revisions.map(revision => presentRevision(revision)) ); ctx.body = { pagination: ctx.state.pagination, data, }; }); router.post('documents.restore', auth(), async ctx => { const { id, revisionId } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id, paranoid: false, }); 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 { ctx.assertPresent(revisionId, 'revisionId is required'); } ctx.body = { data: await presentDocument(document), policies: presentPolicies(user, [document]), }; }); router.post('documents.search', auth(), pagination(), async ctx => { const { query, includeArchived, includeDrafts, collectionId, userId, dateFilter, } = ctx.body; const { offset, limit } = ctx.state.pagination; const user = ctx.state.user; ctx.assertPresent(query, 'query is required'); if (collectionId) { ctx.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) { ctx.assertUuid(userId, 'userId must be a UUID'); collaboratorIds = [userId]; } if (dateFilter) { ctx.assertIn( dateFilter, ['day', 'week', 'month', 'year'], 'dateFilter must be one of day,week,month,year' ); } const results = await Document.searchForUser(user, query, { includeArchived: includeArchived === 'true', includeDrafts: includeDrafts === 'true', collaboratorIds, collectionId, dateFilter, offset, limit, }); 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 }; }) ); const policies = presentPolicies(user, documents); ctx.body = { pagination: ctx.state.pagination, data, policies, }; }); router.post('documents.pin', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'pin', document); document.pinnedById = user.id; await document.save(); await Event.create({ name: 'documents.pin', 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.unpin', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'unpin', document); document.pinnedById = null; await document.save(); await Event.create({ name: 'documents.unpin', 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.star', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'read', document); await Star.findOrCreate({ where: { documentId: document.id, userId: user.id }, }); await Event.create({ name: 'documents.star', documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, data: { title: document.title }, ip: ctx.request.ip, }); }); router.post('documents.unstar', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'read', document); await Star.destroy({ where: { documentId: document.id, userId: user.id }, }); await Event.create({ name: 'documents.unstar', documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, data: { title: document.title }, ip: ctx.request.ip, }); }); router.post('documents.create', auth(), async ctx => { const { title = '', text = '', publish, collectionId, parentDocumentId, index, } = ctx.body; const editorVersion = ctx.headers['x-editor-version']; ctx.assertUuid(collectionId, 'collectionId must be an uuid'); if (parentDocumentId) { ctx.assertUuid(parentDocumentId, 'parentDocumentId must be an uuid'); } if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)'); const user = ctx.state.user; authorize(user, 'create', Document); const collection = await Collection.scope({ method: ['withMembership', user.id], }).findOne({ where: { id: collectionId, teamId: user.teamId, }, }); authorize(user, 'publish', collection); let parentDocument; if (parentDocumentId && collection.type === 'atlas') { parentDocument = await Document.findOne({ where: { id: parentDocumentId, collectionId: collection.id, }, }); authorize(user, 'read', parentDocument, { collection }); } let document = await Document.create({ parentDocumentId, editorVersion, collectionId: collection.id, teamId: user.teamId, userId: user.id, lastModifiedById: user.id, createdById: user.id, title, text, }); await Event.create({ name: 'documents.create', documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, data: { title: document.title }, ip: ctx.request.ip, }); if (publish) { await document.publish(); await Event.create({ name: 'documents.publish', documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, data: { title: document.title }, ip: ctx.request.ip, }); } // reload to get all of the data needed to present (user, collection etc) // we need to specify publishedAt to bypass default scope that only returns // published documents document = await Document.findOne({ where: { id: document.id, publishedAt: document.publishedAt }, }); document.collection = collection; ctx.body = { data: await presentDocument(document), policies: presentPolicies(user, [document]), }; }); router.post('documents.update', auth(), async ctx => { const { id, title, text, publish, autosave, done, lastRevision, append, } = ctx.body; const editorVersion = ctx.headers['x-editor-version']; ctx.assertPresent(id, 'id is required'); ctx.assertPresent(title || text, 'title or text is required'); if (append) ctx.assertPresent(text, 'Text is required while appending'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'update', document); if (lastRevision && lastRevision !== document.revisionCount) { throw new InvalidRequestError('Document has changed since last revision'); } // Update document if (title) document.title = title; if (editorVersion) document.editorVersion = editorVersion; if (append) { document.text += text; } else if (text !== undefined) { document.text = text; } document.lastModifiedById = user.id; const { collection } = document; let transaction; try { transaction = await sequelize.transaction(); if (publish) { await document.publish({ transaction }); } else { await document.save({ autosave, transaction }); } await transaction.commit(); } catch (err) { if (transaction) { await transaction.rollback(); } throw err; } if (publish) { await Event.create({ name: 'documents.publish', documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, data: { title: document.title }, ip: ctx.request.ip, }); } else { await Event.create({ name: 'documents.update', documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, data: { autosave, done, title: document.title, }, ip: ctx.request.ip, }); } document.updatedBy = user; document.collection = collection; ctx.body = { data: await presentDocument(document), policies: presentPolicies(user, [document]), }; }); router.post('documents.move', auth(), async ctx => { const { id, collectionId, parentDocumentId, index } = ctx.body; ctx.assertUuid(id, 'id must be a uuid'); ctx.assertUuid(collectionId, 'collectionId must be a uuid'); if (parentDocumentId) { ctx.assertUuid(parentDocumentId, 'parentDocumentId must be a uuid'); } if (index) { ctx.assertPositiveInteger(index, 'index must be a positive integer'); } if (parentDocumentId === id) { throw new InvalidRequestError( 'Infinite loop detected, cannot nest a document inside itself' ); } const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'move', document); const { collection } = document; if (collection.type !== 'atlas' && parentDocumentId) { throw new InvalidRequestError( 'Document cannot be nested in this collection type' ); } if (parentDocumentId) { const parent = await Document.findByPk(parentDocumentId, { userId: user.id, }); authorize(user, 'update', parent); } const { documents, collections } = await documentMover({ user, document, collectionId, parentDocumentId, index, ip: ctx.request.ip, }); ctx.body = { data: { documents: await Promise.all( documents.map(document => presentDocument(document)) ), collections: await Promise.all( collections.map(collection => presentCollection(collection)) ), policies: presentPolicies(user, documents), }, }; }); router.post('documents.archive', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; 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 } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const document = await Document.findByPk(id, { userId: user.id }); authorize(user, 'delete', document); await document.delete(); 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, }; }); export default router;