chore: Move all routes under routes directory (#2513)

closes #2504
This commit is contained in:
Tom Moor
2021-08-29 13:25:06 -07:00
committed by GitHub
parent 9a875920ac
commit 3dfd336f59
58 changed files with 233 additions and 224 deletions

View File

@@ -0,0 +1,141 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#collections.add_group should require group in team 1`] = `
Object {
"error": "authorization_error",
"message": "Authorization error",
"ok": false,
}
`;
exports[`#collections.add_user should require user in team 1`] = `
Object {
"error": "authorization_error",
"message": "Authorization error",
"ok": false,
}
`;
exports[`#collections.create should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.delete should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.export should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.export_all should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.group_memberships should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.import should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.info should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.memberships should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.move should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.remove_group should require group in team 1`] = `
Object {
"error": "authorization_error",
"message": "Authorization error",
"ok": false,
}
`;
exports[`#collections.remove_user should require user in team 1`] = `
Object {
"error": "authorization_error",
"message": "Authorization error",
"ok": false,
}
`;
exports[`#collections.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.users should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#documents.create should error with invalid parentDocument 1`] = `
Object {
"error": "authorization_error",
"message": "Authorization error",
"ok": false,
}
`;
exports[`#documents.delete should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.pin should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.restore should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.search should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.star should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.starred should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.unpin should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.unstar should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.update should fail if document lastRevision does not match 1`] = `
Object {
"error": "invalid_request",
"message": "Document has changed since last revision",
"ok": false,
"status": 400,
}
`;
exports[`#documents.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.update should require text while appending 1`] = `
Object {
"error": "param_required",
"message": "Text is required while appending",
"ok": false,
"status": 400,
}
`;
exports[`#documents.viewed should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#events.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1,88 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#groups.add_user should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#groups.add_user should require user in team 1`] = `
Object {
"error": "authorization_error",
"message": "Authorization error",
"ok": false,
}
`;
exports[`#groups.delete should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#groups.info should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#groups.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#groups.memberships should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#groups.remove_user should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#groups.remove_user should require user in team 1`] = `
Object {
"error": "authorization_error",
"message": "Authorization error",
"ok": false,
}
`;
exports[`#groups.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#groups.update when user is admin fails with validation error when name already taken 1`] = `
Object {
"error": "",
"message": "The name of this group is already in use (isUniqueNameInTeam)",
"ok": false,
}
`;

View File

@@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#shares.create should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#shares.info should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#shares.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#shares.revoke should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#shares.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1,277 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#users.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.activate should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#users.delete should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#users.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.demote should demote an admin to member 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.demote should demote an admin to viewer 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": true,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.demote should not demote admins if only one available 1`] = `
Object {
"error": "validation_error",
"message": "At least one admin is required",
"ok": false,
"status": 400,
}
`;
exports[`#users.demote should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#users.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": true,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": false,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.promote should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#users.suspend should not allow suspending the user themselves 1`] = `
Object {
"error": "validation_error",
"message": "Unable to suspend the current user",
"ok": false,
"status": 400,
}
`;
exports[`#users.suspend should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#users.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": true,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": false,
"promote": false,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#views.create should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#views.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1,80 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { ApiKey, Event } from "../../models";
import policy from "../../policies";
import { presentApiKey } from "../../presenters";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
const router = new Router();
router.post("apiKeys.create", auth(), async (ctx) => {
const { name } = ctx.body;
ctx.assertPresent(name, "name is required");
const user = ctx.state.user;
authorize(user, "createApiKey", user.team);
const key = await ApiKey.create({
name,
userId: user.id,
});
await Event.create({
name: "api_keys.create",
modelId: key.id,
teamId: user.teamId,
actorId: user.id,
data: { name },
ip: ctx.request.ip,
});
ctx.body = {
data: presentApiKey(key),
};
});
router.post("apiKeys.list", auth(), pagination(), async (ctx) => {
const user = ctx.state.user;
const keys = await ApiKey.findAll({
where: {
userId: user.id,
},
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: keys.map(presentApiKey),
};
});
router.post("apiKeys.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const key = await ApiKey.findByPk(id);
authorize(user, "delete", key);
await key.destroy();
await Event.create({
name: "api_keys.delete",
modelId: key.id,
teamId: user.teamId,
actorId: user.id,
data: { name: key.name },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -0,0 +1,156 @@
// @flow
import { format } from "date-fns";
import Router from "koa-router";
import { v4 as uuidv4 } from "uuid";
import { NotFoundError } from "../../errors";
import auth from "../../middlewares/authentication";
import { Attachment, Document, Event } from "../../models";
import policy from "../../policies";
import {
makePolicy,
getSignature,
publicS3Endpoint,
makeCredential,
getSignedUrl,
} from "../../utils/s3";
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;
authorize(user, "createAttachment", user.team);
const s3Key = uuidv4();
const acl =
ctx.body.public === undefined
? AWS_S3_ACL
: ctx.body.public
? "public-read"
: "private";
const bucket = acl === "public-read" ? "public" : "uploads";
const key = `${bucket}/${user.id}/${s3Key}/${name}`;
const credential = makeCredential();
const longDate = format(new Date(), "yyyyMMdd'T'HHmmss'Z'");
const policy = makePolicy(credential, longDate, acl, contentType);
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,
id: attachment.id,
url: attachment.redirectUrl,
size,
},
},
};
});
router.post("attachments.delete", auth(), async (ctx) => {
let { id } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
const attachment = await Attachment.findByPk(id);
if (!attachment) {
throw new NotFoundError();
}
if (attachment.documentId) {
const document = await Document.findByPk(attachment.documentId, {
userId: user.id,
});
authorize(user, "update", document);
}
authorize(user, "delete", attachment);
await attachment.destroy();
await Event.create({
name: "attachments.delete",
teamId: user.teamId,
userId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
router.post("attachments.redirect", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
const attachment = await Attachment.findByPk(id);
if (!attachment) {
throw new NotFoundError();
}
if (attachment.isPrivate) {
if (attachment.documentId) {
const document = await Document.findByPk(attachment.documentId, {
userId: user.id,
paranoid: false,
});
authorize(user, "read", document);
}
const accessUrl = await getSignedUrl(attachment.key);
ctx.redirect(accessUrl);
} else {
ctx.redirect(attachment.url);
}
});
export default router;

View File

@@ -0,0 +1,232 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { Attachment } from "../../models";
import webService from "../../services/web";
import {
buildUser,
buildAdmin,
buildCollection,
buildAttachment,
buildDocument,
} from "../../test/factories";
import { flushdb } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#attachments.delete", () => {
it("should require authentication", async () => {
const res = await server.post("/api/attachments.delete");
expect(res.status).toEqual(401);
});
it("should allow deleting an attachment belonging to a document user has access to", async () => {
const user = await buildUser();
const attachment = await buildAttachment({
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
});
it("should allow deleting an attachment without a document created by user", async () => {
const user = await buildUser();
const attachment = await buildAttachment({
teamId: user.teamId,
userId: user.id,
});
attachment.documentId = null;
await attachment.save();
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
});
it("should allow deleting an attachment without a document if admin", async () => {
const user = await buildAdmin();
const attachment = await buildAttachment({
teamId: user.teamId,
});
attachment.documentId = null;
await attachment.save();
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
});
it("should not allow deleting an attachment in another team", async () => {
const user = await buildAdmin();
const attachment = await buildAttachment();
attachment.documentId = null;
await attachment.save();
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(403);
});
it("should not allow deleting an attachment without a document", async () => {
const user = await buildUser();
const attachment = await buildAttachment({
teamId: user.teamId,
});
attachment.documentId = null;
await attachment.save();
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(403);
});
it("should not allow deleting an attachment belonging to a document user does not have access to", async () => {
const user = await buildUser();
const collection = await buildCollection({
permission: null,
});
const document = await buildDocument({
teamId: collection.teamId,
userId: collection.userId,
collectionId: collection.id,
});
const attachment = await buildAttachment({
teamId: document.teamId,
userId: document.userId,
documentId: document.id,
acl: "private",
});
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(403);
});
});
describe("#attachments.redirect", () => {
it("should require authentication", async () => {
const res = await server.post("/api/attachments.redirect");
expect(res.status).toEqual(401);
});
it("should return a redirect for an attachment belonging to a document user has access to", async () => {
const user = await buildUser();
const attachment = await buildAttachment({
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
redirect: "manual",
});
expect(res.status).toEqual(302);
});
it("should return a redirect for an attachment belonging to a trashed document user has access to", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
deletedAt: new Date(),
});
const attachment = await buildAttachment({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
redirect: "manual",
});
expect(res.status).toEqual(302);
});
it("should always return a redirect for a public attachment", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
permission: null,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const attachment = await buildAttachment({
teamId: user.teamId,
userId: user.id,
documentId: document.id,
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
redirect: "manual",
});
expect(res.status).toEqual(302);
});
it("should not return a redirect for a private attachment belonging to a document user does not have access to", async () => {
const user = await buildUser();
const collection = await buildCollection({
permission: null,
});
const document = await buildDocument({
teamId: collection.teamId,
userId: collection.userId,
collectionId: collection.id,
});
const attachment = await buildAttachment({
teamId: document.teamId,
userId: document.userId,
documentId: document.id,
acl: "private",
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(403);
});
});

117
server/routes/api/auth.js Normal file
View File

@@ -0,0 +1,117 @@
// @flow
import Router from "koa-router";
import { find } from "lodash";
import { parseDomain, isCustomSubdomain } from "../../../shared/utils/domains";
import auth from "../../middlewares/authentication";
import { Team } from "../../models";
import { presentUser, presentTeam, presentPolicies } from "../../presenters";
import { isCustomDomain } from "../../utils/domains";
import providers from "../auth/providers";
const router = new Router();
function filterProviders(team) {
return providers
.sort((provider) => (provider.id === "email" ? 1 : -1))
.filter((provider) => {
// guest sign-in is an exception as it does not have an authentication
// provider using passport, instead it exists as a boolean option on the team
if (provider.id === "email") {
return team && team.guestSignin;
}
return (
!team ||
find(team.authenticationProviders, { name: provider.id, enabled: true })
);
})
.map((provider) => ({
id: provider.id,
name: provider.name,
authUrl: provider.authUrl,
}));
}
router.post("auth.config", async (ctx) => {
// If self hosted AND there is only one team then that team becomes the
// brand for the knowledge base and it's guest signin option is used for the
// root login page.
if (process.env.DEPLOYMENT !== "hosted") {
const teams = await Team.scope("withAuthenticationProviders").findAll();
if (teams.length === 1) {
const team = teams[0];
ctx.body = {
data: {
name: team.name,
providers: filterProviders(team),
},
};
return;
}
}
if (isCustomDomain(ctx.request.hostname)) {
const team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: ctx.request.hostname },
});
if (team) {
ctx.body = {
data: {
name: team.name,
hostname: ctx.request.hostname,
providers: filterProviders(team),
},
};
return;
}
}
// If subdomain signin page then we return minimal team details to allow
// for a custom screen showing only relevant signin options for that team.
if (
process.env.SUBDOMAINS_ENABLED === "true" &&
isCustomSubdomain(ctx.request.hostname) &&
!isCustomDomain(ctx.request.hostname)
) {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
const team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain },
});
if (team) {
ctx.body = {
data: {
name: team.name,
hostname: ctx.request.hostname,
providers: filterProviders(team),
},
};
return;
}
}
// Otherwise, we're requesting from the standard root signin page
ctx.body = {
data: {
providers: filterProviders(),
},
};
});
router.post("auth.info", auth(), async (ctx) => {
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
ctx.body = {
data: {
user: presentUser(user, { includeDetails: true }),
team: presentTeam(team),
},
policies: presentPolicies(user, [team]),
};
});
export default router;

View File

@@ -0,0 +1,189 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { buildUser, buildTeam } from "../../test/factories";
import { flushdb } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#auth.info", () => {
it("should return current authentication", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/auth.info", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.user.name).toBe(user.name);
expect(body.data.team.name).toBe(team.name);
});
it("should require the team to not be deleted", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await team.destroy();
const res = await server.post("/api/auth.info", {
body: { token: user.getJwtToken() },
});
expect(res.status).toEqual(401);
});
it("should require authentication", async () => {
const res = await server.post("/api/auth.info");
expect(res.status).toEqual(401);
});
});
describe("#auth.config", () => {
it("should return available SSO providers", async () => {
const res = await server.post("/api/auth.config");
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.providers.length).toBe(2);
expect(body.data.providers[0].name).toBe("Slack");
expect(body.data.providers[1].name).toBe("Google");
});
it("should return available providers for team subdomain", async () => {
process.env.URL = "http://localoutline.com";
await buildTeam({
guestSignin: false,
subdomain: "example",
authenticationProviders: [
{
name: "slack",
providerId: "123",
},
],
});
const res = await server.post("/api/auth.config", {
headers: { host: `example.localoutline.com` },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.providers.length).toBe(1);
expect(body.data.providers[0].name).toBe("Slack");
});
it("should return available providers for team custom domain", async () => {
await buildTeam({
guestSignin: false,
domain: "docs.mycompany.com",
authenticationProviders: [
{
name: "slack",
providerId: "123",
},
],
});
const res = await server.post("/api/auth.config", {
headers: { host: "docs.mycompany.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.providers.length).toBe(1);
expect(body.data.providers[0].name).toBe("Slack");
});
it("should return email provider for team when guest signin enabled", async () => {
process.env.URL = "http://localoutline.com";
await buildTeam({
guestSignin: true,
subdomain: "example",
authenticationProviders: [
{
name: "slack",
providerId: "123",
},
],
});
const res = await server.post("/api/auth.config", {
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.providers.length).toBe(2);
expect(body.data.providers[0].name).toBe("Slack");
expect(body.data.providers[1].name).toBe("Email");
});
it("should not return provider when disabled", async () => {
process.env.URL = "http://localoutline.com";
await buildTeam({
guestSignin: false,
subdomain: "example",
authenticationProviders: [
{
name: "slack",
providerId: "123",
enabled: false,
},
],
});
const res = await server.post("/api/auth.config", {
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.providers.length).toBe(0);
});
describe("self hosted", () => {
it("should return available providers for team", async () => {
process.env.DEPLOYMENT = "";
await buildTeam({
guestSignin: false,
authenticationProviders: [
{
name: "slack",
providerId: "123",
},
],
});
const res = await server.post("/api/auth.config");
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.providers.length).toBe(1);
expect(body.data.providers[0].name).toBe("Slack");
});
it("should return email provider for team when guest signin enabled", async () => {
process.env.DEPLOYMENT = "";
await buildTeam({
guestSignin: true,
authenticationProviders: [
{
name: "slack",
providerId: "123",
},
],
});
const res = await server.post("/api/auth.config");
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.providers.length).toBe(2);
expect(body.data.providers[0].name).toBe("Slack");
expect(body.data.providers[1].name).toBe("Email");
});
});
});

View File

@@ -0,0 +1,88 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { AuthenticationProvider, Event } from "../../models";
import policy from "../../policies";
import {
presentAuthenticationProvider,
presentPolicies,
} from "../../presenters";
import allAuthenticationProviders from "../auth/providers";
const router = new Router();
const { authorize } = policy;
router.post("authenticationProviders.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const authenticationProvider = await AuthenticationProvider.findByPk(id);
authorize(user, "read", authenticationProvider);
ctx.body = {
data: presentAuthenticationProvider(authenticationProvider),
policies: presentPolicies(user, [authenticationProvider]),
};
});
router.post("authenticationProviders.update", auth(), async (ctx) => {
const { id, isEnabled } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertPresent(isEnabled, "isEnabled is required");
const user = ctx.state.user;
const authenticationProvider = await AuthenticationProvider.findByPk(id);
authorize(user, "update", authenticationProvider);
const enabled = !!isEnabled;
if (enabled) {
await authenticationProvider.enable();
} else {
await authenticationProvider.disable();
}
await Event.create({
name: "authenticationProviders.update",
data: { enabled },
modelId: id,
teamId: user.teamId,
actorId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
data: presentAuthenticationProvider(authenticationProvider),
policies: presentPolicies(user, [authenticationProvider]),
};
});
router.post("authenticationProviders.list", auth(), async (ctx) => {
const user = ctx.state.user;
authorize(user, "read", user.team);
const teamAuthenticationProviders = await user.team.getAuthenticationProviders();
const otherAuthenticationProviders = allAuthenticationProviders.filter(
(p) =>
!teamAuthenticationProviders.find((t) => t.name === p.id) &&
p.enabled &&
// email auth is dealt with separetly right now, although it definitely
// wants to be here in the future we'll need to migrate more data though
p.id !== "email"
);
ctx.body = {
data: {
authenticationProviders: [
...teamAuthenticationProviders.map(presentAuthenticationProvider),
...otherAuthenticationProviders.map((p) => ({
name: p.id,
isEnabled: false,
isConnected: false,
})),
],
},
};
});
export default router;

View File

@@ -0,0 +1,157 @@
// @flow
import TestServer from "fetch-test-server";
import { v4 as uuidv4 } from "uuid";
import webService from "../../services/web";
import { buildUser, buildAdmin, buildTeam } from "../../test/factories";
import { flushdb } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#authenticationProviders.info", () => {
it("should return auth provider", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.info", {
body: {
id: authenticationProviders[0].id,
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("slack");
expect(body.data.isEnabled).toBe(true);
expect(body.data.isConnected).toBe(true);
expect(body.policies[0].abilities.read).toBe(true);
expect(body.policies[0].abilities.update).toBe(false);
});
it("should require authorization", async () => {
const team = await buildTeam();
const user = await buildUser();
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.info", {
body: {
id: authenticationProviders[0].id,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const team = await buildTeam();
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.info", {
body: {
id: authenticationProviders[0].id,
},
});
expect(res.status).toEqual(401);
});
});
describe("#authenticationProviders.update", () => {
it("should not allow admins to disable when last authentication provider", async () => {
const team = await buildTeam();
const user = await buildAdmin({ teamId: team.id });
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.update", {
body: {
id: authenticationProviders[0].id,
isEnabled: false,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(400);
});
it("should allow admins to disable", async () => {
const team = await buildTeam();
const user = await buildAdmin({ teamId: team.id });
await team.createAuthenticationProvider({
name: "google",
providerId: uuidv4(),
});
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.update", {
body: {
id: authenticationProviders[0].id,
isEnabled: false,
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("slack");
expect(body.data.isEnabled).toBe(false);
expect(body.data.isConnected).toBe(true);
});
it("should require authorization", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.update", {
body: {
id: authenticationProviders[0].id,
isEnabled: false,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const team = await buildTeam();
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.update", {
body: {
id: authenticationProviders[0].id,
isEnabled: false,
},
});
expect(res.status).toEqual(401);
});
});
describe("#authenticationProviders.list", () => {
it("should return enabled and available auth providers", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/authenticationProviders.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.authenticationProviders.length).toBe(2);
expect(body.data.authenticationProviders[0].name).toBe("slack");
expect(body.data.authenticationProviders[0].isEnabled).toBe(true);
expect(body.data.authenticationProviders[0].isConnected).toBe(true);
expect(body.data.authenticationProviders[1].name).toBe("google");
expect(body.data.authenticationProviders[1].isEnabled).toBe(false);
expect(body.data.authenticationProviders[1].isConnected).toBe(false);
});
it("should require authentication", async () => {
const res = await server.post("/api/authenticationProviders.list");
expect(res.status).toEqual(401);
});
});

View File

@@ -0,0 +1,706 @@
// @flow
import fractionalIndex from "fractional-index";
import Router from "koa-router";
import { ValidationError } from "../../errors";
import { exportCollections } from "../../exporter";
import auth from "../../middlewares/authentication";
import {
Collection,
CollectionUser,
CollectionGroup,
Team,
Event,
User,
Group,
Attachment,
FileOperation,
} from "../../models";
import policy from "../../policies";
import {
presentCollection,
presentUser,
presentPolicies,
presentMembership,
presentGroup,
presentCollectionGroupMembership,
presentFileOperation,
} from "../../presenters";
import { Op, sequelize } from "../../sequelize";
import collectionIndexing from "../../utils/collectionIndexing";
import removeIndexCollision from "../../utils/removeIndexCollision";
import { getAWSKeyForFileOp } from "../../utils/s3";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
const router = new Router();
router.post("collections.create", auth(), async (ctx) => {
const {
name,
color,
description,
permission,
sharing,
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
let { index } = ctx.body;
ctx.assertPresent(name, "name is required");
if (color) {
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
}
const user = ctx.state.user;
authorize(user, "createCollection", user.team);
const collections = await Collection.findAll({
where: { teamId: user.teamId, deletedAt: null },
attributes: ["id", "index", "updatedAt"],
limit: 1,
order: [
// using LC_COLLATE:"C" because we need byte order to drive the sorting
sequelize.literal('"collection"."index" collate "C"'),
["updatedAt", "DESC"],
],
});
if (index) {
ctx.assertIndexCharacters(
index,
"Index characters must be between x20 to x7E ASCII"
);
} else {
index = fractionalIndex(
null,
collections.length ? collections[0].index : null
);
}
index = await removeIndexCollision(user.teamId, index);
let collection = await Collection.create({
name,
description,
icon,
color,
teamId: user.teamId,
createdById: user.id,
permission: permission ? permission : null,
sharing,
sort,
index,
});
await Event.create({
name: "collections.create",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { name },
ip: ctx.request.ip,
});
// we must reload the collection to get memberships for policy presenter
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
ctx.body = {
data: presentCollection(collection),
policies: presentPolicies(user, [collection]),
};
});
router.post("collections.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "read", collection);
ctx.body = {
data: presentCollection(collection),
policies: presentPolicies(user, [collection]),
};
});
router.post("collections.import", auth(), async (ctx) => {
const { type, attachmentId } = ctx.body;
ctx.assertIn(type, ["outline"], "type must be one of 'outline'");
ctx.assertUuid(attachmentId, "attachmentId is required");
const user = ctx.state.user;
authorize(user, "importCollection", user.team);
const attachment = await Attachment.findByPk(attachmentId);
authorize(user, "read", attachment);
await Event.create({
name: "collections.import",
modelId: attachmentId,
teamId: user.teamId,
actorId: user.id,
data: { type },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
router.post("collections.add_group", auth(), async (ctx) => {
const { id, groupId, permission = "read_write" } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertUuid(groupId, "groupId is required");
const collection = await Collection.scope({
method: ["withMembership", ctx.state.user.id],
}).findByPk(id);
authorize(ctx.state.user, "update", collection);
const group = await Group.findByPk(groupId);
authorize(ctx.state.user, "read", group);
let membership = await CollectionGroup.findOne({
where: {
collectionId: id,
groupId,
},
});
if (!membership) {
membership = await CollectionGroup.create({
collectionId: id,
groupId,
permission,
createdById: ctx.state.user.id,
});
} else if (permission) {
membership.permission = permission;
await membership.save();
}
await Event.create({
name: "collections.add_group",
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
data: { name: group.name, groupId },
ip: ctx.request.ip,
});
ctx.body = {
data: {
collectionGroupMemberships: [
presentCollectionGroupMembership(membership),
],
},
};
});
router.post("collections.remove_group", auth(), async (ctx) => {
const { id, groupId } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertUuid(groupId, "groupId is required");
const collection = await Collection.scope({
method: ["withMembership", ctx.state.user.id],
}).findByPk(id);
authorize(ctx.state.user, "update", collection);
const group = await Group.findByPk(groupId);
authorize(ctx.state.user, "read", group);
await collection.removeGroup(group);
await Event.create({
name: "collections.remove_group",
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
data: { name: group.name, groupId },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
router.post(
"collections.group_memberships",
auth(),
pagination(),
async (ctx) => {
const { id, query, permission } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "read", collection);
let where = {
collectionId: id,
};
let groupWhere;
if (query) {
groupWhere = {
name: {
[Op.iLike]: `%${query}%`,
},
};
}
if (permission) {
where = {
...where,
permission,
};
}
const memberships = await CollectionGroup.findAll({
where,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
include: [
{
model: Group,
as: "group",
where: groupWhere,
required: true,
},
],
});
ctx.body = {
pagination: ctx.state.pagination,
data: {
collectionGroupMemberships: memberships.map(
presentCollectionGroupMembership
),
groups: memberships.map((membership) => presentGroup(membership.group)),
},
};
}
);
router.post("collections.add_user", auth(), async (ctx) => {
const { id, userId, permission = "read_write" } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertUuid(userId, "userId is required");
const collection = await Collection.scope({
method: ["withMembership", ctx.state.user.id],
}).findByPk(id);
authorize(ctx.state.user, "update", collection);
const user = await User.findByPk(userId);
authorize(ctx.state.user, "read", user);
let membership = await CollectionUser.findOne({
where: {
collectionId: id,
userId,
},
});
if (!membership) {
membership = await CollectionUser.create({
collectionId: id,
userId,
permission,
createdById: ctx.state.user.id,
});
} else if (permission) {
membership.permission = permission;
await membership.save();
}
await Event.create({
name: "collections.add_user",
userId,
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
data: { name: user.name },
ip: ctx.request.ip,
});
ctx.body = {
data: {
users: [presentUser(user)],
memberships: [presentMembership(membership)],
},
};
});
router.post("collections.remove_user", auth(), async (ctx) => {
const { id, userId } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertUuid(userId, "userId is required");
const collection = await Collection.scope({
method: ["withMembership", ctx.state.user.id],
}).findByPk(id);
authorize(ctx.state.user, "update", collection);
const user = await User.findByPk(userId);
authorize(ctx.state.user, "read", user);
await collection.removeUser(user);
await Event.create({
name: "collections.remove_user",
userId,
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
data: { name: user.name },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
// DEPRECATED: Use collection.memberships which has pagination, filtering and permissions
router.post("collections.users", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "read", collection);
const users = await collection.getUsers();
ctx.body = {
data: users.map(presentUser),
};
});
router.post("collections.memberships", auth(), pagination(), async (ctx) => {
const { id, query, permission } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "read", collection);
let where = {
collectionId: id,
};
let userWhere;
if (query) {
userWhere = {
name: {
[Op.iLike]: `%${query}%`,
},
};
}
if (permission) {
where = {
...where,
permission,
};
}
const memberships = await CollectionUser.findAll({
where,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
include: [
{
model: User,
as: "user",
where: userWhere,
required: true,
},
],
});
ctx.body = {
pagination: ctx.state.pagination,
data: {
memberships: memberships.map(presentMembership),
users: memberships.map((membership) => presentUser(membership.user)),
},
};
});
router.post("collections.export", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
authorize(user, "export", team);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
ctx.assertPresent(collection, "Collection should be present");
authorize(user, "read", collection);
const key = getAWSKeyForFileOp(team.id, collection.name);
let exportData;
exportData = await FileOperation.create({
type: "export",
state: "creating",
key,
url: null,
size: 0,
collectionId: id,
userId: user.id,
teamId: team.id,
});
exportCollections(user.teamId, user.id, user.email, exportData.id, id);
exportData.user = user;
exportData.collection = collection;
ctx.body = {
success: true,
data: { fileOperation: presentFileOperation(exportData) },
};
});
router.post("collections.export_all", auth(), async (ctx) => {
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
authorize(user, "export", team);
const key = getAWSKeyForFileOp(team.id, team.name);
let exportData;
exportData = await FileOperation.create({
type: "export",
state: "creating",
key,
url: null,
size: 0,
collectionId: null,
userId: user.id,
teamId: team.id,
});
// async operation to upload zip archive to cloud and email user with link
exportCollections(user.teamId, user.id, user.email, exportData.id);
exportData.user = user;
exportData.collection = null;
ctx.body = {
success: true,
data: { fileOperation: presentFileOperation(exportData) },
};
});
router.post("collections.update", auth(), async (ctx) => {
let {
id,
name,
description,
icon,
permission,
color,
sort,
sharing,
} = ctx.body;
if (color) {
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
}
const user = ctx.state.user;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "update", collection);
// we're making this collection have no default access, ensure that the current
// user has a read-write membership so that at least they can edit it
if (permission !== "read_write" && collection.permission === "read_write") {
await CollectionUser.findOrCreate({
where: {
collectionId: collection.id,
userId: user.id,
},
defaults: {
permission: "read_write",
createdById: user.id,
},
});
}
const permissionChanged = permission !== collection.permission;
if (name !== undefined) {
collection.name = name;
}
if (description !== undefined) {
collection.description = description;
}
if (icon !== undefined) {
collection.icon = icon;
}
if (color !== undefined) {
collection.color = color;
}
if (permission !== undefined) {
collection.permission = permission ? permission : null;
}
if (sharing !== undefined) {
collection.sharing = sharing;
}
if (sort !== undefined) {
collection.sort = sort;
}
await collection.save();
await Event.create({
name: "collections.update",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { name },
ip: ctx.request.ip,
});
// must reload to update collection membership for correct policy calculation
// if the privacy level has changed. Otherwise skip this query for speed.
if (permissionChanged) {
await collection.reload();
}
ctx.body = {
data: presentCollection(collection),
policies: presentPolicies(user, [collection]),
};
});
router.post("collections.list", auth(), pagination(), async (ctx) => {
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
let collections = await Collection.scope({
method: ["withMembership", user.id],
}).findAll({
where: {
teamId: user.teamId,
id: collectionIds,
},
order: [["updatedAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const nullIndexCollection = collections.findIndex(
(collection) => collection.index === null
);
if (nullIndexCollection !== -1) {
const indexedCollections = await collectionIndexing(ctx.state.user.teamId);
collections.forEach((collection) => {
collection.index = indexedCollections[collection.id];
});
}
ctx.body = {
pagination: ctx.state.pagination,
data: collections.map(presentCollection),
policies: presentPolicies(user, collections),
};
});
router.post("collections.delete", auth(), async (ctx) => {
const { id } = ctx.body;
const user = ctx.state.user;
ctx.assertUuid(id, "id is required");
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "delete", collection);
const total = await Collection.count();
if (total === 1) throw new ValidationError("Cannot delete last collection");
await collection.destroy();
await Event.create({
name: "collections.delete",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { name: collection.name },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
router.post("collections.move", auth(), async (ctx) => {
const id = ctx.body.id;
let index = ctx.body.index;
ctx.assertPresent(index, "index is required");
ctx.assertIndexCharacters(
index,
"Index characters must be between x20 to x7E ASCII"
);
ctx.assertUuid(id, "id must be a uuid");
const user = ctx.state.user;
const collection = await Collection.findByPk(id);
authorize(user, "move", collection);
index = await removeIndexCollision(user.teamId, index);
await collection.update({ index });
await Event.create({
name: "collections.move",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { index },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
data: { index },
};
});
export default router;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
// @flow
import Router from "koa-router";
import Sequelize from "sequelize";
import auth from "../../middlewares/authentication";
import { Event, User, Collection } from "../../models";
import policy from "../../policies";
import { presentEvent } from "../../presenters";
import pagination from "./middlewares/pagination";
const Op = Sequelize.Op;
const { authorize } = policy;
const router = new Router();
router.post("events.list", auth(), pagination(), async (ctx) => {
const user = ctx.state.user;
let {
sort = "createdAt",
actorId,
documentId,
collectionId,
direction,
name,
auditLog = false,
} = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Event);
let where = {
name: Event.ACTIVITY_EVENTS,
teamId: user.teamId,
};
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
where = { ...where, actorId };
}
if (documentId) {
ctx.assertUuid(documentId, "documentId must be a UUID");
where = { ...where, documentId };
}
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);
} else {
const collectionIds = await user.collectionIds({ paranoid: false });
where = {
...where,
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: {
[Op.eq]: null,
},
},
],
};
}
if (auditLog) {
authorize(user, "manage", user.team);
where.name = Event.AUDIT_EVENTS;
}
if (name && where.name.includes(name)) {
where.name = name;
}
const events = await Event.findAll({
where,
order: [[sort, direction]],
include: [
{
model: User,
as: "actor",
paranoid: false,
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: events.map((event) => presentEvent(event, auditLog)),
};
});
export default router;

View File

@@ -0,0 +1,226 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { buildEvent, buildUser } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#events.list", () => {
it("should only return activity events", async () => {
const { user, admin, document, collection } = await seed();
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should return audit events", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data[0].id).toEqual(event.id);
expect(body.data[1].id).toEqual(auditEvent.id);
});
it("should allow filtering by actorId", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true, actorId: admin.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(auditEvent.id);
});
it("should allow filtering by documentId", async () => {
const { user, admin, document, collection } = await seed();
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: admin.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).toEqual(event.id);
});
it("should not return events for documentId without authorization", async () => {
const { user, document, collection } = await seed();
const actor = await buildUser();
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: actor.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should allow filtering by event name", async () => {
const { user, admin, document, collection } = await seed();
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
name: "documents.publish",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should return events with deleted actors", async () => {
const { user, admin, document, collection } = await seed();
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
await user.destroy();
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should require authorization for audit events", async () => {
const { user } = await seed();
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken(), auditLog: true },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/events.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,99 @@
// @flow
import Router from "koa-router";
import { NotFoundError, ValidationError } from "../../errors";
import auth from "../../middlewares/authentication";
import { FileOperation, Team } from "../../models";
import policy from "../../policies";
import { presentFileOperation } from "../../presenters";
import { getSignedUrl } from "../../utils/s3";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
const router = new Router();
router.post("fileOperations.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
const fileOperation = await FileOperation.findByPk(id);
authorize(user, fileOperation.type, team);
if (!fileOperation) {
throw new NotFoundError();
}
ctx.body = {
data: presentFileOperation(fileOperation),
};
});
router.post("fileOperations.list", auth(), pagination(), async (ctx) => {
let { sort = "createdAt", direction, type } = ctx.body;
ctx.assertPresent(type, "type is required");
ctx.assertIn(
type,
["import", "export"],
"type must be one of 'import' or 'export'"
);
if (direction !== "ASC") direction = "DESC";
const user = ctx.state.user;
const where = {
teamId: user.teamId,
type,
};
const team = await Team.findByPk(user.teamId);
authorize(user, type, team);
const [exports, total] = await Promise.all([
await FileOperation.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
await FileOperation.count({
where,
}),
]);
ctx.body = {
pagination: {
...ctx.state.pagination,
total,
},
data: exports.map(presentFileOperation),
};
});
router.post("fileOperations.redirect", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
const fileOp = await FileOperation.unscoped().findByPk(id);
if (!fileOp) {
throw new NotFoundError();
}
authorize(user, fileOp.type, team);
if (fileOp.state !== "complete") {
throw new ValidationError("file operation is not complete yet");
}
const accessUrl = await getSignedUrl(fileOp.key);
ctx.redirect(accessUrl);
});
export default router;

View File

@@ -0,0 +1,283 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { Collection, User } from "../../models";
import webService from "../../services/web";
import {
buildAdmin,
buildCollection,
buildFileOperation,
buildTeam,
buildUser,
} from "../../test/factories";
import { flushdb } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#fileOperations.info", () => {
it("should return fileOperation", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.info", {
body: {
id: exportData.id,
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(exportData.id);
expect(body.data.state).toBe(exportData.state);
});
it("should require user to be an admin", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.info", {
body: {
id: exportData.id,
token: user.getJwtToken(),
type: "export",
},
});
expect(res.status).toEqual(403);
});
});
describe("#fileOperations.list", () => {
it("should return fileOperations list", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
});
it("should return exports with collection data", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
userId: admin.id,
teamId: team.id,
});
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
collectionId: collection.id,
});
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
expect(data.collection.id).toBe(collection.id);
});
it("should return exports with collection data even if collection is deleted", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
userId: admin.id,
teamId: team.id,
});
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
collectionId: collection.id,
});
await collection.destroy();
const isCollectionPresent = await Collection.findByPk(collection.id);
expect(isCollectionPresent).toBe(null);
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
expect(data.collection.id).toBe(collection.id);
});
it("should return exports with user data even if user is deleted", async () => {
const team = await buildTeam();
const admin2 = await buildAdmin({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
userId: admin.id,
teamId: team.id,
});
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
collectionId: collection.id,
});
await admin.destroy();
const isAdminPresent = await User.findByPk(admin.id);
expect(isAdminPresent).toBe(null);
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin2.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
expect(data.user.id).toBe(admin.id);
});
it("should require authorization", async () => {
const user = await buildUser();
const res = await server.post("/api/fileOperations.list", {
body: { token: user.getJwtToken(), type: "export" },
});
expect(res.status).toEqual(403);
});
});
describe("#fileOperations.redirect", () => {
it("should not redirect when file operation is not complete", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.redirect", {
body: {
token: admin.getJwtToken(),
id: exportData.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("file operation is not complete yet");
});
});
describe("#fileOperations.info", () => {
it("should return file operation", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.info", {
body: {
token: admin.getJwtToken(),
id: exportData.id,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe(exportData.id);
expect(body.data.user.id).toBe(admin.id);
});
it("should require authorization", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.info", {
body: {
token: user.getJwtToken(),
id: exportData.id,
},
});
expect(res.status).toBe(403);
});
});

287
server/routes/api/groups.js Normal file
View File

@@ -0,0 +1,287 @@
// @flow
import Router from "koa-router";
import { MAX_AVATAR_DISPLAY } from "../../../shared/constants";
import auth from "../../middlewares/authentication";
import { User, Event, Group, GroupUser } from "../../models";
import policy from "../../policies";
import {
presentGroup,
presentPolicies,
presentUser,
presentGroupMembership,
} from "../../presenters";
import { Op } from "../../sequelize";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
const router = new Router();
router.post("groups.list", auth(), pagination(), async (ctx) => {
let { sort = "updatedAt", direction } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Group);
const user = ctx.state.user;
let groups = await Group.findAll({
where: {
teamId: user.teamId,
},
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
if (!user.isAdmin) {
groups = groups.filter(
(group) =>
group.groupMemberships.filter((gm) => gm.userId === user.id).length
);
}
ctx.body = {
pagination: ctx.state.pagination,
data: {
groups: groups.map(presentGroup),
groupMemberships: groups
.map((g) =>
g.groupMemberships
.filter((membership) => !!membership.user)
.slice(0, MAX_AVATAR_DISPLAY)
)
.flat()
.map(presentGroupMembership),
},
policies: presentPolicies(user, groups),
};
});
router.post("groups.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const group = await Group.findByPk(id);
authorize(user, "read", group);
ctx.body = {
data: presentGroup(group),
policies: presentPolicies(user, [group]),
};
});
router.post("groups.create", auth(), async (ctx) => {
const { name } = ctx.body;
ctx.assertPresent(name, "name is required");
const user = ctx.state.user;
authorize(user, "createGroup", user.team);
let group = await Group.create({
name,
teamId: user.teamId,
createdById: user.id,
});
// reload to get default scope
group = await Group.findByPk(group.id);
await Event.create({
name: "groups.create",
actorId: user.id,
teamId: user.teamId,
modelId: group.id,
data: { name: group.name },
ip: ctx.request.ip,
});
ctx.body = {
data: presentGroup(group),
policies: presentPolicies(user, [group]),
};
});
router.post("groups.update", auth(), async (ctx) => {
const { id, name } = ctx.body;
ctx.assertPresent(name, "name is required");
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const group = await Group.findByPk(id);
authorize(user, "update", group);
group.name = name;
if (group.changed()) {
await group.save();
await Event.create({
name: "groups.update",
teamId: user.teamId,
actorId: user.id,
modelId: group.id,
data: { name },
ip: ctx.request.ip,
});
}
ctx.body = {
data: presentGroup(group),
policies: presentPolicies(user, [group]),
};
});
router.post("groups.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const { user } = ctx.state;
const group = await Group.findByPk(id);
authorize(user, "delete", group);
await group.destroy();
await Event.create({
name: "groups.delete",
actorId: user.id,
modelId: group.id,
teamId: group.teamId,
data: { name: group.name },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
router.post("groups.memberships", auth(), pagination(), async (ctx) => {
const { id, query } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const group = await Group.findByPk(id);
authorize(user, "read", group);
let userWhere;
if (query) {
userWhere = {
name: {
[Op.iLike]: `%${query}%`,
},
};
}
const memberships = await GroupUser.findAll({
where: { groupId: id },
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
include: [
{
model: User,
as: "user",
where: userWhere,
required: true,
},
],
});
ctx.body = {
pagination: ctx.state.pagination,
data: {
groupMemberships: memberships.map(presentGroupMembership),
users: memberships.map((membership) => presentUser(membership.user)),
},
};
});
router.post("groups.add_user", auth(), async (ctx) => {
const { id, userId } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertUuid(userId, "userId is required");
const user = await User.findByPk(userId);
authorize(ctx.state.user, "read", user);
let group = await Group.findByPk(id);
authorize(ctx.state.user, "update", group);
let membership = await GroupUser.findOne({
where: {
groupId: id,
userId,
},
});
if (!membership) {
await group.addUser(user, {
through: { createdById: ctx.state.user.id },
});
// reload to get default scope
membership = await GroupUser.findOne({
where: {
groupId: id,
userId,
},
});
// reload to get default scope
group = await Group.findByPk(id);
await Event.create({
name: "groups.add_user",
userId,
teamId: user.teamId,
modelId: group.id,
actorId: ctx.state.user.id,
data: { name: user.name },
ip: ctx.request.ip,
});
}
ctx.body = {
data: {
users: [presentUser(user)],
groupMemberships: [presentGroupMembership(membership)],
groups: [presentGroup(group)],
},
};
});
router.post("groups.remove_user", auth(), async (ctx) => {
const { id, userId } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertUuid(userId, "userId is required");
let group = await Group.findByPk(id);
authorize(ctx.state.user, "update", group);
const user = await User.findByPk(userId);
authorize(ctx.state.user, "read", user);
await group.removeUser(user);
await Event.create({
name: "groups.remove_user",
userId,
modelId: group.id,
teamId: user.teamId,
actorId: ctx.state.user.id,
data: { name: user.name },
ip: ctx.request.ip,
});
// reload to get default scope
group = await Group.findByPk(id);
ctx.body = {
data: {
groups: [presentGroup(group)],
},
};
});
export default router;

View File

@@ -0,0 +1,491 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { Event } from "../../models";
import webService from "../../services/web";
import { buildUser, buildAdmin, buildGroup } from "../../test/factories";
import { flushdb } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#groups.create", () => {
it("should create a group", async () => {
const name = "hello I am a group";
const user = await buildAdmin();
const res = await server.post("/api/groups.create", {
body: { token: user.getJwtToken(), name },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual(name);
});
});
describe("#groups.update", () => {
it("should require authentication", async () => {
const group = await buildGroup();
const res = await server.post("/api/groups.update", {
body: { id: group.id, name: "Test" },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const group = await buildGroup();
const user = await buildUser();
const res = await server.post("/api/groups.update", {
body: { token: user.getJwtToken(), id: group.id, name: "Test" },
});
expect(res.status).toEqual(403);
});
it("should require authorization", async () => {
const group = await buildGroup();
const user = await buildAdmin();
const res = await server.post("/api/groups.update", {
body: { token: user.getJwtToken(), id: group.id, name: "Test" },
});
expect(res.status).toEqual(403);
});
describe("when user is admin", () => {
let user, group;
beforeEach(async () => {
user = await buildAdmin();
group = await buildGroup({ teamId: user.teamId });
});
it("allows admin to edit a group", async () => {
const res = await server.post("/api/groups.update", {
body: { token: user.getJwtToken(), id: group.id, name: "Test" },
});
const events = await Event.findAll();
expect(events.length).toEqual(1);
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("Test");
});
it("does not create an event if the update is a noop", async () => {
const res = await server.post("/api/groups.update", {
body: { token: user.getJwtToken(), id: group.id, name: group.name },
});
const events = await Event.findAll();
expect(events.length).toEqual(0);
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe(group.name);
});
it("fails with validation error when name already taken", async () => {
await buildGroup({
teamId: user.teamId,
name: "test",
});
const res = await server.post("/api/groups.update", {
body: {
token: user.getJwtToken(),
id: group.id,
name: "TEST",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
});
});
describe("#groups.list", () => {
it("should require authentication", async () => {
const res = await server.post("/api/groups.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return groups with memberships preloaded", async () => {
const user = await buildUser();
const group = await buildGroup({ teamId: user.teamId });
await group.addUser(user, { through: { createdById: user.id } });
const res = await server.post("/api/groups.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data["groups"].length).toEqual(1);
expect(body.data["groups"][0].id).toEqual(group.id);
expect(body.data["groupMemberships"].length).toEqual(1);
expect(body.data["groupMemberships"][0].groupId).toEqual(group.id);
expect(body.data["groupMemberships"][0].user.id).toEqual(user.id);
expect(body.policies.length).toEqual(1);
expect(body.policies[0].abilities.read).toEqual(true);
});
it("should return groups when membership user is deleted", async () => {
const me = await buildUser();
const user = await buildUser({ teamId: me.teamId });
const group = await buildGroup({ teamId: user.teamId });
await group.addUser(user, { through: { createdById: me.id } });
await group.addUser(me, { through: { createdById: me.id } });
await user.destroy();
const res = await server.post("/api/groups.list", {
body: { token: me.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data["groups"].length).toEqual(1);
expect(body.data["groups"][0].id).toEqual(group.id);
expect(body.data["groupMemberships"].length).toEqual(1);
expect(body.data["groupMemberships"][0].groupId).toEqual(group.id);
expect(body.data["groupMemberships"][0].user.id).toEqual(me.id);
expect(body.policies.length).toEqual(1);
expect(body.policies[0].abilities.read).toEqual(true);
});
});
describe("#groups.info", () => {
it("should return group if admin", async () => {
const user = await buildAdmin();
const group = await buildGroup({ teamId: user.teamId });
const res = await server.post("/api/groups.info", {
body: { token: user.getJwtToken(), id: group.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(group.id);
});
it("should return group if member", async () => {
const user = await buildUser();
const group = await buildGroup({ teamId: user.teamId });
await group.addUser(user, { through: { createdById: user.id } });
const res = await server.post("/api/groups.info", {
body: { token: user.getJwtToken(), id: group.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(group.id);
});
it("should not return group if non-member, non-admin", async () => {
const user = await buildUser();
const group = await buildGroup({ teamId: user.teamId });
const res = await server.post("/api/groups.info", {
body: { token: user.getJwtToken(), id: group.id },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.info");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const user = await buildUser();
const group = await buildGroup();
const res = await server.post("/api/groups.info", {
body: { token: user.getJwtToken(), id: group.id },
});
expect(res.status).toEqual(403);
});
});
describe("#groups.delete", () => {
it("should require authentication", async () => {
const group = await buildGroup();
const res = await server.post("/api/groups.delete", {
body: { id: group.id },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const group = await buildGroup();
const user = await buildUser();
const res = await server.post("/api/groups.delete", {
body: { token: user.getJwtToken(), id: group.id },
});
expect(res.status).toEqual(403);
});
it("should require authorization", async () => {
const group = await buildGroup();
const user = await buildAdmin();
const res = await server.post("/api/groups.delete", {
body: { token: user.getJwtToken(), id: group.id },
});
expect(res.status).toEqual(403);
});
it("allows admin to delete a group", async () => {
const user = await buildAdmin();
const group = await buildGroup({ teamId: user.teamId });
const res = await server.post("/api/groups.delete", {
body: { token: user.getJwtToken(), id: group.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
});
describe("#groups.memberships", () => {
it("should return members in a group", async () => {
const user = await buildUser();
const group = await buildGroup({ teamId: user.teamId });
await group.addUser(user, { through: { createdById: user.id } });
const res = await server.post("/api/groups.memberships", {
body: { token: user.getJwtToken(), id: group.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.users.length).toEqual(1);
expect(body.data.users[0].id).toEqual(user.id);
expect(body.data.groupMemberships.length).toEqual(1);
expect(body.data.groupMemberships[0].user.id).toEqual(user.id);
});
it("should allow filtering members in group by name", async () => {
const user = await buildUser();
const user2 = await buildUser({ name: "Won't find" });
const user3 = await buildUser({ teamId: user.teamId, name: "Deleted" });
const group = await buildGroup({ teamId: user.teamId });
await group.addUser(user, { through: { createdById: user.id } });
await group.addUser(user2, { through: { createdById: user.id } });
await group.addUser(user3, { through: { createdById: user.id } });
await user3.destroy();
const res = await server.post("/api/groups.memberships", {
body: {
token: user.getJwtToken(),
id: group.id,
query: user.name.slice(0, 3),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.users.length).toEqual(1);
expect(body.data.users[0].id).toEqual(user.id);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.memberships");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const user = await buildUser();
const group = await buildGroup();
const res = await server.post("/api/groups.memberships", {
body: { token: user.getJwtToken(), id: group.id },
});
expect(res.status).toEqual(403);
});
});
describe("#groups.add_user", () => {
it("should add user to group", async () => {
const user = await buildAdmin();
const group = await buildGroup({
teamId: user.teamId,
});
const res = await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: user.id,
},
});
const users = await group.getUsers();
expect(res.status).toEqual(200);
expect(users.length).toEqual(1);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.add_user");
expect(res.status).toEqual(401);
});
it("should require user in team", async () => {
const user = await buildAdmin();
const group = await buildGroup({
teamId: user.teamId,
});
const anotherUser = await buildUser();
const res = await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const user = await buildUser();
const group = await buildGroup({
teamId: user.teamId,
});
const anotherUser = await buildUser({ teamId: user.teamId });
const res = await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe("#groups.remove_user", () => {
it("should remove user from group", async () => {
const user = await buildAdmin();
const group = await buildGroup({
teamId: user.teamId,
});
await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: user.id,
},
});
const users = await group.getUsers();
expect(users.length).toEqual(1);
const res = await server.post("/api/groups.remove_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: user.id,
},
});
const users1 = await group.getUsers();
expect(res.status).toEqual(200);
expect(users1.length).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.remove_user");
expect(res.status).toEqual(401);
});
it("should require user in team", async () => {
const user = await buildAdmin();
const group = await buildGroup({
teamId: user.teamId,
});
const anotherUser = await buildUser();
const res = await server.post("/api/groups.remove_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const user = await buildUser();
const group = await buildGroup({
teamId: user.teamId,
});
const anotherUser = await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/groups.remove_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});

265
server/routes/api/hooks.js Normal file
View File

@@ -0,0 +1,265 @@
// @flow
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { AuthenticationError, InvalidRequestError } from "../../errors";
import {
UserAuthentication,
AuthenticationProvider,
Document,
User,
Team,
SearchQuery,
Integration,
IntegrationAuthentication,
} from "../../models";
import { presentSlackAttachment } from "../../presenters";
import * as Slack from "../../slack";
const router = new Router();
// triggered by a user posting a getoutline.com link in Slack
router.post("hooks.unfurl", async (ctx) => {
const { challenge, token, event } = ctx.body;
if (challenge) return (ctx.body = ctx.body.challenge);
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
throw new AuthenticationError("Invalid token");
}
const user = await User.findOne({
include: [
{
where: { providerId: event.user },
model: UserAuthentication,
as: "authentications",
required: true,
},
],
});
if (!user) return;
const auth = await IntegrationAuthentication.findOne({
where: { service: "slack", teamId: user.teamId },
});
if (!auth) return;
// get content for unfurled links
let unfurls = {};
for (let link of event.links) {
const id = link.url.substr(link.url.lastIndexOf("/") + 1);
const doc = await Document.findByPk(id);
if (!doc || doc.teamId !== user.teamId) continue;
unfurls[link.url] = {
title: doc.title,
text: doc.getSummary(),
color: doc.collection.color,
};
}
await Slack.post("chat.unfurl", {
token: auth.token,
channel: event.channel,
ts: event.message_ts,
unfurls,
});
});
// triggered by interactions with actions, dialogs, message buttons in Slack
router.post("hooks.interactive", async (ctx) => {
const { payload } = ctx.body;
ctx.assertPresent(payload, "payload is required");
const data = JSON.parse(payload);
const { callback_id, token } = data;
ctx.assertPresent(token, "token is required");
ctx.assertPresent(callback_id, "callback_id is required");
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
throw new AuthenticationError("Invalid verification token");
}
// we find the document based on the users teamId to ensure access
const document = await Document.scope("withCollection").findByPk(
data.callback_id
);
if (!document) {
throw new InvalidRequestError("Invalid callback_id");
}
const team = await Team.findByPk(document.teamId);
// respond with a public message that will be posted in the original channel
ctx.body = {
response_type: "in_channel",
replace_original: false,
attachments: [
presentSlackAttachment(
document,
document.collection,
team,
document.getSummary()
),
],
};
});
// triggered by the /outline command in Slack
router.post("hooks.slack", async (ctx) => {
const { token, team_id, user_id, text = "" } = ctx.body;
ctx.assertPresent(token, "token is required");
ctx.assertPresent(team_id, "team_id is required");
ctx.assertPresent(user_id, "user_id is required");
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
throw new AuthenticationError("Invalid verification token");
}
// Handle "help" command or no input
if (text.trim() === "help" || !text.trim()) {
ctx.body = {
response_type: "ephemeral",
text: "How to use /outline",
attachments: [
{
text:
"To search your knowledge base use `/outline keyword`. \nYouve already learned how to get help with `/outline help`.",
},
],
};
return;
}
let user, team;
// attempt to find the corresponding team for this request based on the team_id
team = await Team.findOne({
include: [
{
where: {
name: "slack",
providerId: team_id,
},
as: "authenticationProviders",
model: AuthenticationProvider,
required: true,
},
],
});
if (team) {
const authentication = await UserAuthentication.findOne({
where: {
providerId: user_id,
},
include: [
{
where: { teamId: team.id },
model: User,
as: "user",
required: true,
},
],
});
if (authentication) {
user = authentication.user;
}
} else {
// If we couldn't find a team it's still possible that the request is from
// a team that authenticated with a different service, but connected Slack
// via integration
const integration = await Integration.findOne({
where: {
settings: {
serviceTeamId: team_id,
},
},
include: [
{
model: Team,
as: "team",
},
],
});
if (integration) {
team = integration.team;
}
}
// This should be super rare, how does someone end up being able to make a valid
// request from Slack that connects to no teams in Outline.
if (!team) {
ctx.body = {
response_type: "ephemeral",
text:
"Sorry, we couldnt find an integration for your team. Head to your Outline settings to set one up.",
};
return;
}
const options = {
limit: 5,
};
// If we were able to map the request to a user then we can use their permissions
// to load more documents based on the collections they have access to. Otherwise
// just a generic search against team-visible documents is allowed.
const { results, totalCount } = user
? await Document.searchForUser(user, text, options)
: await Document.searchForTeam(team, text, options);
SearchQuery.create({
userId: user ? user.id : null,
teamId: team.id,
source: "slack",
query: text,
results: totalCount,
});
const haventSignedIn = `(It looks like you havent signed in to Outline yet, so results may be limited)`;
// Map search results to the format expected by the Slack API
if (results.length) {
const attachments = [];
for (const result of results) {
const queryIsInTitle = !!result.document.title
.toLowerCase()
.match(escapeRegExp(text.toLowerCase()));
attachments.push(
presentSlackAttachment(
result.document,
result.document.collection,
team,
queryIsInTitle ? undefined : result.context,
process.env.SLACK_MESSAGE_ACTIONS
? [
{
name: "post",
text: "Post to Channel",
type: "button",
value: result.document.id,
},
]
: undefined
)
);
}
ctx.body = {
text: user
? `This is what we found for "${text}"…`
: `This is what we found for "${text}" ${haventSignedIn}`,
attachments,
};
} else {
ctx.body = {
text: user
? `No results for "${text}"`
: `No results for "${text}" ${haventSignedIn}`,
};
}
});
export default router;

View File

@@ -0,0 +1,334 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { IntegrationAuthentication, SearchQuery } from "../../models";
import webService from "../../services/web";
import * as Slack from "../../slack";
import { buildDocument, buildIntegration } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
jest.mock("../../slack", () => ({
post: jest.fn(),
}));
describe("#hooks.unfurl", () => {
it("should return documents", async () => {
const { user, document } = await seed();
await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: "",
});
const res = await server.post("/api/hooks.unfurl", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
team_id: "TXXXXXXXX",
api_app_id: "AXXXXXXXXX",
event: {
type: "link_shared",
channel: "Cxxxxxx",
user: user.authentications[0].providerId,
message_ts: "123456789.9875",
links: [
{
domain: "getoutline.com",
url: document.url,
},
],
},
},
});
expect(res.status).toEqual(200);
expect(Slack.post).toHaveBeenCalled();
});
});
describe("#hooks.slack", () => {
it("should return no matches", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "dsfkndfskndsfkn",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments).toEqual(undefined);
});
it("should return search results with summary if query is in title", async () => {
const { user, team } = await seed();
const document = await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(document.getSummary());
});
it("should return search results if query is regex-like", async () => {
const { user, team } = await seed();
await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "*contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments.length).toEqual(1);
});
it("should return search results with snippet if query is in text", async () => {
const { user, team } = await seed();
const document = await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(
"This title *contains* a search term"
);
});
it("should save search term, hits and source", async (done) => {
const { user, team } = await seed();
await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
// setTimeout is needed here because SearchQuery is saved asynchronously
// in order to not slow down the response time.
setTimeout(async () => {
const searchQuery = await SearchQuery.findAll({
where: { query: "contains" },
});
expect(searchQuery.length).toBe(1);
expect(searchQuery[0].results).toBe(0);
expect(searchQuery[0].source).toBe("slack");
done();
}, 100);
});
it("should respond with help content for help keyword", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "help",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text.includes("How to use")).toEqual(true);
});
it("should respond with help content for no keyword", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text.includes("How to use")).toEqual(true);
});
it("should return search results with snippet for unknown user", async () => {
const { user, team } = await seed();
// unpublished document will not be returned
await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
publishedAt: null,
});
const document = await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: "unknown-slack-user-id",
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text).toContain("you havent signed in to Outline yet");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(
"This title *contains* a search term"
);
});
it("should return search results with snippet for user through integration mapping", async () => {
const { user } = await seed();
const serviceTeamId = "slack_team_id";
await buildIntegration({
teamId: user.teamId,
settings: {
serviceTeamId,
},
});
const document = await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: "unknown-slack-user-id",
team_id: serviceTeamId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text).toContain("you havent signed in to Outline yet");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(
"This title *contains* a search term"
);
});
it("should error if incorrect verification token", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: "wrong-verification-token",
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "Welcome",
},
});
expect(res.status).toEqual(401);
});
});
describe("#hooks.interactive", () => {
it("should respond with replacement message", async () => {
const { user, team } = await seed();
const document = await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const payload = JSON.stringify({
token: process.env.SLACK_VERIFICATION_TOKEN,
user: { id: user.authentications[0].providerId },
team: { id: team.authenticationProviders[0].providerId },
callback_id: document.id,
});
const res = await server.post("/api/hooks.interactive", {
body: { payload },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.response_type).toEqual("in_channel");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
});
it("should respond with replacement message if unknown user", async () => {
const { user, team } = await seed();
const document = await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const payload = JSON.stringify({
token: process.env.SLACK_VERIFICATION_TOKEN,
user: { id: "unknown-slack-user-id" },
team: { id: team.authenticationProviders[0].providerId },
callback_id: document.id,
});
const res = await server.post("/api/hooks.interactive", {
body: { payload },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.response_type).toEqual("in_channel");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
});
it("should error if incorrect verification token", async () => {
const { user } = await seed();
const payload = JSON.stringify({
token: "wrong-verification-token",
user: { id: user.authentications[0].providerId, name: user.name },
callback_id: "doesnt-matter",
});
const res = await server.post("/api/hooks.interactive", {
body: { payload },
});
expect(res.status).toEqual(401);
});
});

View File

@@ -0,0 +1,76 @@
// @flow
import Koa from "koa";
import bodyParser from "koa-body";
import Router from "koa-router";
import { NotFoundError } from "../../errors";
import errorHandling from "../../middlewares/errorHandling";
import methodOverride from "../../middlewares/methodOverride";
import validation from "../../middlewares/validation";
import apiKeys from "./apiKeys";
import attachments from "./attachments";
import auth from "./auth";
import authenticationProviders from "./authenticationProviders";
import collections from "./collections";
import documents from "./documents";
import events from "./events";
import fileOperationsRoute from "./fileOperations";
import groups from "./groups";
import hooks from "./hooks";
import integrations from "./integrations";
import apiWrapper from "./middlewares/apiWrapper";
import editor from "./middlewares/editor";
import notificationSettings from "./notificationSettings";
import revisions from "./revisions";
import shares from "./shares";
import team from "./team";
import users from "./users";
import utils from "./utils";
import views from "./views";
const api = new Koa();
const router = new Router();
// middlewares
api.use(errorHandling());
api.use(
bodyParser({
multipart: true,
formidable: { maxFieldsSize: 10 * 1024 * 1024 },
})
);
api.use(methodOverride());
api.use(validation());
api.use(apiWrapper());
api.use(editor());
// routes
router.use("/", auth.routes());
router.use("/", authenticationProviders.routes());
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());
router.use("/", shares.routes());
router.use("/", team.routes());
router.use("/", integrations.routes());
router.use("/", notificationSettings.routes());
router.use("/", attachments.routes());
router.use("/", utils.routes());
router.use("/", groups.routes());
router.use("/", fileOperationsRoute.routes());
router.post("*", (ctx) => {
ctx.throw(new NotFoundError("Endpoint not found"));
});
// Router is embedded in a Koa application wrapper, because koa-router does not
// allow middleware to catch any routes which were not explicitly defined.
api.use(router.routes());
export default api;

View File

@@ -0,0 +1,23 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { flushdb } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("POST unknown endpoint", () => {
it("should be not found", async () => {
const res = await server.post("/api/blah");
expect(res.status).toEqual(404);
});
});
describe("GET unknown endpoint", () => {
it("should be not found", async () => {
const res = await server.get("/api/blah");
expect(res.status).toEqual(404);
});
});

View File

@@ -0,0 +1,55 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { Event } from "../../models";
import Integration from "../../models/Integration";
import policy from "../../policies";
import { presentIntegration } from "../../presenters";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
const router = new Router();
router.post("integrations.list", auth(), pagination(), async (ctx) => {
let { sort = "updatedAt", direction } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Integration);
const user = ctx.state.user;
const integrations = await Integration.findAll({
where: { teamId: user.teamId },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: integrations.map(presentIntegration),
};
});
router.post("integrations.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const integration = await Integration.findByPk(id);
authorize(user, "delete", integration);
await integration.destroy();
await Event.create({
name: "integrations.delete",
modelId: integration.id,
teamId: integration.teamId,
actorId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -0,0 +1,27 @@
// @flow
import stream from "stream";
import { type Context } from "koa";
export default function apiWrapper() {
return async function apiWrapperMiddleware(
ctx: Context,
next: () => Promise<*>
) {
await next();
const ok = ctx.status < 400;
if (
typeof ctx.body !== "string" &&
!(ctx.body instanceof stream.Readable)
) {
// $FlowFixMe
ctx.body = {
// $FlowFixMe
...ctx.body,
status: ctx.status,
ok,
};
}
};
}

View File

@@ -0,0 +1,27 @@
// @flow
import { type Context } from "koa";
import pkg from "rich-markdown-editor/package.json";
import semver from "semver";
import { EditorUpdateError } from "../../../errors";
export default function editor() {
return async function editorMiddleware(ctx: Context, next: () => Promise<*>) {
const clientVersion = ctx.headers["x-editor-version"];
// If the editor version on the client is behind the current version being
// served in production by either a minor (new features), or major (breaking
// changes) then force a client reload.
if (clientVersion) {
const parsedClientVersion = semver.parse(clientVersion);
const parsedCurrentVersion = semver.parse(pkg.version);
if (
parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor
) {
throw new EditorUpdateError();
}
}
return next();
};
}

View File

@@ -0,0 +1,70 @@
// @flow
import querystring from "querystring";
import { type Context } from "koa";
import { InvalidRequestError } from "../../../errors";
export default function pagination(options?: Object) {
return async function paginationMiddleware(
ctx: Context,
next: () => Promise<*>
) {
const opts = {
defaultLimit: 15,
defaultOffset: 0,
maxLimit: 100,
...options,
};
let query = ctx.request.query;
let body = ctx.request.body;
// $FlowFixMe
let limit = query.limit || body.limit;
// $FlowFixMe
let offset = query.offset || body.offset;
if (limit && isNaN(limit)) {
throw new InvalidRequestError(`Pagination limit must be a valid number`);
}
if (offset && isNaN(offset)) {
throw new InvalidRequestError(`Pagination offset must be a valid number`);
}
limit = parseInt(limit || opts.defaultLimit, 10);
offset = parseInt(offset || opts.defaultOffset, 10);
if (limit > opts.maxLimit) {
throw new InvalidRequestError(
`Pagination limit is too large (max ${opts.maxLimit})`
);
}
if (limit <= 0) {
throw new InvalidRequestError(`Pagination limit must be greater than 0`);
}
if (offset < 0) {
throw new InvalidRequestError(
`Pagination offset must be greater than or equal to 0`
);
}
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
ctx.state.pagination = {
limit,
offset,
};
// $FlowFixMe
query.limit = ctx.state.pagination.limit;
// $FlowFixMe
query.offset = ctx.state.pagination.offset + query.limit;
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
ctx.state.pagination.nextPath = `/api${
ctx.request.path
}?${querystring.stringify(query)}`;
return next();
};
}

View File

@@ -0,0 +1,56 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import webService from "../../../services/web";
import { flushdb, seed } from "../../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#pagination", () => {
it("should allow offset and limit", async () => {
const { user } = await seed();
const res = await server.post("/api/users.list", {
body: { token: user.getJwtToken(), limit: 1, offset: 1 },
});
expect(res.status).toEqual(200);
});
it("should not allow negative limit", async () => {
const { user } = await seed();
const res = await server.post("/api/users.list", {
body: { token: user.getJwtToken(), limit: -1 },
});
expect(res.status).toEqual(400);
});
it("should not allow non-integer limit", async () => {
const { user } = await seed();
const res = await server.post("/api/users.list", {
body: { token: user.getJwtToken(), limit: "blah" },
});
expect(res.status).toEqual(400);
});
it("should not allow negative offset", async () => {
const { user } = await seed();
const res = await server.post("/api/users.list", {
body: { token: user.getJwtToken(), offset: -1 },
});
expect(res.status).toEqual(400);
});
it("should not allow non-integer offset", async () => {
const { user } = await seed();
const res = await server.post("/api/users.list", {
body: { token: user.getJwtToken(), offset: "blah" },
});
expect(res.status).toEqual(400);
});
});

View File

@@ -0,0 +1,84 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { Team, NotificationSetting } from "../../models";
import policy from "../../policies";
import { presentNotificationSetting } from "../../presenters";
const { authorize } = policy;
const router = new Router();
router.post("notificationSettings.create", auth(), async (ctx) => {
const { event } = ctx.body;
ctx.assertPresent(event, "event is required");
const user = ctx.state.user;
authorize(user, "createNotificationSetting", user.team);
const [setting] = await NotificationSetting.findOrCreate({
where: {
userId: user.id,
teamId: user.teamId,
event,
},
});
ctx.body = {
data: presentNotificationSetting(setting),
};
});
router.post("notificationSettings.list", auth(), async (ctx) => {
const user = ctx.state.user;
const settings = await NotificationSetting.findAll({
where: {
userId: user.id,
},
});
ctx.body = {
data: settings.map(presentNotificationSetting),
};
});
router.post("notificationSettings.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const setting = await NotificationSetting.findByPk(id);
authorize(user, "delete", setting);
await setting.destroy();
ctx.body = {
success: true,
};
});
router.post("notificationSettings.unsubscribe", async (ctx) => {
const { id, token } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertPresent(token, "token is required");
const setting = await NotificationSetting.findByPk(id, {
include: [
{
model: Team,
required: true,
as: "team",
},
],
});
if (setting && setting.unsubscribeToken === token) {
await setting.destroy();
ctx.redirect(`${setting.team.url}/settings/notifications?success`);
return;
}
ctx.redirect(`${process.env.URL}?notice=invalid-auth`);
});
export default router;

View File

@@ -0,0 +1,61 @@
// @flow
import Router from "koa-router";
import { NotFoundError } from "../../errors";
import auth from "../../middlewares/authentication";
import { Document, Revision } from "../../models";
import policy from "../../policies";
import { presentRevision } from "../../presenters";
import pagination from "./middlewares/pagination";
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.assertSort(sort, Revision);
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;

View File

@@ -0,0 +1,91 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { Revision } from "../../models";
import webService from "../../services/web";
import { buildDocument, buildUser } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#revisions.info", () => {
it("should return a document revision", async () => {
const { user, document } = await seed();
const revision = await Revision.createFromDocument(document);
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.createFromDocument(document);
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", () => {
it("should return a document's revisions", async () => {
const { user, document } = await seed();
await Revision.createFromDocument(document);
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();
await Revision.createFromDocument(document);
collection.permission = null;
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);
});
});

278
server/routes/api/shares.js Normal file
View File

@@ -0,0 +1,278 @@
// @flow
import Router from "koa-router";
import Sequelize from "sequelize";
import { NotFoundError } from "../../errors";
import auth from "../../middlewares/authentication";
import { Document, User, Event, Share, Team, Collection } from "../../models";
import policy from "../../policies";
import { presentShare, presentPolicies } from "../../presenters";
import pagination from "./middlewares/pagination";
const Op = Sequelize.Op;
const { authorize } = policy;
const router = new Router();
router.post("shares.info", auth(), async (ctx) => {
const { id, documentId, apiVersion } = ctx.body;
ctx.assertUuid(id || documentId, "id or documentId is required");
const user = ctx.state.user;
let shares = [];
let share = await Share.scope({
method: ["withCollection", user.id],
}).findOne({
where: id
? {
id,
revokedAt: { [Op.eq]: null },
}
: {
documentId,
teamId: user.teamId,
revokedAt: { [Op.eq]: null },
},
});
// Deprecated API response returns just the share for the current documentId
if (apiVersion !== 2) {
if (!share || !share.document) {
return (ctx.response.status = 204);
}
authorize(user, "read", share);
ctx.body = {
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
};
return;
}
// API version 2 returns the response for the current documentId and any
// parent documents that are publicly shared and accessible to the user
if (share && share.document) {
authorize(user, "read", share);
shares.push(share);
}
if (documentId) {
const document = await Document.scope("withCollection").findByPk(
documentId
);
const parentIds = document?.collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.findOne({
where: {
documentId: parentIds,
teamId: user.teamId,
revokedAt: { [Op.eq]: null },
includeChildDocuments: true,
published: true,
},
})
: undefined;
if (parentShare && parentShare.document) {
authorize(user, "read", parentShare);
shares.push(parentShare);
}
}
if (!shares.length) {
return (ctx.response.status = 204);
}
ctx.body = {
data: {
shares: shares.map((share) => presentShare(share, user.isAdmin)),
},
policies: presentPolicies(user, shares),
};
});
router.post("shares.list", auth(), pagination(), async (ctx) => {
let { sort = "updatedAt", direction } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Share);
const user = ctx.state.user;
const where = {
teamId: user.teamId,
userId: user.id,
published: true,
revokedAt: { [Op.eq]: null },
};
if (user.isAdmin) {
delete where.userId;
}
const collectionIds = await user.collectionIds();
const shares = await Share.findAll({
where,
order: [[sort, direction]],
include: [
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
},
],
},
{
model: User,
required: true,
as: "user",
},
{
model: Team,
required: true,
as: "team",
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: shares.map((share) => presentShare(share, user.isAdmin)),
policies: presentPolicies(user, shares),
};
});
router.post("shares.update", auth(), async (ctx) => {
const { id, includeChildDocuments, published } = ctx.body;
ctx.assertUuid(id, "id is required");
const { user } = ctx.state;
const team = await Team.findByPk(user.teamId);
authorize(user, "share", team);
// fetch the share with document and collection.
const share = await Share.scope({
method: ["withCollection", user.id],
}).findByPk(id);
authorize(user, "update", share);
if (published !== undefined) {
share.published = published;
// Reset nested document sharing when unpublishing a share link. So that
// If it's ever re-published this doesn't immediately share nested docs
// without forewarning the user
if (!published) {
share.includeChildDocuments = false;
}
}
if (includeChildDocuments !== undefined) {
share.includeChildDocuments = includeChildDocuments;
}
await share.save();
await Event.create({
name: "shares.update",
documentId: share.documentId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { published },
ip: ctx.request.ip,
});
ctx.body = {
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
};
});
router.post("shares.create", auth(), async (ctx) => {
const { documentId } = ctx.body;
ctx.assertPresent(documentId, "documentId is required");
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
const team = await Team.findByPk(user.teamId);
// user could be creating the share link to share with team members
authorize(user, "read", document);
const [share, isCreated] = await Share.findOrCreate({
where: {
documentId,
teamId: user.teamId,
revokedAt: null,
},
defaults: {
userId: user.id,
},
});
if (isCreated) {
await Event.create({
name: "shares.create",
documentId,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { name: document.title },
ip: ctx.request.ip,
});
}
share.team = team;
share.user = user;
share.document = document;
ctx.body = {
data: presentShare(share),
policies: presentPolicies(user, [share]),
};
});
router.post("shares.revoke", 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, "revoke", share);
const document = await Document.findByPk(share.documentId);
if (!document) {
throw new NotFoundError();
}
await share.revoke(user.id);
await Event.create({
name: "shares.revoke",
documentId: document.id,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { name: document.title },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -0,0 +1,691 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { CollectionUser } from "../../models";
import webService from "../../services/web";
import { buildUser, buildDocument, buildShare } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#shares.list", () => {
it("should only return shares created by user", async () => {
const { user, admin, document } = await seed();
await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: admin.id,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(share.id);
expect(body.data[0].documentTitle).toBe(document.title);
});
it("should not return revoked shares", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await share.revoke(user.id);
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should not return unpublished shares", async () => {
const { user, document } = await seed();
await buildShare({
published: false,
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should not return shares to deleted documents", async () => {
const { user, document } = await seed();
await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await document.delete(user.id);
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("admins should return shares created by all users", async () => {
const { user, admin, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: { token: admin.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(share.id);
expect(body.data[0].documentTitle).toBe(document.title);
});
it("admins should not return shares in collection not a member of", async () => {
const { admin, document, collection } = await seed();
await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
collection.permission = null;
await collection.save();
const res = await server.post("/api/shares.list", {
body: { token: admin.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/shares.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
});
describe("#shares.create", () => {
it("should allow creating a share record for document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.published).toBe(false);
expect(body.data.documentTitle).toBe(document.title);
});
it("should allow creating a share record with read-only permissions but no publishing", async () => {
const { user, document, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
});
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
const response = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: body.data.id, published: true },
});
expect(response.status).toEqual(403);
});
it("should allow creating a share record if link previously revoked", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await share.revoke();
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).not.toEqual(share.id);
expect(body.data.documentTitle).toBe(document.title);
});
it("should return existing share link for document and user", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
});
it("should allow creating a share record if team sharing disabled but not publishing", async () => {
const { user, document, team } = await seed();
await team.update({ sharing: false });
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
const response = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: body.data.id, published: true },
});
expect(response.status).toEqual(403);
});
it("should allow creating a share record if collection sharing disabled but not publishing", async () => {
const { user, collection, document } = await seed();
await collection.update({ sharing: false });
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
const response = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: body.data.id, published: true },
});
expect(response.status).toEqual(403);
});
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/shares.create", {
body: { documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
});
describe("#shares.info", () => {
it("should allow reading share by id", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), id: share.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
expect(body.data.createdBy.id).toBe(user.id);
});
it("should allow reading share created by deleted user", async () => {
const { user, document } = await seed();
const author = await buildUser({ teamId: user.teamId });
const share = await buildShare({
documentId: document.id,
teamId: author.teamId,
userId: author.id,
});
await author.destroy();
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), id: share.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
expect(body.data.createdBy.id).toBe(author.id);
});
it("should allow reading share by documentId", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
expect(body.data.published).toBe(true);
});
it("should find share created by another user", async () => {
const { admin, document } = await seed();
const user = await buildUser({
teamId: admin.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
expect(body.data.published).toBe(true);
});
it("should not find revoked share", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await share.revoke();
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(204);
});
it("should not find share for deleted document", async () => {
const { user, document } = await seed();
await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await document.delete(user.id);
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(204);
});
describe("apiVersion=2", () => {
it("should allow reading share by documentId", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: document.id,
apiVersion: 2,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.shares.length).toBe(1);
expect(body.data.shares[0].id).toBe(share.id);
expect(body.data.shares[0].published).toBe(true);
});
it("should return share for parent document with includeChildDocuments=true", async () => {
const { user, document, collection } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: true,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: childDocument.id,
apiVersion: 2,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.shares.length).toBe(1);
expect(body.data.shares[0].id).toBe(share.id);
expect(body.data.shares[0].documentId).toBe(document.id);
expect(body.data.shares[0].published).toBe(true);
expect(body.data.shares[0].includeChildDocuments).toBe(true);
});
it("should not return share for parent document with includeChildDocuments=false", async () => {
const { user, document, collection } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: false,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: childDocument.id,
apiVersion: 2,
},
});
expect(res.status).toEqual(204);
});
it("should return shares for parent document and current document", async () => {
const { user, document, collection } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
const share = await buildShare({
documentId: childDocument.id,
teamId: user.teamId,
userId: user.id,
includeChildDocuments: false,
});
const share2 = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: true,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: childDocument.id,
apiVersion: 2,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.shares.length).toBe(2);
expect(body.data.shares[0].id).toBe(share.id);
expect(body.data.shares[0].includeChildDocuments).toBe(false);
expect(body.data.shares[0].documentId).toBe(childDocument.id);
expect(body.data.shares[0].published).toBe(true);
expect(body.data.shares[1].id).toBe(share2.id);
expect(body.data.shares[1].documentId).toBe(document.id);
expect(body.data.shares[1].published).toBe(true);
expect(body.data.shares[1].includeChildDocuments).toBe(true);
});
});
it("should require authentication", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: { id: share.id },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const { admin, document } = await seed();
const user = await buildUser();
const share = await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), id: share.id },
});
expect(res.status).toEqual(403);
});
});
describe("#shares.update", () => {
it("should allow user to update a share", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
});
const res = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: share.id, published: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
expect(body.data.published).toBe(true);
});
it("should allow author to update a share", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: share.id, published: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
expect(body.data.published).toBe(true);
});
it("should allow admin to update a share", async () => {
const { user, admin, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.update", {
body: { token: admin.getJwtToken(), id: share.id, published: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(share.id);
expect(body.data.published).toBe(true);
});
it("should require authentication", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.update", {
body: { id: share.id, published: true },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const { admin, document } = await seed();
const user = await buildUser();
const share = await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: share.id, published: true },
});
expect(res.status).toEqual(403);
});
});
describe("#shares.revoke", () => {
it("should allow author to revoke a share", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.revoke", {
body: { token: user.getJwtToken(), id: share.id },
});
expect(res.status).toEqual(200);
});
it("should 404 if shares document is deleted", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await document.delete(user.id);
const res = await server.post("/api/shares.revoke", {
body: { token: user.getJwtToken(), id: share.id },
});
expect(res.status).toEqual(404);
});
it("should allow admin to revoke a share", async () => {
const { user, admin, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.revoke", {
body: { token: admin.getJwtToken(), id: share.id },
});
expect(res.status).toEqual(200);
});
it("should require authentication", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.revoke", {
body: { id: share.id },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const { admin, document } = await seed();
const user = await buildUser();
const share = await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/shares.revoke", {
body: { token: user.getJwtToken(), id: share.id },
});
expect(res.status).toEqual(403);
});
});

60
server/routes/api/team.js Normal file
View File

@@ -0,0 +1,60 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { Event, Team } from "../../models";
import policy from "../../policies";
import { presentTeam, presentPolicies } from "../../presenters";
const { authorize } = policy;
const router = new Router();
router.post("team.update", auth(), async (ctx) => {
const {
name,
avatarUrl,
subdomain,
sharing,
guestSignin,
documentEmbeds,
} = ctx.body;
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
authorize(user, "update", team);
if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
team.subdomain = subdomain === "" ? null : subdomain;
}
if (name) team.name = name;
if (sharing !== undefined) team.sharing = sharing;
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (guestSignin !== undefined) team.guestSignin = guestSignin;
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
const changes = team.changed();
const data = {};
await team.save();
if (changes) {
for (const change of changes) {
data[change] = team[change];
}
await Event.create({
name: "teams.update",
actorId: user.id,
teamId: user.teamId,
data,
ip: ctx.request.ip,
});
}
ctx.body = {
data: presentTeam(team),
policies: presentPolicies(user, [team]),
};
});
export default router;

View File

@@ -0,0 +1,48 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { flushdb, seed } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#team.update", () => {
it("should update team details", async () => {
const { admin } = await seed();
const res = await server.post("/api/team.update", {
body: { token: admin.getJwtToken(), name: "New name" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("New name");
});
it("should allow identical team details", async () => {
const { admin, team } = await seed();
const res = await server.post("/api/team.update", {
body: { token: admin.getJwtToken(), name: team.name },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual(team.name);
});
it("should require admin", async () => {
const { user } = await seed();
const res = await server.post("/api/team.update", {
body: { token: user.getJwtToken(), name: "New name" },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
await seed();
const res = await server.post("/api/team.update");
expect(res.status).toEqual(401);
});
});

300
server/routes/api/users.js Normal file
View File

@@ -0,0 +1,300 @@
// @flow
import Router from "koa-router";
import userDestroyer from "../../commands/userDestroyer";
import userInviter from "../../commands/userInviter";
import userSuspender from "../../commands/userSuspender";
import auth from "../../middlewares/authentication";
import { Event, User, Team } from "../../models";
import policy from "../../policies";
import { presentUser, presentPolicies } from "../../presenters";
import { Op } from "../../sequelize";
import pagination from "./middlewares/pagination";
const { can, authorize } = policy;
const router = new Router();
router.post("users.list", auth(), pagination(), async (ctx) => {
let { sort = "createdAt", query, direction, filter } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, User);
if (filter) {
ctx.assertIn(filter, [
"invited",
"viewers",
"admins",
"active",
"all",
"suspended",
]);
}
const actor = ctx.state.user;
let where = {
teamId: actor.teamId,
};
switch (filter) {
case "invited": {
where = { ...where, lastActiveAt: null };
break;
}
case "viewers": {
where = { ...where, isViewer: true };
break;
}
case "admins": {
where = { ...where, isAdmin: true };
break;
}
case "suspended": {
where = { ...where, suspendedAt: { [Op.ne]: null } };
break;
}
case "all": {
break;
}
default: {
where = { ...where, suspendedAt: { [Op.eq]: null } };
break;
}
}
if (query) {
where = {
...where,
name: {
[Op.iLike]: `%${query}%`,
},
};
}
const [users, total] = await Promise.all([
User.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
User.count({
where,
}),
]);
ctx.body = {
pagination: {
...ctx.state.pagination,
total,
},
data: users.map((user) =>
presentUser(user, { includeDetails: can(actor, "readDetails", user) })
),
policies: presentPolicies(actor, users),
};
});
router.post("users.count", auth(), async (ctx) => {
const { user } = ctx.state;
const counts = await User.getCounts(user.teamId);
ctx.body = {
data: {
counts,
},
};
});
router.post("users.info", auth(), async (ctx) => {
const { id } = ctx.body;
const actor = ctx.state.user;
const user = id ? await User.findByPk(id) : actor;
authorize(actor, "read", user);
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
router.post("users.update", auth(), async (ctx) => {
const { user } = ctx.state;
const { name, avatarUrl, language } = ctx.body;
if (name) user.name = name;
if (avatarUrl) user.avatarUrl = avatarUrl;
if (language) user.language = language;
await user.save();
await Event.create({
name: "users.update",
actorId: user.id,
userId: user.id,
teamId: user.teamId,
ip: ctx.request.ip,
});
ctx.body = {
data: presentUser(user, { includeDetails: true }),
};
});
// Admin specific
router.post("users.promote", auth(), async (ctx) => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required");
const user = await User.findByPk(userId);
authorize(actor, "promote", user);
await user.promote();
await Event.create({
name: "users.promote",
actorId: actor.id,
userId,
teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
router.post("users.demote", auth(), async (ctx) => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
let { to } = ctx.body;
const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required");
to = to === "viewer" ? "viewer" : "member";
const user = await User.findByPk(userId);
authorize(actor, "demote", user);
await user.demote(teamId, to);
await Event.create({
name: "users.demote",
actorId: actor.id,
userId,
teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
router.post("users.suspend", auth(), async (ctx) => {
const userId = ctx.body.id;
const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required");
const user = await User.findByPk(userId);
authorize(actor, "suspend", user);
await userSuspender({
user,
actorId: actor.id,
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
router.post("users.activate", auth(), async (ctx) => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required");
const user = await User.findByPk(userId);
authorize(actor, "activate", user);
await user.activate();
await Event.create({
name: "users.activate",
actorId: actor.id,
userId,
teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
router.post("users.invite", auth(), async (ctx) => {
const { invites } = ctx.body;
ctx.assertArray(invites, "invites must be an array");
const { user } = ctx.state;
const team = await Team.findByPk(user.teamId);
authorize(user, "inviteUser", team);
const response = await userInviter({ user, invites, ip: ctx.request.ip });
ctx.body = {
data: {
sent: response.sent,
users: response.users.map((user) => presentUser(user)),
},
};
});
router.post("users.delete", auth(), async (ctx) => {
const { confirmation, id } = ctx.body;
ctx.assertPresent(confirmation, "confirmation is required");
const actor = ctx.state.user;
let user = actor;
if (id) {
user = await User.findByPk(id);
}
authorize(actor, "delete", user);
await userDestroyer({
user,
actor,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -0,0 +1,573 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { buildTeam, buildAdmin, buildUser } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#users.list", () => {
it("should allow filtering by user name", async () => {
const user = await buildUser({ name: "Tester" });
// suspended user should not be returned
await buildUser({
name: "Tester",
teamId: user.teamId,
suspendedAt: new Date(),
});
const res = await server.post("/api/users.list", {
body: {
query: "test",
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(user.id);
});
it("should allow filtering to suspended users", async () => {
const user = await buildUser({ name: "Tester" });
await buildUser({
name: "Tester",
teamId: user.teamId,
suspendedAt: new Date(),
});
const res = await server.post("/api/users.list", {
body: {
query: "test",
filter: "suspended",
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
it("should allow filtering to invited", async () => {
const user = await buildUser({ name: "Tester" });
await buildUser({
name: "Tester",
teamId: user.teamId,
lastActiveAt: null,
});
const res = await server.post("/api/users.list", {
body: {
query: "test",
filter: "invited",
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
it("should return teams paginated user list", async () => {
const { admin, user } = await seed();
const res = await server.post("/api/users.list", {
body: { token: admin.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data[0].id).toEqual(user.id);
expect(body.data[1].id).toEqual(admin.id);
});
it("should require admin for detailed info", async () => {
const { user, admin } = await seed();
const res = await server.post("/api/users.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data[0].email).toEqual(undefined);
expect(body.data[1].email).toEqual(undefined);
expect(body.data[0].id).toEqual(user.id);
expect(body.data[1].id).toEqual(admin.id);
});
});
describe("#users.info", () => {
it("should return current user with no id", async () => {
const user = await buildUser();
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(user.id);
expect(body.data.name).toEqual(user.name);
expect(body.data.email).toEqual(user.email);
});
it("should return user with permission", async () => {
const user = await buildUser();
const another = await buildUser({ teamId: user.teamId });
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken(), id: another.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(another.id);
expect(body.data.name).toEqual(another.name);
// no emails of other users
expect(body.data.email).toEqual(undefined);
});
it("should now return user without permission", async () => {
const user = await buildUser();
const another = await buildUser();
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken(), id: another.id },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.info");
expect(res.status).toEqual(401);
});
});
describe("#users.invite", () => {
it("should return sent invites", async () => {
const user = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: user.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "member" }],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
});
it("should require invites to be an array", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: { email: "test@example.com", name: "Test", role: "member" },
},
});
expect(res.status).toEqual(400);
});
it("should require admin", async () => {
const admin = await buildUser();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "member" }],
},
});
expect(res.status).toEqual(403);
});
it("should invite user as an admin", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "admin" }],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
expect(body.data.users[0].isAdmin).toBeTruthy();
expect(body.data.users[0].isViewer).toBeFalsy();
});
it("should invite user as a viewer", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "viewer" }],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
expect(body.data.users[0].isViewer).toBeTruthy();
expect(body.data.users[0].isAdmin).toBeFalsy();
});
it("should invite user as a member if role is any arbitary value", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [
{ email: "test@example.com", name: "Test", role: "arbitary" },
],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
expect(body.data.users[0].isViewer).toBeFalsy();
expect(body.data.users[0].isAdmin).toBeFalsy();
});
it("should require authentication", async () => {
const res = await server.post("/api/users.invite");
expect(res.status).toEqual(401);
});
});
describe("#users.delete", () => {
it("should not allow deleting without confirmation", async () => {
const user = await buildUser();
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken() },
});
expect(res.status).toEqual(400);
});
it("should not allow deleting last admin if many users", async () => {
const user = await buildAdmin();
await buildUser({ teamId: user.teamId, isAdmin: false });
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(400);
});
it("should allow deleting user account with confirmation", async () => {
const user = await buildUser();
await buildUser({ teamId: user.teamId });
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(200);
});
it("should allow deleting pending user account with admin", async () => {
const user = await buildAdmin();
const pending = await buildUser({
teamId: user.teamId,
lastActiveAt: null,
});
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), id: pending.id, confirmation: true },
});
expect(res.status).toEqual(200);
});
it("should not allow deleting another user account", async () => {
const user = await buildAdmin();
const user2 = await buildUser({ teamId: user.teamId });
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), id: user2.id, confirmation: true },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.delete");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
});
describe("#users.update", () => {
it("should update user profile information", async () => {
const { user } = await seed();
const res = await server.post("/api/users.update", {
body: { token: user.getJwtToken(), name: "New name" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("New name");
});
it("should require authentication", async () => {
const res = await server.post("/api/users.update");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
});
describe("#users.promote", () => {
it("should promote a new admin", async () => {
const { admin, user } = await seed();
const res = await server.post("/api/users.promote", {
body: { token: admin.getJwtToken(), id: user.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.promote", {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe("#users.demote", () => {
it("should demote an admin", async () => {
const { admin, user } = await seed();
await user.update({ isAdmin: true }); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
token: admin.getJwtToken(),
id: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should demote an admin to viewer", async () => {
const { admin, user } = await seed();
await user.update({ isAdmin: true }); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
token: admin.getJwtToken(),
id: user.id,
to: "viewer",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should demote an admin to member", async () => {
const { admin, user } = await seed();
await user.update({ isAdmin: true }); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
token: admin.getJwtToken(),
id: user.id,
to: "member",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should not demote admins if only one available", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.demote", {
body: {
token: admin.getJwtToken(),
id: admin.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.promote", {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe("#users.suspend", () => {
it("should suspend an user", async () => {
const { admin, user } = await seed();
const res = await server.post("/api/users.suspend", {
body: {
token: admin.getJwtToken(),
id: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should not allow suspending the user themselves", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.suspend", {
body: {
token: admin.getJwtToken(),
id: admin.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.suspend", {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe("#users.activate", () => {
it("should activate a suspended user", async () => {
const { admin, user } = await seed();
await user.update({
suspendedById: admin.id,
suspendedAt: new Date(),
});
expect(user.isSuspended).toBe(true);
const res = await server.post("/api/users.activate", {
body: {
token: admin.getJwtToken(),
id: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.activate", {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe("#users.count", () => {
it("should count active users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count admin users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, isAdmin: true });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(1);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count suspended users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildUser({ teamId: team.id, suspendedAt: new Date() });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(2);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(1);
expect(body.data.counts.active).toEqual(1);
});
it("should count invited users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(1);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.count");
expect(res.status).toEqual(401);
});
});

View File

@@ -0,0 +1,62 @@
// @flow
import { subDays } from "date-fns";
import debug from "debug";
import Router from "koa-router";
import { documentPermanentDeleter } from "../../commands/documentPermanentDeleter";
import { AuthenticationError } from "../../errors";
import { Document, FileOperation } from "../../models";
import { Op } from "../../sequelize";
const router = new Router();
const log = debug("utils");
router.post("utils.gc", async (ctx) => {
const { token, limit = 500 } = ctx.body;
if (process.env.UTILS_SECRET !== token) {
throw new AuthenticationError("Invalid secret token");
}
log(`Permanently destroying upto ${limit} documents older than 30 days…`);
const documents = await Document.scope("withUnpublished").findAll({
attributes: ["id", "teamId", "text", "deletedAt"],
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),
},
},
paranoid: false,
limit,
});
const countDeletedDocument = await documentPermanentDeleter(documents);
log(`Destroyed ${countDeletedDocument} documents`);
log(`Expiring all the collection export older than 30 days…`);
const exports = await FileOperation.unscoped().findAll({
where: {
type: "export",
createdAt: {
[Op.lt]: subDays(new Date(), 30),
},
state: {
[Op.ne]: "expired",
},
},
});
await Promise.all(
exports.map(async (e) => {
await e.expire();
})
);
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -0,0 +1,153 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { subDays } from "date-fns";
import TestServer from "fetch-test-server";
import { Document, FileOperation } from "../../models";
import { Op } from "../../sequelize";
import webService from "../../services/web";
import { buildDocument, buildFileOperation } from "../../test/factories";
import { flushdb } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#utils.gc", () => {
it("should not destroy documents not deleted", async () => {
await buildDocument({
publishedAt: new Date(),
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
it("should not destroy documents deleted less than 30 days ago", async () => {
await buildDocument({
publishedAt: new Date(),
deletedAt: subDays(new Date(), 25),
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
it("should destroy documents deleted more than 30 days ago", async () => {
await buildDocument({
publishedAt: new Date(),
deletedAt: subDays(new Date(), 60),
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should destroy draft documents deleted more than 30 days ago", async () => {
await buildDocument({
publishedAt: undefined,
deletedAt: subDays(new Date(), 60),
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should expire exports older than 30 days ago", async () => {
await buildFileOperation({
type: "export",
state: "complete",
createdAt: subDays(new Date(), 30),
});
await buildFileOperation({
type: "export",
state: "complete",
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
const data = await FileOperation.count({
where: {
type: "export",
state: {
[Op.eq]: "expired",
},
},
});
expect(res.status).toEqual(200);
expect(data).toEqual(1);
});
it("should not expire exports made less than 30 days ago", async () => {
await buildFileOperation({
type: "export",
state: "complete",
createdAt: subDays(new Date(), 29),
});
await buildFileOperation({
type: "export",
state: "complete",
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
const data = await FileOperation.count({
where: {
type: "export",
state: {
[Op.eq]: "expired",
},
},
});
expect(res.status).toEqual(200);
expect(data).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/utils.gc");
expect(res.status).toEqual(401);
});
});

View File

@@ -0,0 +1,52 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { View, Document, Event } from "../../models";
import policy from "../../policies";
import { presentView } from "../../presenters";
const { authorize } = policy;
const router = new Router();
router.post("views.list", auth(), async (ctx) => {
const { documentId } = ctx.body;
ctx.assertUuid(documentId, "documentId is required");
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, "read", document);
const views = await View.findByDocument(documentId);
ctx.body = {
data: views.map(presentView),
};
});
router.post("views.create", auth(), async (ctx) => {
const { documentId } = ctx.body;
ctx.assertUuid(documentId, "documentId is required");
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, "read", document);
const view = await View.increment({ documentId, userId: user.id });
await Event.create({
name: "views.create",
actorId: user.id,
documentId: document.id,
collectionId: document.collectionId,
teamId: user.teamId,
data: { title: document.title },
ip: ctx.request.ip,
});
view.user = user;
ctx.body = {
data: presentView(view),
};
});
export default router;

View File

@@ -0,0 +1,126 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { View, CollectionUser } from "../../models";
import webService from "../../services/web";
import { buildUser } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#views.list", () => {
it("should return views for a document", async () => {
const { user, document } = await seed();
await View.increment({ documentId: document.id, userId: user.id });
const res = await server.post("/api/views.list", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data[0].count).toBe(1);
expect(body.data[0].user.name).toBe(user.name);
});
it("should return views for a document in read-only collection", async () => {
const { user, document, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
});
await View.increment({ documentId: document.id, userId: user.id });
const res = await server.post("/api/views.list", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data[0].count).toBe(1);
expect(body.data[0].user.name).toBe(user.name);
});
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/views.list", {
body: { documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post("/api/views.list", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
});
describe("#views.create", () => {
it("should allow creating a view record for document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/views.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.count).toBe(1);
});
it("should allow creating a view record for document in read-only collection", async () => {
const { user, document, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
});
const res = await server.post("/api/views.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.count).toBe(1);
});
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/views.create", {
body: { documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post("/api/views.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
});