diff --git a/app/components/DocumentHistory/DocumentHistory.js b/app/components/DocumentHistory/DocumentHistory.js index b22c27c3..0ab51f2d 100644 --- a/app/components/DocumentHistory/DocumentHistory.js +++ b/app/components/DocumentHistory/DocumentHistory.js @@ -42,7 +42,7 @@ class DocumentHistory extends React.Component { const results = await this.props.revisions.fetchPage({ limit, offset: this.offset, - id: this.props.match.params.documentSlug, + documentId: this.props.match.params.documentSlug, }); if ( diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index d8592a91..d7a025e4 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -267,7 +267,7 @@ class CollectionScene extends React.Component { collection.id )} fetch={documents.fetchAlphabetical} - options={{ collection: collection.id }} + options={{ collectionId: collection.id }} showPin /> @@ -278,7 +278,7 @@ class CollectionScene extends React.Component { collection.id )} fetch={documents.fetchLeastRecentlyUpdated} - options={{ collection: collection.id }} + options={{ collectionId: collection.id }} showPin /> @@ -289,7 +289,7 @@ class CollectionScene extends React.Component { collection.id )} fetch={documents.fetchRecentlyPublished} - options={{ collection: collection.id }} + options={{ collectionId: collection.id }} showPublished showPin /> @@ -300,7 +300,7 @@ class CollectionScene extends React.Component { collection.id )} fetch={documents.fetchRecentlyUpdated} - options={{ collection: collection.id }} + options={{ collectionId: collection.id }} showPin /> diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js index 0d98ac9e..f9025a60 100644 --- a/app/scenes/Document/components/DataLoader.js +++ b/app/scenes/Document/components/DataLoader.js @@ -97,11 +97,8 @@ class DataLoader extends React.Component { }; loadRevision = async () => { - const { documentSlug, revisionId } = this.props.match.params; - - this.revision = await this.props.revisions.fetch(documentSlug, { - revisionId, - }); + const { revisionId } = this.props.match.params; + this.revision = await this.props.revisions.fetch(revisionId); }; loadDocument = async () => { diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index a3aa105f..d08ae3bb 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -105,6 +105,6 @@ export default class CollectionsStore extends BaseStore { } export = () => { - return client.post('/collections.exportAll'); + return client.post('/collections.export_all'); }; } diff --git a/app/stores/RevisionsStore.js b/app/stores/RevisionsStore.js index 63a51d06..527b93b8 100644 --- a/app/stores/RevisionsStore.js +++ b/app/stores/RevisionsStore.js @@ -20,21 +20,16 @@ export default class RevisionsStore extends BaseStore { } @action - fetch = async ( - documentId: string, - options?: FetchOptions - ): Promise => { + fetch = async (id: string, options?: FetchOptions): Promise => { this.isFetching = true; - const id = options && options.revisionId; - if (!id) throw new Error('revisionId is required'); + invariant(id, 'Id is required'); try { const rev = this.data.get(id); if (rev) return rev; - const res = await client.post('/documents.revision', { - id: documentId, - revisionId: id, + const res = await client.post('/revisions.info', { + id, }); invariant(res && res.data, 'Revision not available'); this.add(res.data); @@ -54,7 +49,7 @@ export default class RevisionsStore extends BaseStore { this.isFetching = true; try { - const res = await client.post('/documents.revisions', options); + const res = await client.post('/revisions.list', options); invariant(res && res.data, 'Document revisions not available'); runInAction('RevisionsStore#fetchPage', () => { res.data.forEach(revision => this.add(revision)); diff --git a/app/utils/uploadFile.js b/app/utils/uploadFile.js index 6bed5b34..46bbb288 100644 --- a/app/utils/uploadFile.js +++ b/app/utils/uploadFile.js @@ -13,7 +13,7 @@ export const uploadFile = async ( options?: Options = { name: '' } ) => { const name = file instanceof File ? file.name : options.name; - const response = await client.post('/users.s3Upload', { + const response = await client.post('/attachments.create', { public: options.public, documentId: options.documentId, contentType: file.type, @@ -24,7 +24,7 @@ export const uploadFile = async ( invariant(response, 'Response should be available'); const data = response.data; - const asset = data.asset; + const attachment = data.attachment; const formData = new FormData(); for (const key in data.form) { @@ -44,7 +44,7 @@ export const uploadFile = async ( body: formData, }); - return asset; + return attachment; }; export const dataUrlToBlob = (dataURL: string) => { diff --git a/server/api/__snapshots__/collections.test.js.snap b/server/api/__snapshots__/collections.test.js.snap index 01338028..95fec2a7 100644 --- a/server/api/__snapshots__/collections.test.js.snap +++ b/server/api/__snapshots__/collections.test.js.snap @@ -43,7 +43,7 @@ Object { } `; -exports[`#collections.exportAll should require authentication 1`] = ` +exports[`#collections.export_all should require authentication 1`] = ` Object { "error": "authentication_required", "message": "Authentication required", diff --git a/server/api/attachments.js b/server/api/attachments.js index 06525da1..8134effa 100644 --- a/server/api/attachments.js +++ b/server/api/attachments.js @@ -1,13 +1,92 @@ // @flow import Router from 'koa-router'; +import uuid from 'uuid'; +import format from 'date-fns/format'; +import { Attachment, Document, Event } from '../models'; +import { + makePolicy, + getSignature, + publicS3Endpoint, + makeCredential, + getSignedImageUrl, +} from '../utils/s3'; import auth from '../middlewares/authentication'; -import { Attachment, Document } from '../models'; -import { getSignedImageUrl } from '../utils/s3'; import { NotFoundError } from '../errors'; import policy from '../policies'; const { authorize } = policy; const router = new Router(); +const AWS_S3_ACL = process.env.AWS_S3_ACL || 'private'; + +router.post('attachments.create', auth(), async ctx => { + let { name, documentId, contentType, size } = ctx.body; + + ctx.assertPresent(name, 'name is required'); + ctx.assertPresent(contentType, 'contentType is required'); + ctx.assertPresent(size, 'size is required'); + + const { user } = ctx.state; + const s3Key = uuid.v4(); + const key = `uploads/${user.id}/${s3Key}/${name}`; + const acl = + ctx.body.public === undefined + ? AWS_S3_ACL + : ctx.body.public ? 'public-read' : 'private'; + const credential = makeCredential(); + const longDate = format(new Date(), 'YYYYMMDDTHHmmss\\Z'); + const policy = makePolicy(credential, longDate, acl); + const endpoint = publicS3Endpoint(); + const url = `${endpoint}/${key}`; + + if (documentId) { + const document = await Document.findByPk(documentId, { userId: user.id }); + authorize(user, 'update', document); + } + + const attachment = await Attachment.create({ + key, + acl, + size, + url, + contentType, + documentId, + teamId: user.teamId, + userId: user.id, + }); + + await Event.create({ + name: 'attachments.create', + data: { name }, + teamId: user.teamId, + userId: user.id, + ip: ctx.request.ip, + }); + + ctx.body = { + data: { + maxUploadSize: process.env.AWS_S3_UPLOAD_MAX_SIZE, + uploadUrl: endpoint, + form: { + 'Cache-Control': 'max-age=31557600', + 'Content-Type': contentType, + acl, + key, + policy, + 'x-amz-algorithm': 'AWS4-HMAC-SHA256', + 'x-amz-credential': credential, + 'x-amz-date': longDate, + 'x-amz-signature': getSignature(policy), + }, + attachment: { + documentId, + contentType, + name, + url: attachment.redirectUrl, + size, + }, + }, + }; +}); router.post('attachments.redirect', auth(), async ctx => { const { id } = ctx.body; diff --git a/server/api/collections.js b/server/api/collections.js index 4cc584d0..cb2b1472 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -410,7 +410,7 @@ router.post('collections.export', auth(), async ctx => { ctx.body = fs.createReadStream(filePath); }); -router.post('collections.exportAll', auth(), async ctx => { +router.post('collections.export_all', auth(), async ctx => { const { download = false } = ctx.body; const user = ctx.state.user; diff --git a/server/api/collections.test.js b/server/api/collections.test.js index e9e5ab01..fdc959ab 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -176,9 +176,9 @@ describe('#collections.export', async () => { }); }); -describe('#collections.exportAll', async () => { +describe('#collections.export_all', async () => { it('should require authentication', async () => { - const res = await server.post('/api/collections.exportAll'); + const res = await server.post('/api/collections.export_all'); const body = await res.json(); expect(res.status).toEqual(401); @@ -187,7 +187,7 @@ describe('#collections.exportAll', async () => { it('should require authorization', async () => { const user = await buildUser(); - const res = await server.post('/api/collections.exportAll', { + const res = await server.post('/api/collections.export_all', { body: { token: user.getJwtToken() }, }); expect(res.status).toEqual(403); @@ -195,7 +195,7 @@ describe('#collections.exportAll', async () => { it('should return success', async () => { const { admin } = await seed(); - const res = await server.post('/api/collections.exportAll', { + const res = await server.post('/api/collections.export_all', { body: { token: admin.getJwtToken() }, }); @@ -204,7 +204,7 @@ describe('#collections.exportAll', async () => { it('should allow downloading directly', async () => { const { admin } = await seed(); - const res = await server.post('/api/collections.exportAll', { + const res = await server.post('/api/collections.export_all', { body: { token: admin.getJwtToken(), download: true }, }); diff --git a/server/api/documents.js b/server/api/documents.js index 034ce0b3..22937254 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -7,7 +7,6 @@ import documentMover from '../commands/documentMover'; import { presentDocument, presentCollection, - presentRevision, presentPolicies, } from '../presenters'; import { @@ -31,8 +30,10 @@ 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; + + // collection and user are here for backwards compatablity + const collectionId = ctx.body.collectionId || ctx.body.collection; + const createdById = ctx.body.userId || ctx.body.user; let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; @@ -411,54 +412,6 @@ router.post('documents.info', auth({ required: false }), async ctx => { }; }); -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'); @@ -667,6 +620,10 @@ router.post('documents.star', auth(), async ctx => { data: { title: document.title }, ip: ctx.request.ip, }); + + ctx.body = { + success: true, + }; }); router.post('documents.unstar', auth(), async ctx => { @@ -690,6 +647,10 @@ router.post('documents.unstar', auth(), async ctx => { data: { title: document.title }, ip: ctx.request.ip, }); + + ctx.body = { + success: true, + }; }); router.post('documents.create', auth(), async ctx => { @@ -925,8 +886,8 @@ router.post('documents.move', auth(), async ctx => { collections: await Promise.all( collections.map(collection => presentCollection(collection)) ), - policies: presentPolicies(user, documents), }, + policies: presentPolicies(user, documents), }; }); diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 8d653c9c..364e90ca 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -436,48 +436,6 @@ describe('#documents.drafts', async () => { }); }); -describe('#documents.revision', async () => { - it("should return a document's revisions", async () => { - const { user, document } = await seed(); - const res = await server.post('/api/documents.revisions', { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - const body = await res.json(); - - expect(res.status).toEqual(200); - expect(body.data.length).toEqual(1); - expect(body.data[0].id).not.toEqual(document.id); - expect(body.data[0].title).toEqual(document.title); - }); - - it('should not return revisions for document in collection not a member of', async () => { - const { user, document, collection } = await seed(); - collection.private = true; - await collection.save(); - - const res = await server.post('/api/documents.revisions', { - body: { token: user.getJwtToken(), id: document.id }, - }); - - expect(res.status).toEqual(403); - }); - - it('should require authorization', async () => { - const { document } = await seed(); - const user = await buildUser(); - const res = await server.post('/api/documents.revisions', { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - expect(res.status).toEqual(403); - }); -}); - describe('#documents.search', async () => { it('should return results', async () => { const { user } = await seed(); diff --git a/server/api/groups.js b/server/api/groups.js index d3217e98..4aceb55a 100644 --- a/server/api/groups.js +++ b/server/api/groups.js @@ -18,13 +18,16 @@ const { authorize } = policy; const router = new Router(); router.post('groups.list', auth(), pagination(), async ctx => { + const { sort = 'updatedAt' } = ctx.body; + let direction = ctx.body.direction; + if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; let groups = await Group.findAll({ where: { teamId: user.teamId, }, - order: [['updatedAt', 'DESC']], + order: [[sort, direction]], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, }); diff --git a/server/api/index.js b/server/api/index.js index 8380694a..743aeba2 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -8,6 +8,7 @@ import events from './events'; import users from './users'; import collections from './collections'; import documents from './documents'; +import revisions from './revisions'; import views from './views'; import hooks from './hooks'; import apiKeys from './apiKeys'; @@ -45,6 +46,7 @@ router.use('/', events.routes()); router.use('/', users.routes()); router.use('/', collections.routes()); router.use('/', documents.routes()); +router.use('/', revisions.routes()); router.use('/', views.routes()); router.use('/', hooks.routes()); router.use('/', apiKeys.routes()); diff --git a/server/api/revisions.js b/server/api/revisions.js new file mode 100644 index 00000000..af305c55 --- /dev/null +++ b/server/api/revisions.js @@ -0,0 +1,60 @@ +// @flow +import Router from 'koa-router'; +import auth from '../middlewares/authentication'; +import pagination from './middlewares/pagination'; +import { presentRevision } from '../presenters'; +import { Document, Revision } from '../models'; +import { NotFoundError } from '../errors'; +import policy from '../policies'; + +const { authorize } = policy; +const router = new Router(); + +router.post('revisions.info', auth(), async ctx => { + let { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const revision = await Revision.findByPk(id); + if (!revision) { + throw new NotFoundError(); + } + + const document = await Document.findByPk(revision.documentId, { + userId: user.id, + }); + authorize(user, 'read', document); + + ctx.body = { + pagination: ctx.state.pagination, + data: await presentRevision(revision), + }; +}); + +router.post('revisions.list', auth(), pagination(), async ctx => { + let { documentId, sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + ctx.assertPresent(documentId, 'documentId is required'); + + const user = ctx.state.user; + const document = await Document.findByPk(documentId, { 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, + }; +}); + +export default router; diff --git a/server/api/revisions.test.js b/server/api/revisions.test.js new file mode 100644 index 00000000..0a60bd1b --- /dev/null +++ b/server/api/revisions.test.js @@ -0,0 +1,92 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from 'fetch-test-server'; +import app from '../app'; +import { flushdb, seed } from '../test/support'; +import { buildDocument, buildUser } from '../test/factories'; +import Revision from '../models/Revision'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(server.close); + +describe('#revisions.info', async () => { + it('should return a document revision', async () => { + const { user, document } = await seed(); + const revision = await Revision.findOne({ + where: { + documentId: document.id, + }, + }); + const res = await server.post('/api/revisions.info', { + body: { + token: user.getJwtToken(), + id: revision.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).not.toEqual(document.id); + expect(body.data.title).toEqual(document.title); + }); + + it('should require authorization', async () => { + const document = await buildDocument(); + const revision = await Revision.findOne({ + where: { + documentId: document.id, + }, + }); + const user = await buildUser(); + const res = await server.post('/api/revisions.info', { + body: { + token: user.getJwtToken(), + id: revision.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); + +describe('#revisions.list', async () => { + it("should return a document's revisions", async () => { + const { user, document } = await seed(); + const res = await server.post('/api/revisions.list', { + body: { + token: user.getJwtToken(), + documentId: document.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).not.toEqual(document.id); + expect(body.data[0].title).toEqual(document.title); + }); + + it('should not return revisions for document in collection not a member of', async () => { + const { user, document, collection } = await seed(); + collection.private = true; + await collection.save(); + + const res = await server.post('/api/revisions.list', { + body: { token: user.getJwtToken(), documentId: document.id }, + }); + + expect(res.status).toEqual(403); + }); + + it('should require authorization', async () => { + const document = await buildDocument(); + const user = await buildUser(); + const res = await server.post('/api/revisions.list', { + body: { + token: user.getJwtToken(), + documentId: document.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); diff --git a/server/api/shares.js b/server/api/shares.js index abb4f114..53dd6a31 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -11,6 +11,19 @@ const Op = Sequelize.Op; const { authorize } = policy; const router = new Router(); +router.post('shares.info', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertUuid(id, 'id is required'); + + const user = ctx.state.user; + const share = await Share.findByPk(id); + authorize(user, 'read', share); + + ctx.body = { + data: presentShare(share), + }; +}); + router.post('shares.list', auth(), pagination(), async ctx => { let { sort = 'updatedAt', direction } = ctx.body; if (direction !== 'ASC') direction = 'DESC'; diff --git a/server/api/users.js b/server/api/users.js index f6c1607a..bf69320e 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -1,27 +1,20 @@ // @flow -import uuid from 'uuid'; import Router from 'koa-router'; -import format from 'date-fns/format'; import { Op } from '../sequelize'; -import { - makePolicy, - getSignature, - publicS3Endpoint, - makeCredential, -} from '../utils/s3'; -import { Document, Attachment, Event, User, Team } from '../models'; +import { Event, User, Team } from '../models'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import userInviter from '../commands/userInviter'; import { presentUser } from '../presenters'; import policy from '../policies'; -const AWS_S3_ACL = process.env.AWS_S3_ACL || 'private'; const { authorize } = policy; const router = new Router(); router.post('users.list', auth(), pagination(), async ctx => { - const { query, includeSuspended = false } = ctx.body; + const { sort = 'createdAt', query, includeSuspended = false } = ctx.body; + let direction = ctx.body.direction; + if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; let where = { @@ -48,7 +41,7 @@ router.post('users.list', auth(), pagination(), async ctx => { const users = await User.findAll({ where, - order: [['createdAt', 'DESC']], + order: [[sort, direction]], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, }); @@ -81,79 +74,6 @@ router.post('users.update', auth(), async ctx => { }; }); -router.post('users.s3Upload', auth(), async ctx => { - let { name, filename, documentId, contentType, kind, size } = ctx.body; - - // backwards compatability - name = name || filename; - contentType = contentType || kind; - - ctx.assertPresent(name, 'name is required'); - ctx.assertPresent(contentType, 'contentType is required'); - ctx.assertPresent(size, 'size is required'); - - const { user } = ctx.state; - const s3Key = uuid.v4(); - const key = `uploads/${user.id}/${s3Key}/${name}`; - const acl = - ctx.body.public === undefined - ? AWS_S3_ACL - : ctx.body.public ? 'public-read' : 'private'; - const credential = makeCredential(); - const longDate = format(new Date(), 'YYYYMMDDTHHmmss\\Z'); - const policy = makePolicy(credential, longDate, acl); - const endpoint = publicS3Endpoint(); - const url = `${endpoint}/${key}`; - - if (documentId) { - const document = await Document.findByPk(documentId, { userId: user.id }); - authorize(user, 'update', document); - } - - const attachment = await Attachment.create({ - key, - acl, - size, - url, - contentType, - documentId, - teamId: user.teamId, - userId: user.id, - }); - - await Event.create({ - name: 'user.s3Upload', - data: { name }, - teamId: user.teamId, - userId: user.id, - ip: ctx.request.ip, - }); - - ctx.body = { - data: { - maxUploadSize: process.env.AWS_S3_UPLOAD_MAX_SIZE, - uploadUrl: endpoint, - form: { - 'Cache-Control': 'max-age=31557600', - 'Content-Type': contentType, - acl, - key, - policy, - 'x-amz-algorithm': 'AWS4-HMAC-SHA256', - 'x-amz-credential': credential, - 'x-amz-date': longDate, - 'x-amz-signature': getSignature(policy), - }, - asset: { - contentType, - name, - url: attachment.redirectUrl, - size, - }, - }, - }; -}); - // Admin specific router.post('users.promote', auth(), async ctx => { diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js index a75fcfd0..b4e451c7 100644 --- a/server/pages/developers/Api.js +++ b/server/pages/developers/Api.js @@ -49,7 +49,7 @@ export default function Api() { - + You can upload small files and images as part of your documents. All files are stored using Amazon S3. Instead of uploading files @@ -84,7 +84,7 @@ export default function Api() { Promote a user to be a team admin. This endpoint is only available for admin users. - + @@ -95,7 +95,7 @@ export default function Api() { is always required. This endpoint is only available for admin users. - + @@ -105,7 +105,7 @@ export default function Api() { Admin can suspend users to reduce the number of accounts on their billing plan or prevent them from accessing documention. - + - + - + Returns a zip file of all the collections or creates an async job to send a zip file via email to the authenticated user. If @@ -527,20 +530,6 @@ export default function Api() { - - - Get a document with its ID or URL identifier from user’s - collections. - - - - - - - + Return a specific revision of a document. - - + - + Return revisions for a document. Upon each edit, a new revision is stored. @@ -688,7 +666,7 @@ export default function Api() { This method allows you to create a new group to organize people in the team. - + - +