chore: API Consistency (#1304)
* chore: Addressing API inconsistencies * lint * add: Missing sort to groups.list fix: Documention issues * test: fix * feat: Add missing shares.info endpoint * feat: Add sorting to users.list endpoint * fix: Incorrect pagination parameters listed on user endpoints * users.s3Upload -> attachments.create * chore: exportAll -> export_all
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
60
server/api/revisions.js
Normal file
60
server/api/revisions.js
Normal file
@@ -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;
|
||||
92
server/api/revisions.test.js
Normal file
92
server/api/revisions.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user