chore: Move to Typescript (#2783)

This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously.

closes #1282
This commit is contained in:
Tom Moor
2021-11-29 06:40:55 -08:00
committed by GitHub
parent 25ccfb5d04
commit 15b1069bcc
1017 changed files with 17410 additions and 54942 deletions

View File

@@ -1,10 +1,9 @@
// @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 auth from "@server/middlewares/authentication";
import { ApiKey, Event } from "@server/models";
import policy from "@server/policies";
import { presentApiKey } from "@server/presenters";
import { assertUuid, assertPresent } from "@server/validation";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
@@ -12,25 +11,23 @@ const router = new Router();
router.post("apiKeys.create", auth(), async (ctx) => {
const { name } = ctx.body;
ctx.assertPresent(name, "name is required");
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 },
data: {
name,
},
ip: ctx.request.ip,
});
ctx.body = {
data: presentApiKey(key),
};
@@ -46,7 +43,6 @@ router.post("apiKeys.list", auth(), pagination(), async (ctx) => {
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: keys.map(presentApiKey),
@@ -55,23 +51,21 @@ router.post("apiKeys.list", auth(), pagination(), async (ctx) => {
router.post("apiKeys.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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 },
data: {
name: key.name,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};

View File

@@ -1,30 +1,30 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import { Attachment } from "../../models";
import webService from "../../services/web";
import { Attachment } from "@server/models";
import webService from "@server/services/web";
import {
buildUser,
buildAdmin,
buildCollection,
buildAttachment,
buildDocument,
} from "../../test/factories";
import { flushdb } from "../../test/support";
} from "@server/test/factories";
import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
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");
@@ -38,9 +38,11 @@ describe("#attachments.delete", () => {
userId: user.id,
});
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
});
@@ -51,14 +53,14 @@ describe("#attachments.delete", () => {
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 },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
});
@@ -68,14 +70,14 @@ describe("#attachments.delete", () => {
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 },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
});
@@ -83,14 +85,14 @@ describe("#attachments.delete", () => {
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 },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
});
expect(res.status).toEqual(403);
});
@@ -99,14 +101,14 @@ describe("#attachments.delete", () => {
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 },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
});
expect(res.status).toEqual(403);
});
@@ -126,15 +128,15 @@ describe("#attachments.delete", () => {
documentId: document.id,
acl: "private",
});
const res = await server.post("/api/attachments.delete", {
body: { token: user.getJwtToken(), id: attachment.id },
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");
@@ -148,10 +150,12 @@ describe("#attachments.redirect", () => {
userId: user.id,
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
redirect: "manual",
});
expect(res.status).toEqual(302);
});
@@ -173,10 +177,12 @@ describe("#attachments.redirect", () => {
userId: user.id,
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
redirect: "manual",
});
expect(res.status).toEqual(302);
});
@@ -197,12 +203,13 @@ describe("#attachments.redirect", () => {
userId: user.id,
documentId: document.id,
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
redirect: "manual",
});
expect(res.status).toEqual(302);
});
@@ -222,11 +229,12 @@ describe("#attachments.redirect", () => {
documentId: document.id,
acl: "private",
});
const res = await server.post("/api/attachments.redirect", {
body: { token: user.getJwtToken(), id: attachment.id },
body: {
token: user.getJwtToken(),
id: attachment.id,
},
});
expect(res.status).toEqual(403);
});
});

View File

@@ -1,33 +1,30 @@
// @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 { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { Attachment, Document, Event } from "@server/models";
import policy from "@server/policies";
import {
makePolicy,
getSignature,
publicS3Endpoint,
makeCredential,
getSignedUrl,
} from "../../utils/s3";
} from "@server/utils/s3";
import { assertPresent } from "@server/validation";
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 { name, documentId, contentType, size } = ctx.body;
assertPresent(name, "name is required");
assertPresent(contentType, "contentType is required");
assertPresent(size, "size is required");
const { user } = ctx.state;
authorize(user, "createAttachment", user.team);
const s3Key = uuidv4();
const acl =
ctx.body.public === undefined
@@ -35,7 +32,6 @@ router.post("attachments.create", auth(), async (ctx) => {
: ctx.body.public
? "public-read"
: "private";
const bucket = acl === "public-read" ? "public" : "uploads";
const key = `${bucket}/${user.id}/${s3Key}/${name}`;
const credential = makeCredential();
@@ -45,7 +41,9 @@ router.post("attachments.create", auth(), async (ctx) => {
const url = `${endpoint}/${key}`;
if (documentId) {
const document = await Document.findByPk(documentId, { userId: user.id });
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "update", document);
}
@@ -59,15 +57,15 @@ router.post("attachments.create", auth(), async (ctx) => {
teamId: user.teamId,
userId: user.id,
});
await Event.create({
name: "attachments.create",
data: { name },
data: {
name,
},
teamId: user.teamId,
userId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
data: {
maxUploadSize: process.env.AWS_S3_UPLOAD_MAX_SIZE,
@@ -96,13 +94,13 @@ router.post("attachments.create", auth(), async (ctx) => {
});
router.post("attachments.delete", auth(), async (ctx) => {
let { id } = ctx.body;
ctx.assertPresent(id, "id is required");
const { id } = ctx.body;
assertPresent(id, "id is required");
const user = ctx.state.user;
const attachment = await Attachment.findByPk(id);
if (!attachment) {
throw new NotFoundError();
throw NotFoundError();
}
if (attachment.documentId) {
@@ -114,14 +112,12 @@ router.post("attachments.delete", auth(), async (ctx) => {
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,
};
@@ -129,12 +125,12 @@ router.post("attachments.delete", auth(), async (ctx) => {
router.post("attachments.redirect", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
assertPresent(id, "id is required");
const user = ctx.state.user;
const attachment = await Attachment.findByPk(id);
if (!attachment) {
throw new NotFoundError();
throw NotFoundError();
}
if (attachment.isPrivate) {

View File

@@ -1,24 +1,25 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { buildUser, buildTeam } from "../../test/factories";
import { flushdb } from "../../test/support";
import webService from "@server/services/web";
import { buildUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/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 user = await buildUser({
teamId: team.id,
});
const res = await server.post("/api/auth.info", {
body: { token: user.getJwtToken() },
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);
@@ -26,12 +27,14 @@ describe("#auth.info", () => {
it("should require the team to not be deleted", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const user = await buildUser({
teamId: team.id,
});
await team.destroy();
const res = await server.post("/api/auth.info", {
body: { token: user.getJwtToken() },
body: {
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(401);
});
@@ -41,12 +44,10 @@ describe("#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");
@@ -55,7 +56,6 @@ describe("#auth.config", () => {
it("should return available providers for team subdomain", async () => {
process.env.URL = "http://localoutline.com";
await buildTeam({
guestSignin: false,
subdomain: "example",
@@ -67,10 +67,11 @@ describe("#auth.config", () => {
],
});
const res = await server.post("/api/auth.config", {
headers: { host: `example.localoutline.com` },
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");
@@ -88,10 +89,11 @@ describe("#auth.config", () => {
],
});
const res = await server.post("/api/auth.config", {
headers: { host: "docs.mycompany.com" },
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");
@@ -99,7 +101,6 @@ describe("#auth.config", () => {
it("should return email provider for team when guest signin enabled", async () => {
process.env.URL = "http://localoutline.com";
await buildTeam({
guestSignin: true,
subdomain: "example",
@@ -111,10 +112,11 @@ describe("#auth.config", () => {
],
});
const res = await server.post("/api/auth.config", {
headers: { host: "example.localoutline.com" },
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");
@@ -123,7 +125,6 @@ describe("#auth.config", () => {
it("should not return provider when disabled", async () => {
process.env.URL = "http://localoutline.com";
await buildTeam({
guestSignin: false,
subdomain: "example",
@@ -136,18 +137,17 @@ describe("#auth.config", () => {
],
});
const res = await server.post("/api/auth.config", {
headers: { host: "example.localoutline.com" },
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: [
@@ -159,15 +159,12 @@ describe("#auth.config", () => {
});
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: [
@@ -179,7 +176,6 @@ describe("#auth.config", () => {
});
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");

View File

@@ -1,16 +1,18 @@
// @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 { parseDomain, isCustomSubdomain } from "@shared/utils/domains";
import auth from "@server/middlewares/authentication";
import { Team } from "@server/models";
import { presentUser, presentTeam, presentPolicies } from "@server/presenters";
import { isCustomDomain } from "@server/utils/domains";
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'providers' implicitly has type 'any[]' i... Remove this comment to see the full error message
import providers from "../auth/providers";
const router = new Router();
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'team' implicitly has an 'any' type.
function filterProviders(team) {
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'providers' implicitly has an 'any[]' typ... Remove this comment to see the full error message
return providers
.sort((provider) => (provider.id === "email" ? 1 : -1))
.filter((provider) => {
@@ -22,7 +24,10 @@ function filterProviders(team) {
return (
!team ||
find(team.authenticationProviders, { name: provider.id, enabled: true })
find(team.authenticationProviders, {
name: provider.id,
enabled: true,
})
);
})
.map((provider) => ({
@@ -53,7 +58,9 @@ router.post("auth.config", async (ctx) => {
if (isCustomDomain(ctx.request.hostname)) {
const team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: ctx.request.hostname },
where: {
domain: ctx.request.hostname,
},
});
if (team) {
@@ -78,7 +85,9 @@ router.post("auth.config", async (ctx) => {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
const team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain },
where: {
subdomain,
},
});
if (team) {
@@ -96,6 +105,7 @@ router.post("auth.config", async (ctx) => {
// Otherwise, we're requesting from the standard root signin page
ctx.body = {
data: {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 1 arguments, but got 0.
providers: filterProviders(),
},
};
@@ -104,10 +114,11 @@ router.post("auth.config", async (ctx) => {
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 }),
user: presentUser(user, {
includeDetails: true,
}),
team: presentTeam(team),
},
policies: presentPolicies(user, [team]),

View File

@@ -1,9 +1,9 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
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";
import webService from "@server/services/web";
import { buildUser, buildAdmin, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
@@ -14,9 +14,10 @@ afterAll(() => server.close());
describe("#authenticationProviders.info", () => {
it("should return auth provider", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
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,
@@ -24,7 +25,6 @@ describe("#authenticationProviders.info", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("slack");
expect(body.data.isEnabled).toBe(true);
@@ -37,7 +37,6 @@ describe("#authenticationProviders.info", () => {
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,
@@ -50,7 +49,6 @@ describe("#authenticationProviders.info", () => {
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,
@@ -59,13 +57,13 @@ describe("#authenticationProviders.info", () => {
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 user = await buildAdmin({
teamId: team.id,
});
const authenticationProviders = await team.getAuthenticationProviders();
const res = await server.post("/api/authenticationProviders.update", {
body: {
id: authenticationProviders[0].id,
@@ -73,19 +71,19 @@ describe("#authenticationProviders.update", () => {
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 });
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,
@@ -94,7 +92,6 @@ describe("#authenticationProviders.update", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("slack");
expect(body.data.isEnabled).toBe(false);
@@ -103,9 +100,10 @@ describe("#authenticationProviders.update", () => {
it("should require authorization", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
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,
@@ -119,7 +117,6 @@ describe("#authenticationProviders.update", () => {
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,
@@ -129,17 +126,18 @@ describe("#authenticationProviders.update", () => {
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 user = await buildUser({
teamId: team.id,
});
const res = await server.post("/api/authenticationProviders.list", {
body: { token: user.getJwtToken() },
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");

View File

@@ -1,12 +1,13 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { AuthenticationProvider, Event } from "../../models";
import policy from "../../policies";
import auth from "@server/middlewares/authentication";
import { AuthenticationProvider, Event } from "@server/models";
import policy from "@server/policies";
import {
presentAuthenticationProvider,
presentPolicies,
} from "../../presenters";
} from "@server/presenters";
import { assertUuid, assertPresent } from "@server/validation";
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'allAuthenticationProviders' implicitly h... Remove this comment to see the full error message
import allAuthenticationProviders from "../auth/providers";
const router = new Router();
@@ -14,12 +15,10 @@ const { authorize } = policy;
router.post("authenticationProviders.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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]),
@@ -28,14 +27,13 @@ router.post("authenticationProviders.info", auth(), async (ctx) => {
router.post("authenticationProviders.update", auth(), async (ctx) => {
const { id, isEnabled } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertPresent(isEnabled, "isEnabled is required");
assertUuid(id, "id is required");
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 {
@@ -44,13 +42,14 @@ router.post("authenticationProviders.update", auth(), async (ctx) => {
await Event.create({
name: "authenticationProviders.update",
data: { enabled },
data: {
enabled,
},
modelId: id,
teamId: user.teamId,
actorId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
data: presentAuthenticationProvider(authenticationProvider),
policies: presentPolicies(user, [authenticationProvider]),
@@ -60,17 +59,16 @@ router.post("authenticationProviders.update", auth(), async (ctx) => {
router.post("authenticationProviders.list", auth(), async (ctx) => {
const user = ctx.state.user;
authorize(user, "read", user.team);
const teamAuthenticationProviders = await user.team.getAuthenticationProviders();
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'allAuthenticationProviders' implicitly h... Remove this comment to see the full error message
const otherAuthenticationProviders = allAuthenticationProviders.filter(
(p) =>
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 't' implicitly has an 'any' type.
!teamAuthenticationProviders.find((t) => t.name === p.id) &&
p.enabled &&
// email auth is dealt with separetly right now, although it definitely
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: [

View File

@@ -1,9 +1,8 @@
// @flow
import fractionalIndex from "fractional-index";
import Router from "koa-router";
import collectionExporter from "../../commands/collectionExporter";
import { ValidationError } from "../../errors";
import auth from "../../middlewares/authentication";
import collectionExporter from "@server/commands/collectionExporter";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import {
Collection,
CollectionUser,
@@ -13,8 +12,8 @@ import {
User,
Group,
Attachment,
} from "../../models";
import policy from "../../policies";
} from "@server/models";
import policy from "@server/policies";
import {
presentCollection,
presentUser,
@@ -23,11 +22,17 @@ import {
presentGroup,
presentCollectionGroupMembership,
presentFileOperation,
} from "../../presenters";
import { Op, sequelize } from "../../sequelize";
import collectionIndexing from "../../utils/collectionIndexing";
import removeIndexCollision from "../../utils/removeIndexCollision";
} from "@server/presenters";
import { Op, sequelize } from "@server/sequelize";
import collectionIndexing from "@server/utils/collectionIndexing";
import removeIndexCollision from "@server/utils/removeIndexCollision";
import {
assertUuid,
assertIn,
assertPresent,
assertHexColor,
assertIndexCharacters,
} from "@server/validation";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
@@ -43,19 +48,20 @@ router.post("collections.create", auth(), async (ctx) => {
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
let { index } = ctx.body;
ctx.assertPresent(name, "name is required");
assertPresent(name, "name is required");
if (color) {
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
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 },
where: {
teamId: user.teamId,
deletedAt: null,
},
attributes: ["id", "index", "updatedAt"],
limit: 1,
order: [
@@ -66,7 +72,7 @@ router.post("collections.create", auth(), async (ctx) => {
});
if (index) {
ctx.assertIndexCharacters(
assertIndexCharacters(
index,
"Index characters must be between x20 to x7E ASCII"
);
@@ -78,7 +84,6 @@ router.post("collections.create", auth(), async (ctx) => {
}
index = await removeIndexCollision(user.teamId, index);
let collection = await Collection.create({
name,
description,
@@ -91,21 +96,20 @@ router.post("collections.create", auth(), async (ctx) => {
sort,
index,
});
await Event.create({
name: "collections.create",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { name },
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]),
@@ -114,14 +118,12 @@ router.post("collections.create", auth(), async (ctx) => {
router.post("collections.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
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]),
@@ -130,24 +132,22 @@ router.post("collections.info", auth(), async (ctx) => {
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");
assertIn(type, ["outline"], "type must be one of 'outline'");
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 },
data: {
type,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
@@ -155,17 +155,14 @@ router.post("collections.import", auth(), async (ctx) => {
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");
assertUuid(id, "id is required");
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,
@@ -190,10 +187,12 @@ router.post("collections.add_group", auth(), async (ctx) => {
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
data: { name: group.name, groupId },
data: {
name: group.name,
groupId,
},
ip: ctx.request.ip,
});
ctx.body = {
data: {
collectionGroupMemberships: [
@@ -205,28 +204,26 @@ router.post("collections.add_group", auth(), async (ctx) => {
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");
assertUuid(id, "id is required");
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 },
data: {
name: group.name,
groupId,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
@@ -238,19 +235,15 @@ router.post(
pagination(),
async (ctx) => {
const { id, query, permission } = ctx.body;
ctx.assertUuid(id, "id is required");
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) {
@@ -262,10 +255,8 @@ router.post(
}
if (permission) {
where = {
...where,
permission,
};
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ permission: any; collectionId: any; }' is ... Remove this comment to see the full error message
where = { ...where, permission };
}
const memberships = await CollectionGroup.findAll({
@@ -282,13 +273,13 @@ router.post(
},
],
});
ctx.body = {
pagination: ctx.state.pagination,
data: {
collectionGroupMemberships: memberships.map(
presentCollectionGroupMembership
),
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'membership' implicitly has an 'any' typ... Remove this comment to see the full error message
groups: memberships.map((membership) => presentGroup(membership.group)),
},
};
@@ -297,17 +288,14 @@ router.post(
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");
assertUuid(id, "id is required");
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,
@@ -333,10 +321,11 @@ router.post("collections.add_user", auth(), async (ctx) => {
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
data: { name: user.name },
data: {
name: user.name,
},
ip: ctx.request.ip,
});
ctx.body = {
data: {
users: [presentUser(user)],
@@ -347,47 +336,40 @@ router.post("collections.add_user", auth(), async (ctx) => {
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");
assertUuid(id, "id is required");
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 },
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");
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),
};
@@ -395,18 +377,15 @@ router.post("collections.users", auth(), async (ctx) => {
router.post("collections.memberships", auth(), pagination(), async (ctx) => {
const { id, query, permission } = ctx.body;
ctx.assertUuid(id, "id is required");
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) {
@@ -418,10 +397,8 @@ router.post("collections.memberships", auth(), pagination(), async (ctx) => {
}
if (permission) {
where = {
...where,
permission,
};
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ permission: any; collectionId: any; }' is ... Remove this comment to see the full error message
where = { ...where, permission };
}
const memberships = await CollectionUser.findAll({
@@ -438,11 +415,11 @@ router.post("collections.memberships", auth(), pagination(), async (ctx) => {
},
],
});
ctx.body = {
pagination: ctx.state.pagination,
data: {
memberships: memberships.map(presentMembership),
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'membership' implicitly has an 'any' typ... Remove this comment to see the full error message
users: memberships.map((membership) => presentUser(membership.user)),
},
};
@@ -450,29 +427,26 @@ router.post("collections.memberships", auth(), pagination(), async (ctx) => {
router.post("collections.export", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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");
assertPresent(collection, "Collection should be present");
authorize(user, "read", collection);
const fileOperation = await collectionExporter({
collection,
user,
team,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
data: { fileOperation: presentFileOperation(fileOperation) },
data: {
fileOperation: presentFileOperation(fileOperation),
},
};
});
@@ -480,21 +454,21 @@ 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 fileOperation = await collectionExporter({
user,
team,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
data: { fileOperation: presentFileOperation(fileOperation) },
data: {
fileOperation: presentFileOperation(fileOperation),
},
};
});
router.post("collections.update", auth(), async (ctx) => {
let {
const {
id,
name,
description,
@@ -506,14 +480,13 @@ router.post("collections.update", auth(), async (ctx) => {
} = ctx.body;
if (color) {
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
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
@@ -537,18 +510,22 @@ router.post("collections.update", auth(), async (ctx) => {
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) {
// frontend sends empty string
ctx.assertIn(
assertIn(
permission,
["read_write", "read", "", null],
"Invalid permission"
@@ -556,22 +533,25 @@ router.post("collections.update", auth(), async (ctx) => {
privacyChanged = permission !== collection.permission;
collection.permission = permission ? permission : null;
}
if (sharing !== undefined) {
sharingChanged = sharing !== collection.sharing;
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 },
data: {
name,
},
ip: ctx.request.ip,
});
@@ -581,7 +561,10 @@ router.post("collections.update", auth(), async (ctx) => {
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { privacyChanged, sharingChanged },
data: {
privacyChanged,
sharingChanged,
},
ip: ctx.request.ip,
});
}
@@ -601,7 +584,7 @@ router.post("collections.update", auth(), async (ctx) => {
router.post("collections.list", auth(), pagination(), async (ctx) => {
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
let collections = await Collection.scope({
const collections = await Collection.scope({
method: ["withMembership", user.id],
}).findAll({
where: {
@@ -612,13 +595,14 @@ router.post("collections.list", auth(), pagination(), async (ctx) => {
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const nullIndexCollection = collections.findIndex(
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'collection' implicitly has an 'any' typ... Remove this comment to see the full error message
(collection) => collection.index === null
);
if (nullIndexCollection !== -1) {
const indexedCollections = await collectionIndexing(ctx.state.user.teamId);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'collection' implicitly has an 'any' typ... Remove this comment to see the full error message
collections.forEach((collection) => {
collection.index = indexedCollections[collection.id];
});
@@ -634,28 +618,27 @@ router.post("collections.list", auth(), pagination(), async (ctx) => {
router.post("collections.delete", auth(), async (ctx) => {
const { id } = ctx.body;
const user = ctx.state.user;
ctx.assertUuid(id, "id is required");
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");
if (total === 1) throw 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 },
data: {
name: collection.name,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
@@ -664,35 +647,35 @@ router.post("collections.delete", auth(), async (ctx) => {
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(
assertPresent(index, "index is required");
assertIndexCharacters(
index,
"Index characters must be between x20 to x7E ASCII"
);
ctx.assertUuid(id, "id must be a uuid");
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 collection.update({
index,
});
await Event.create({
name: "collections.move",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: { index },
data: {
index,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
data: { index },
data: {
index,
},
};
});
export default router;

View File

@@ -1,18 +1,16 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { buildEvent, buildUser } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
import webService from "@server/services/web";
import { buildEvent, buildUser } from "@server/test/factories";
import { flushdb, seed } from "@server/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",
@@ -20,7 +18,6 @@ describe("#events.list", () => {
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
@@ -29,12 +26,12 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken() },
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);
@@ -42,7 +39,6 @@ describe("#events.list", () => {
it("should return audit events", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
@@ -50,7 +46,6 @@ describe("#events.list", () => {
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
@@ -59,12 +54,13 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true },
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);
@@ -73,7 +69,6 @@ describe("#events.list", () => {
it("should allow filtering by actorId", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
@@ -81,7 +76,6 @@ describe("#events.list", () => {
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
await buildEvent({
name: "documents.publish",
@@ -90,12 +84,14 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true, actorId: admin.id },
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);
@@ -103,7 +99,6 @@ describe("#events.list", () => {
it("should allow filtering by documentId", async () => {
const { user, admin, document, collection } = await seed();
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
@@ -111,7 +106,6 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: admin.getJwtToken(),
@@ -119,7 +113,6 @@ describe("#events.list", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
@@ -128,7 +121,6 @@ describe("#events.list", () => {
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,
@@ -136,7 +128,6 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: actor.getJwtToken(),
@@ -144,14 +135,12 @@ describe("#events.list", () => {
},
});
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",
@@ -159,7 +148,6 @@ describe("#events.list", () => {
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
@@ -168,7 +156,6 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
@@ -176,7 +163,6 @@ describe("#events.list", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
@@ -184,7 +170,6 @@ describe("#events.list", () => {
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",
@@ -193,15 +178,13 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: user.id,
});
await user.destroy();
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken() },
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);
@@ -210,16 +193,17 @@ describe("#events.list", () => {
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 },
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

@@ -1,10 +1,10 @@
// @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 auth from "@server/middlewares/authentication";
import { Event, User, Collection } from "@server/models";
import policy from "@server/policies";
import { presentEvent } from "@server/presenters";
import { assertSort, assertUuid } from "@server/validation";
import pagination from "./middlewares/pagination";
const Op = Sequelize.Op;
@@ -13,47 +13,53 @@ const router = new Router();
router.post("events.list", auth(), pagination(), async (ctx) => {
const user = ctx.state.user;
let {
let { direction } = ctx.body;
const {
sort = "createdAt",
actorId,
documentId,
collectionId,
direction,
name,
auditLog = false,
} = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Event);
assertSort(sort, Event);
let where = {
name: Event.ACTIVITY_EVENTS,
teamId: user.teamId,
};
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
assertUuid(actorId, "actorId must be a UUID");
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ actorId: any; name: any; teamId: any; }' i... Remove this comment to see the full error message
where = { ...where, actorId };
}
if (documentId) {
ctx.assertUuid(documentId, "documentId must be a UUID");
assertUuid(documentId, "documentId must be a UUID");
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ documentId: any; name: any; teamId: any; }... Remove this comment to see the full error message
where = { ...where, documentId };
}
if (collectionId) {
ctx.assertUuid(collectionId, "collection must be a UUID");
assertUuid(collectionId, "collection must be a UUID");
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ collectionId: any; name: any; teamId: any;... Remove this comment to see the full error message
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 });
const collectionIds = await user.collectionIds({
paranoid: false,
});
where = {
...where,
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ [Sequelize.Op.or]: { collectionId: any; }[... Remove this comment to see the full error message
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: collectionIds,
},
{
collectionId: {
[Op.eq]: null,
@@ -85,9 +91,9 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'event' implicitly has an 'any' type.
data: events.map((event) => presentEvent(event, auditLog)),
};
});

View File

@@ -1,41 +1,41 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import { Collection, User, Event, FileOperation } from "../../models";
import webService from "../../services/web";
import { Collection, User, Event, FileOperation } from "@server/models";
import webService from "@server/services/web";
import {
buildAdmin,
buildCollection,
buildFileOperation,
buildTeam,
buildUser,
} from "../../test/factories";
import { flushdb } from "../../test/support";
} from "@server/test/factories";
import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
const mS3 = {
deleteObject: jest.fn().mockReturnThis(),
promise: jest.fn(),
};
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
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 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,
@@ -43,9 +43,7 @@ describe("#fileOperations.info", () => {
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);
@@ -53,14 +51,17 @@ describe("#fileOperations.info", () => {
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 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,
@@ -68,31 +69,28 @@ describe("#fileOperations.info", () => {
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 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);
@@ -102,29 +100,27 @@ describe("#fileOperations.list", () => {
it("should return exports with collection data", async () => {
const team = await buildTeam();
const admin = 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,
});
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);
@@ -135,35 +131,30 @@ describe("#fileOperations.list", () => {
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 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);
@@ -174,36 +165,33 @@ describe("#fileOperations.list", () => {
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 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);
@@ -214,55 +202,54 @@ describe("#fileOperations.list", () => {
it("should require authorization", async () => {
const user = await buildUser();
const res = await server.post("/api/fileOperations.list", {
body: { token: user.getJwtToken(), type: "export" },
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 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("export 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 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);
@@ -271,43 +258,44 @@ describe("#fileOperations.info", () => {
it("should require authorization", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser({ teamId: team.id });
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);
});
});
describe("#fileOperations.delete", () => {
it("should delete file operation", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const admin = await buildAdmin({
teamId: team.id,
});
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
state: "complete",
});
const deleteResponse = await server.post("/api/fileOperations.delete", {
body: {
token: admin.getJwtToken(),
id: exportData.id,
},
});
expect(deleteResponse.status).toBe(200);
expect(await Event.count()).toBe(1);
expect(await FileOperation.count()).toBe(0);

View File

@@ -1,12 +1,12 @@
// @flow
import Router from "koa-router";
import fileOperationDeleter from "../../commands/fileOperationDeleter";
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 fileOperationDeleter from "@server/commands/fileOperationDeleter";
import { NotFoundError, ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { FileOperation, Team } from "@server/models";
import policy from "@server/policies";
import { presentFileOperation } from "@server/presenters";
import { getSignedUrl } from "@server/utils/s3";
import { assertPresent, assertIn, assertUuid } from "@server/validation";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
@@ -14,16 +14,14 @@ const router = new Router();
router.post("fileOperations.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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();
throw NotFoundError();
}
ctx.body = {
@@ -32,27 +30,23 @@ router.post("fileOperations.info", auth(), async (ctx) => {
});
router.post("fileOperations.list", auth(), pagination(), async (ctx) => {
let { sort = "createdAt", direction, type } = ctx.body;
ctx.assertPresent(type, "type is required");
ctx.assertIn(
let { direction } = ctx.body;
const { sort = "createdAt", type } = ctx.body;
assertPresent(type, "type is required");
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,
@@ -64,57 +58,51 @@ router.post("fileOperations.list", auth(), pagination(), async (ctx) => {
where,
}),
]);
ctx.body = {
pagination: {
...ctx.state.pagination,
total,
},
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");
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();
throw NotFoundError();
}
authorize(user, fileOp.type, team);
if (fileOp.state !== "complete") {
throw new ValidationError(`${fileOp.type} is not complete yet`);
throw ValidationError(`${fileOp.type} is not complete yet`);
}
const accessUrl = await getSignedUrl(fileOp.key);
ctx.redirect(accessUrl);
});
router.post("fileOperations.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
assertUuid(id, "id is required");
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
const fileOp = await FileOperation.findByPk(id);
if (!fileOp) {
throw new NotFoundError();
throw NotFoundError();
}
authorize(user, fileOp.type, team);
await fileOperationDeleter(fileOp, user, ctx.request.ip);
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -1,39 +1,39 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
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";
import { Event } from "@server/models";
import webService from "@server/services/web";
import { buildUser, buildAdmin, buildGroup } from "@server/test/factories";
import { flushdb } from "@server/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 },
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" },
body: {
id: group.id,
name: "Test",
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -42,7 +42,11 @@ describe("#groups.update", () => {
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" },
body: {
token: user.getJwtToken(),
id: group.id,
name: "Test",
},
});
expect(res.status).toEqual(403);
});
@@ -50,165 +54,196 @@ describe("#groups.update", () => {
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" },
body: {
token: user.getJwtToken(),
id: group.id,
name: "Test",
},
});
expect(res.status).toEqual(403);
});
describe("when user is admin", () => {
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'user' implicitly has type 'any' in some ... Remove this comment to see the full error message
let user, group;
beforeEach(async () => {
user = await buildAdmin();
group = await buildGroup({ teamId: user.teamId });
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" },
body: {
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'user' implicitly has an 'any' type.
token: user.getJwtToken(),
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'group' implicitly has an 'any' type.
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 },
body: {
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'user' implicitly has an 'any' type.
token: user.getJwtToken(),
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'group' implicitly has an 'any' type.
id: group.id,
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'group' implicitly has an 'any' type.
name: group.name,
},
});
const events = await Event.findAll();
expect(events.length).toEqual(0);
const body = await res.json();
expect(res.status).toEqual(200);
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'group' implicitly has an 'any' type.
expect(body.data.name).toBe(group.name);
});
it("fails with validation error when name already taken", async () => {
await buildGroup({
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'user' implicitly has an 'any' type.
teamId: user.teamId,
name: "test",
});
const res = await server.post("/api/groups.update", {
body: {
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'user' implicitly has an 'any' type.
token: user.getJwtToken(),
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'group' implicitly has an 'any' type.
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 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() },
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 } });
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() },
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 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 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 still return group if non-member, non-admin", async () => {
const user = await buildUser();
const group = await buildGroup({ teamId: user.teamId });
const group = await buildGroup({
teamId: user.teamId,
});
const res = await server.post("/api/groups.info", {
body: { token: user.getJwtToken(), id: group.id },
body: {
token: user.getJwtToken(),
id: group.id,
},
});
const body = await res.json();
@@ -220,7 +255,6 @@ describe("#groups.info", () => {
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();
});
@@ -229,20 +263,23 @@ describe("#groups.info", () => {
const user = await buildUser();
const group = await buildGroup();
const res = await server.post("/api/groups.info", {
body: { token: user.getJwtToken(), id: group.id },
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 },
body: {
id: group.id,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -251,7 +288,10 @@ describe("#groups.delete", () => {
const group = await buildGroup();
const user = await buildUser();
const res = await server.post("/api/groups.delete", {
body: { token: user.getJwtToken(), id: group.id },
body: {
token: user.getJwtToken(),
id: group.id,
},
});
expect(res.status).toEqual(403);
});
@@ -259,40 +299,49 @@ describe("#groups.delete", () => {
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 },
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 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 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);
@@ -302,16 +351,32 @@ describe("#groups.memberships", () => {
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 } });
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(),
@@ -320,7 +385,6 @@ describe("#groups.memberships", () => {
},
});
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);
@@ -329,7 +393,6 @@ describe("#groups.memberships", () => {
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();
});
@@ -337,21 +400,21 @@ describe("#groups.memberships", () => {
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 },
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(),
@@ -359,7 +422,6 @@ describe("#groups.add_user", () => {
userId: user.id,
},
});
const users = await group.getUsers();
expect(res.status).toEqual(200);
expect(users.length).toEqual(1);
@@ -376,7 +438,6 @@ describe("#groups.add_user", () => {
teamId: user.teamId,
});
const anotherUser = await buildUser();
const res = await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
@@ -384,88 +445,7 @@ describe("#groups.add_user", () => {
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();
});
@@ -478,7 +458,56 @@ describe("#groups.remove_user", () => {
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(),
@@ -487,7 +516,26 @@ describe("#groups.remove_user", () => {
},
});
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();
});

View File

@@ -1,30 +1,29 @@
// @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 { MAX_AVATAR_DISPLAY } from "@shared/constants";
import auth from "@server/middlewares/authentication";
import { User, Event, Group, GroupUser } from "@server/models";
import policy from "@server/policies";
import {
presentGroup,
presentPolicies,
presentUser,
presentGroupMembership,
} from "../../presenters";
import { Op } from "../../sequelize";
} from "@server/presenters";
import { Op } from "@server/sequelize";
import { assertPresent, assertUuid, assertSort } from "@server/validation";
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;
let { direction } = ctx.body;
const { sort = "updatedAt" } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Group);
assertSort(sort, Group);
const user = ctx.state.user;
let groups = await Group.findAll({
const groups = await Group.findAll({
where: {
teamId: user.teamId,
},
@@ -38,8 +37,10 @@ router.post("groups.list", auth(), pagination(), async (ctx) => {
data: {
groups: groups.map(presentGroup),
groupMemberships: groups
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'g' implicitly has an 'any' type.
.map((g) =>
g.groupMemberships
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'membership' implicitly has an 'any' typ... Remove this comment to see the full error message
.filter((membership) => !!membership.user)
.slice(0, MAX_AVATAR_DISPLAY)
)
@@ -52,12 +53,11 @@ router.post("groups.list", auth(), pagination(), async (ctx) => {
router.post("groups.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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]),
@@ -66,29 +66,27 @@ router.post("groups.info", auth(), async (ctx) => {
router.post("groups.create", auth(), async (ctx) => {
const { name } = ctx.body;
ctx.assertPresent(name, "name is required");
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 },
data: {
name: group.name,
},
ip: ctx.request.ip,
});
ctx.body = {
data: presentGroup(group),
policies: presentPolicies(user, [group]),
@@ -97,14 +95,12 @@ router.post("groups.create", auth(), async (ctx) => {
router.post("groups.update", auth(), async (ctx) => {
const { id, name } = ctx.body;
ctx.assertPresent(name, "name is required");
ctx.assertUuid(id, "id is required");
assertPresent(name, "name is required");
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()) {
@@ -114,7 +110,9 @@ router.post("groups.update", auth(), async (ctx) => {
teamId: user.teamId,
actorId: user.id,
modelId: group.id,
data: { name },
data: {
name,
},
ip: ctx.request.ip,
});
}
@@ -127,23 +125,22 @@ router.post("groups.update", auth(), async (ctx) => {
router.post("groups.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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 },
data: {
name: group.name,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
@@ -151,13 +148,13 @@ router.post("groups.delete", auth(), async (ctx) => {
router.post("groups.memberships", auth(), pagination(), async (ctx) => {
const { id, query } = ctx.body;
ctx.assertUuid(id, "id is required");
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: {
@@ -167,7 +164,9 @@ router.post("groups.memberships", auth(), pagination(), async (ctx) => {
}
const memberships = await GroupUser.findAll({
where: { groupId: id },
where: {
groupId: id,
},
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
@@ -180,11 +179,11 @@ router.post("groups.memberships", auth(), pagination(), async (ctx) => {
},
],
});
ctx.body = {
pagination: ctx.state.pagination,
data: {
groupMemberships: memberships.map(presentGroupMembership),
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'membership' implicitly has an 'any' typ... Remove this comment to see the full error message
users: memberships.map((membership) => presentUser(membership.user)),
},
};
@@ -192,15 +191,13 @@ router.post("groups.memberships", auth(), pagination(), async (ctx) => {
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");
assertUuid(id, "id is required");
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,
@@ -210,9 +207,10 @@ router.post("groups.add_user", auth(), async (ctx) => {
if (!membership) {
await group.addUser(user, {
through: { createdById: ctx.state.user.id },
through: {
createdById: ctx.state.user.id,
},
});
// reload to get default scope
membership = await GroupUser.findOne({
where: {
@@ -220,17 +218,17 @@ router.post("groups.add_user", auth(), async (ctx) => {
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 },
data: {
name: user.name,
},
ip: ctx.request.ip,
});
}
@@ -246,30 +244,27 @@ router.post("groups.add_user", auth(), async (ctx) => {
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");
assertUuid(id, "id is required");
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 },
data: {
name: user.name,
},
ip: ctx.request.ip,
});
// reload to get default scope
group = await Group.findByPk(id);
ctx.body = {
data: {
groups: [presentGroup(group)],

View File

@@ -1,21 +1,18 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import { IntegrationAuthentication, SearchQuery } from "../../models";
import webService from "../../services/web";
import { buildDocument, buildIntegration } from "../../test/factories";
import { flushdb, seed } from "../../test/support";
import * as Slack from "../../utils/slack";
import { IntegrationAuthentication, SearchQuery } from "@server/models";
import webService from "@server/services/web";
import { buildDocument, buildIntegration } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
import * as Slack from "@server/utils/slack";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
jest.mock("../../utils/slack", () => ({
post: jest.fn(),
}));
describe("#hooks.unfurl", () => {
it("should return documents", async () => {
const { user, document } = await seed();
@@ -25,7 +22,6 @@ describe("#hooks.unfurl", () => {
teamId: user.teamId,
token: "",
});
const res = await server.post("/api/hooks.unfurl", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
@@ -49,11 +45,9 @@ describe("#hooks.unfurl", () => {
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,
@@ -132,7 +126,7 @@ describe("#hooks.slack", () => {
"This title *contains* a search term"
);
});
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '(done: DoneCallback) => Promise<... Remove this comment to see the full error message
it("should save search term, hits and source", async (done) => {
const { user, team } = await seed();
await server.post("/api/hooks.slack", {
@@ -143,12 +137,13 @@ describe("#hooks.slack", () => {
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" },
where: {
query: "contains",
},
});
expect(searchQuery.length).toBe(1);
expect(searchQuery[0].results).toBe(0);
@@ -189,7 +184,6 @@ describe("#hooks.slack", () => {
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",
@@ -197,7 +191,6 @@ describe("#hooks.slack", () => {
teamId: user.teamId,
publishedAt: null,
});
const document = await buildDocument({
text: "This title contains a search term",
userId: user.id,
@@ -224,14 +217,12 @@ describe("#hooks.slack", () => {
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,
@@ -257,7 +248,6 @@ describe("#hooks.slack", () => {
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",
@@ -269,7 +259,6 @@ describe("#hooks.slack", () => {
expect(res.status).toEqual(401);
});
});
describe("#hooks.interactive", () => {
it("should respond with replacement message", async () => {
const { user, team } = await seed();
@@ -278,15 +267,20 @@ describe("#hooks.interactive", () => {
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 },
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 },
body: {
payload,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
@@ -302,15 +296,20 @@ describe("#hooks.interactive", () => {
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 },
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 },
body: {
payload,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
@@ -323,11 +322,16 @@ describe("#hooks.interactive", () => {
const { user } = await seed();
const payload = JSON.stringify({
token: "wrong-verification-token",
user: { id: user.authentications[0].providerId, name: user.name },
user: {
id: user.authentications[0].providerId,
name: user.name,
},
callback_id: "doesnt-matter",
});
const res = await server.post("/api/hooks.interactive", {
body: { payload },
body: {
payload,
},
});
expect(res.status).toEqual(401);
});

View File

@@ -1,7 +1,6 @@
// @flow
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { AuthenticationError, InvalidRequestError } from "../../errors";
import { AuthenticationError, InvalidRequestError } from "@server/errors";
import {
UserAuthentication,
AuthenticationProvider,
@@ -11,9 +10,11 @@ import {
SearchQuery,
Integration,
IntegrationAuthentication,
} from "../../models";
import { presentSlackAttachment } from "../../presenters";
import * as Slack from "../../utils/slack";
} from "@server/models";
import { presentSlackAttachment } from "@server/presenters";
import * as Slack from "@server/utils/slack";
import { assertPresent } from "@server/validation";
const router = new Router();
// triggered by a user posting a getoutline.com link in Slack
@@ -22,13 +23,15 @@ router.post("hooks.unfurl", async (ctx) => {
if (challenge) return (ctx.body = ctx.body.challenge);
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
throw new AuthenticationError("Invalid token");
throw AuthenticationError("Invalid token");
}
const user = await User.findOne({
include: [
{
where: { providerId: event.user },
where: {
providerId: event.user,
},
model: UserAuthentication,
as: "authentications",
required: true,
@@ -36,19 +39,20 @@ router.post("hooks.unfurl", async (ctx) => {
],
});
if (!user) return;
const auth = await IntegrationAuthentication.findOne({
where: { service: "slack", teamId: user.teamId },
where: {
service: "slack",
teamId: user.teamId,
},
});
if (!auth) return;
// get content for unfurled links
let unfurls = {};
for (let link of event.links) {
const unfurls = {};
for (const 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(),
@@ -67,27 +71,28 @@ router.post("hooks.unfurl", async (ctx) => {
// 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");
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");
assertPresent(token, "token is required");
assertPresent(callback_id, "callback_id is required");
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
throw new AuthenticationError("Invalid verification token");
throw 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");
throw 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",
@@ -106,12 +111,12 @@ router.post("hooks.interactive", async (ctx) => {
// 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");
assertPresent(token, "token is required");
assertPresent(team_id, "team_id is required");
assertPresent(user_id, "user_id is required");
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
throw new AuthenticationError("Invalid verification token");
throw AuthenticationError("Invalid verification token");
}
// Handle "help" command or no input
@@ -130,7 +135,6 @@ router.post("hooks.slack", async (ctx) => {
}
let user, team;
// attempt to find the corresponding team for this request based on the team_id
team = await Team.findOne({
include: [
@@ -153,7 +157,9 @@ router.post("hooks.slack", async (ctx) => {
},
include: [
{
where: { teamId: team.id },
where: {
teamId: team.id,
},
model: User,
as: "user",
required: true,
@@ -201,14 +207,12 @@ router.post("hooks.slack", async (ctx) => {
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,
@@ -216,17 +220,16 @@ router.post("hooks.slack", async (ctx) => {
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,

View File

@@ -1,20 +1,18 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { flushdb } from "../../test/support";
import webService from "@server/services/web";
import { flushdb } from "@server/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");

View File

@@ -1,12 +1,9 @@
// @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 { NotFoundError } from "@server/errors";
import errorHandling from "@server/middlewares/errorHandling";
import methodOverride from "@server/middlewares/methodOverride";
import apiKeys from "./apiKeys";
import attachments from "./attachments";
import auth from "./auth";
@@ -18,7 +15,6 @@ 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";
@@ -37,11 +33,12 @@ api.use(errorHandling());
api.use(
bodyParser({
multipart: true,
formidable: { maxFieldsSize: 10 * 1024 * 1024 },
formidable: {
maxFieldsSize: 10 * 1024 * 1024,
},
})
);
api.use(methodOverride());
api.use(validation());
api.use(apiWrapper());
api.use(editor());
@@ -66,7 +63,7 @@ router.use("/", groups.routes());
router.use("/", fileOperationsRoute.routes());
router.post("*", (ctx) => {
ctx.throw(new NotFoundError("Endpoint not found"));
ctx.throw(NotFoundError("Endpoint not found"));
});
// Router is embedded in a Koa application wrapper, because koa-router does not

View File

@@ -1,28 +1,30 @@
// @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 auth from "@server/middlewares/authentication";
import { Event } from "@server/models";
import Integration from "@server/models/Integration";
import policy from "@server/policies";
import { presentIntegration } from "@server/presenters";
import { assertSort, assertUuid } from "@server/validation";
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;
let { direction } = ctx.body;
const { sort = "updatedAt" } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Integration);
assertSort(sort, Integration);
const user = ctx.state.user;
const integrations = await Integration.findAll({
where: { teamId: user.teamId },
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),
@@ -31,14 +33,12 @@ router.post("integrations.list", auth(), pagination(), async (ctx) => {
router.post("integrations.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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,
@@ -46,7 +46,6 @@ router.post("integrations.delete", auth(), async (ctx) => {
actorId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};

View File

@@ -1,23 +1,20 @@
// @flow
import stream from "stream";
import { type Context } from "koa";
import { Context } from "koa";
export default function apiWrapper() {
return async function apiWrapperMiddleware(
ctx: Context,
next: () => Promise<*>
next: () => Promise<any>
) {
await next();
const ok = ctx.status < 400;
if (
typeof ctx.body !== "string" &&
!(ctx.body instanceof stream.Readable)
) {
// $FlowFixMe
ctx.body = {
// $FlowFixMe
// @ts-expect-error ts-migrate(2698) FIXME: Spread types may only be created from object types... Remove this comment to see the full error message
...ctx.body,
status: ctx.status,
ok,

View File

@@ -1,11 +1,14 @@
// @flow
import { type Context } from "koa";
import { Context } from "koa";
import pkg from "rich-markdown-editor/package.json";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'semv... Remove this comment to see the full error message
import semver from "semver";
import { EditorUpdateError } from "../../../errors";
import { EditorUpdateError } from "@server/errors";
export default function editor() {
return async function editorMiddleware(ctx: Context, next: () => Promise<*>) {
return async function editorMiddleware(
ctx: Context,
next: () => Promise<any>
) {
const clientVersion = ctx.headers["x-editor-version"];
// If the editor version on the client is behind the current version being
@@ -19,9 +22,10 @@ export default function editor() {
parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor
) {
throw new EditorUpdateError();
throw EditorUpdateError();
}
}
return next();
};
}

View File

@@ -1,56 +1,66 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import webService from "../../../services/web";
import { flushdb, seed } from "../../../test/support";
import webService from "@server/services/web";
import { flushdb, seed } from "@server/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 },
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 },
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" },
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 },
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" },
body: {
token: user.getJwtToken(),
offset: "blah",
},
});
expect(res.status).toEqual(400);
});
});

View File

@@ -1,12 +1,11 @@
// @flow
import querystring from "querystring";
import { type Context } from "koa";
import { InvalidRequestError } from "../../../errors";
import { Context } from "koa";
import { InvalidRequestError } from "@server/errors";
export default function pagination(options?: Object) {
export default function pagination(options?: Record<string, any>) {
return async function paginationMiddleware(
ctx: Context,
next: () => Promise<*>
next: () => Promise<any>
) {
const opts = {
defaultLimit: 15,
@@ -14,53 +13,46 @@ export default function pagination(options?: Object) {
maxLimit: 100,
...options,
};
let query = ctx.request.query;
let body = ctx.request.body;
// $FlowFixMe
const query = ctx.request.query;
const body = ctx.request.body;
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`);
throw InvalidRequestError(`Pagination limit must be a valid number`);
}
if (offset && isNaN(offset)) {
throw new InvalidRequestError(`Pagination offset must be a valid number`);
throw 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(
throw InvalidRequestError(
`Pagination limit is too large (max ${opts.maxLimit})`
);
}
if (limit <= 0) {
throw new InvalidRequestError(`Pagination limit must be greater than 0`);
throw InvalidRequestError(`Pagination limit must be greater than 0`);
}
if (offset < 0) {
throw new InvalidRequestError(
throw 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)}`;

View File

@@ -1,21 +1,19 @@
// @flow
import Router from "koa-router";
import auth from "../../middlewares/authentication";
import { Team, NotificationSetting } from "../../models";
import policy from "../../policies";
import { presentNotificationSetting } from "../../presenters";
import auth from "@server/middlewares/authentication";
import { Team, NotificationSetting } from "@server/models";
import policy from "@server/policies";
import { presentNotificationSetting } from "@server/presenters";
import { assertPresent, assertUuid } from "@server/validation";
const { authorize } = policy;
const router = new Router();
router.post("notificationSettings.create", auth(), async (ctx) => {
const { event } = ctx.body;
ctx.assertPresent(event, "event is required");
assertPresent(event, "event is required");
const user = ctx.state.user;
authorize(user, "createNotificationSetting", user.team);
const [setting] = await NotificationSetting.findOrCreate({
where: {
userId: user.id,
@@ -23,7 +21,6 @@ router.post("notificationSettings.create", auth(), async (ctx) => {
event,
},
});
ctx.body = {
data: presentNotificationSetting(setting),
};
@@ -36,7 +33,6 @@ router.post("notificationSettings.list", auth(), async (ctx) => {
userId: user.id,
},
});
ctx.body = {
data: settings.map(presentNotificationSetting),
};
@@ -44,14 +40,12 @@ router.post("notificationSettings.list", auth(), async (ctx) => {
router.post("notificationSettings.delete", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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,
};
@@ -59,8 +53,8 @@ router.post("notificationSettings.delete", auth(), async (ctx) => {
router.post("notificationSettings.unsubscribe", async (ctx) => {
const { id, token } = ctx.body;
ctx.assertUuid(id, "id is required");
ctx.assertPresent(token, "token is required");
assertUuid(id, "id is required");
assertPresent(token, "token is required");
const setting = await NotificationSetting.findByPk(id, {
include: [

View File

@@ -1,21 +1,18 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
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";
import { Revision } from "@server/models";
import webService from "@server/services/web";
import { buildDocument, buildUser } from "@server/test/factories";
import { flushdb, seed } from "@server/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(),
@@ -23,7 +20,6 @@ describe("#revisions.info", () => {
},
});
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);
@@ -32,7 +28,6 @@ describe("#revisions.info", () => {
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: {
@@ -43,12 +38,10 @@ describe("#revisions.info", () => {
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(),
@@ -56,7 +49,6 @@ describe("#revisions.list", () => {
},
});
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);
@@ -66,14 +58,14 @@ describe("#revisions.list", () => {
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 },
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
expect(res.status).toEqual(403);
});

View File

@@ -1,23 +1,23 @@
// @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 { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { Document, Revision } from "@server/models";
import policy from "@server/policies";
import { presentRevision } from "@server/presenters";
import { assertPresent, assertSort } from "@server/validation";
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 { id } = ctx.body;
assertPresent(id, "id is required");
const user = ctx.state.user;
const revision = await Revision.findByPk(id);
if (!revision) {
throw new NotFoundError();
throw NotFoundError();
}
const document = await Document.findByPk(revision.documentId, {
@@ -32,26 +32,29 @@ router.post("revisions.info", auth(), async (ctx) => {
});
router.post("revisions.list", auth(), pagination(), async (ctx) => {
let { documentId, sort = "updatedAt", direction } = ctx.body;
let { direction } = ctx.body;
const { documentId, sort = "updatedAt" } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Revision);
ctx.assertPresent(documentId, "documentId is required");
assertSort(sort, Revision);
assertPresent(documentId, "documentId is required");
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
const revisions = await Revision.findAll({
where: { documentId: document.id },
where: {
documentId: document.id,
},
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'revision' implicitly has an 'any' type.
revisions.map((revision) => presentRevision(revision))
);
ctx.body = {
pagination: ctx.state.pagination,
data,

View File

@@ -1,16 +1,14 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
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";
import { CollectionUser } from "@server/models";
import webService from "@server/services/web";
import { buildUser, buildDocument, buildShare } from "@server/test/factories";
import { flushdb, seed } from "@server/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();
@@ -25,10 +23,11 @@ describe("#shares.list", () => {
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
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);
@@ -43,12 +42,12 @@ describe("#shares.list", () => {
userId: user.id,
});
await share.revoke(user.id);
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
@@ -61,12 +60,12 @@ describe("#shares.list", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
@@ -78,14 +77,13 @@ describe("#shares.list", () => {
teamId: user.teamId,
userId: user.id,
});
await document.delete(user.id);
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
@@ -98,10 +96,11 @@ describe("#shares.list", () => {
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: { token: admin.getJwtToken() },
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);
@@ -115,15 +114,14 @@ describe("#shares.list", () => {
teamId: admin.teamId,
userId: admin.id,
});
collection.permission = null;
await collection.save();
const res = await server.post("/api/shares.list", {
body: { token: admin.getJwtToken() },
body: {
token: admin.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
@@ -131,20 +129,20 @@ describe("#shares.list", () => {
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 },
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);
@@ -153,26 +151,28 @@ describe("#shares.create", () => {
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 },
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 },
body: {
token: user.getJwtToken(),
id: body.data.id,
published: true,
},
});
expect(response.status).toEqual(403);
});
@@ -185,10 +185,12 @@ describe("#shares.create", () => {
});
await share.revoke();
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
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);
@@ -202,53 +204,70 @@ describe("#shares.create", () => {
userId: user.id,
});
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
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 });
await team.update({
sharing: false,
});
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
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 },
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 });
await collection.update({
sharing: false,
});
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
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 },
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 },
body: {
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -257,12 +276,14 @@ describe("#shares.create", () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
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();
@@ -271,12 +292,13 @@ describe("#shares.info", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), id: share.id },
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);
@@ -284,20 +306,22 @@ describe("#shares.info", () => {
it("should allow reading share created by deleted user", async () => {
const { user, document } = await seed();
const author = await buildUser({ teamId: user.teamId });
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 },
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);
@@ -310,12 +334,13 @@ describe("#shares.info", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
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);
@@ -332,10 +357,12 @@ describe("#shares.info", () => {
userId: admin.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
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);
@@ -350,7 +377,10 @@ describe("#shares.info", () => {
});
await share.revoke();
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
expect(res.status).toEqual(204);
});
@@ -364,11 +394,13 @@ describe("#shares.info", () => {
});
await document.delete(user.id);
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
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();
@@ -377,7 +409,6 @@ describe("#shares.info", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
@@ -386,13 +417,11 @@ describe("#shares.info", () => {
},
});
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({
@@ -406,9 +435,7 @@ describe("#shares.info", () => {
userId: user.id,
includeChildDocuments: true,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
@@ -417,7 +444,6 @@ describe("#shares.info", () => {
},
});
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);
@@ -427,7 +453,6 @@ describe("#shares.info", () => {
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.update).toBe(true);
});
it("should not return share for parent document with includeChildDocuments=false", async () => {
const { user, document, collection } = await seed();
const childDocument = await buildDocument({
@@ -441,9 +466,7 @@ describe("#shares.info", () => {
userId: user.id,
includeChildDocuments: false,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
@@ -451,10 +474,8 @@ describe("#shares.info", () => {
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({
@@ -474,9 +495,7 @@ describe("#shares.info", () => {
userId: user.id,
includeChildDocuments: true,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
@@ -485,14 +504,12 @@ describe("#shares.info", () => {
},
});
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);
@@ -508,10 +525,11 @@ describe("#shares.info", () => {
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: { id: share.id },
body: {
id: share.id,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -525,12 +543,14 @@ describe("#shares.info", () => {
userId: admin.id,
});
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), id: share.id },
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();
@@ -538,12 +558,14 @@ describe("#shares.update", () => {
documentId: document.id,
teamId: user.teamId,
});
const res = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: share.id, published: true },
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);
@@ -556,12 +578,14 @@ describe("#shares.update", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: share.id, published: true },
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);
@@ -574,12 +598,14 @@ describe("#shares.update", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.update", {
body: { token: admin.getJwtToken(), id: share.id, published: true },
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);
@@ -593,10 +619,12 @@ describe("#shares.update", () => {
userId: user.id,
});
const res = await server.post("/api/shares.update", {
body: { id: share.id, published: true },
body: {
id: share.id,
published: true,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -610,12 +638,15 @@ describe("#shares.update", () => {
userId: admin.id,
});
const res = await server.post("/api/shares.update", {
body: { token: user.getJwtToken(), id: share.id, published: true },
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();
@@ -624,9 +655,11 @@ describe("#shares.revoke", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.revoke", {
body: { token: user.getJwtToken(), id: share.id },
body: {
token: user.getJwtToken(),
id: share.id,
},
});
expect(res.status).toEqual(200);
});
@@ -638,11 +671,12 @@ describe("#shares.revoke", () => {
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 },
body: {
token: user.getJwtToken(),
id: share.id,
},
});
expect(res.status).toEqual(404);
});
@@ -654,9 +688,11 @@ describe("#shares.revoke", () => {
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.revoke", {
body: { token: admin.getJwtToken(), id: share.id },
body: {
token: admin.getJwtToken(),
id: share.id,
},
});
expect(res.status).toEqual(200);
});
@@ -669,10 +705,11 @@ describe("#shares.revoke", () => {
userId: user.id,
});
const res = await server.post("/api/shares.revoke", {
body: { id: share.id },
body: {
id: share.id,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -686,7 +723,10 @@ describe("#shares.revoke", () => {
userId: admin.id,
});
const res = await server.post("/api/shares.revoke", {
body: { token: user.getJwtToken(), id: share.id },
body: {
token: user.getJwtToken(),
id: share.id,
},
});
expect(res.status).toEqual(403);
});

View File

@@ -1,35 +1,39 @@
// @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 { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { Document, User, Event, Share, Team, Collection } from "@server/models";
import policy from "@server/policies";
import { presentShare, presentPolicies } from "@server/presenters";
import { assertUuid, assertSort, assertPresent } from "@server/validation";
import pagination from "./middlewares/pagination";
const Op = Sequelize.Op;
const { authorize } = policy;
const router = new Router();
// @ts-expect-error ts-migrate(7030) FIXME: Not all code paths return a value.
router.post("shares.info", auth(), async (ctx) => {
const { id, documentId, apiVersion } = ctx.body;
ctx.assertUuid(id || documentId, "id or documentId is required");
assertUuid(id || documentId, "id or documentId is required");
const user = ctx.state.user;
let shares = [];
let share = await Share.scope({
const shares = [];
const share = await Share.scope({
method: ["withCollection", user.id],
}).findOne({
where: id
? {
id,
revokedAt: { [Op.eq]: null },
revokedAt: {
[Op.eq]: null,
},
}
: {
documentId,
teamId: user.teamId,
revokedAt: { [Op.eq]: null },
revokedAt: {
[Op.eq]: null,
},
},
});
@@ -40,7 +44,6 @@ router.post("shares.info", auth(), async (ctx) => {
}
authorize(user, "read", share);
ctx.body = {
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
@@ -60,7 +63,6 @@ router.post("shares.info", auth(), async (ctx) => {
documentId
);
const parentIds = document?.collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.scope({
method: ["withCollection", user.id],
@@ -68,7 +70,9 @@ router.post("shares.info", auth(), async (ctx) => {
where: {
documentId: parentIds,
teamId: user.teamId,
revokedAt: { [Op.eq]: null },
revokedAt: {
[Op.eq]: null,
},
includeChildDocuments: true,
published: true,
},
@@ -94,16 +98,19 @@ router.post("shares.info", auth(), async (ctx) => {
});
router.post("shares.list", auth(), pagination(), async (ctx) => {
let { sort = "updatedAt", direction } = ctx.body;
let { direction } = ctx.body;
const { sort = "updatedAt" } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, Share);
assertSort(sort, Share);
const user = ctx.state.user;
const where = {
teamId: user.teamId,
userId: user.id,
published: true,
revokedAt: { [Op.eq]: null },
revokedAt: {
[Op.eq]: null,
},
};
if (user.isAdmin) {
@@ -146,9 +153,9 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'share' implicitly has an 'any' type.
data: shares.map((share) => presentShare(share, user.isAdmin)),
policies: presentPolicies(user, shares),
};
@@ -156,17 +163,15 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
router.post("shares.update", auth(), async (ctx) => {
const { id, includeChildDocuments, published } = ctx.body;
ctx.assertUuid(id, "id is required");
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) {
@@ -185,17 +190,17 @@ router.post("shares.update", auth(), async (ctx) => {
}
await share.save();
await Event.create({
name: "shares.update",
documentId: share.documentId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { published },
data: {
published,
},
ip: ctx.request.ip,
});
ctx.body = {
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
@@ -204,10 +209,12 @@ router.post("shares.update", auth(), async (ctx) => {
router.post("shares.create", auth(), async (ctx) => {
const { documentId } = ctx.body;
ctx.assertPresent(documentId, "documentId is required");
assertPresent(documentId, "documentId is required");
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
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);
@@ -231,7 +238,9 @@ router.post("shares.create", auth(), async (ctx) => {
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { name: document.title },
data: {
name: document.title,
},
ip: ctx.request.ip,
});
}
@@ -239,7 +248,6 @@ router.post("shares.create", auth(), async (ctx) => {
share.team = team;
share.user = user;
share.document = document;
ctx.body = {
data: presentShare(share),
policies: presentPolicies(user, [share]),
@@ -248,19 +256,18 @@ router.post("shares.create", auth(), async (ctx) => {
router.post("shares.revoke", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertUuid(id, "id is required");
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();
throw NotFoundError();
}
await share.revoke(user.id);
await Event.create({
name: "shares.revoke",
documentId: document.id,
@@ -268,10 +275,11 @@ router.post("shares.revoke", auth(), async (ctx) => {
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { name: document.title },
data: {
name: document.title,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};

View File

@@ -1,22 +1,22 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import webService from "@server/services/web";
import { flushdb, seed } from "@server/test/support";
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" },
body: {
token: admin.getJwtToken(),
name: "New name",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("New name");
});
@@ -24,15 +24,18 @@ describe("#team.update", () => {
it("should only allow member,viewer or admin as default role", async () => {
const { admin } = await seed();
const res = await server.post("/api/team.update", {
body: { token: admin.getJwtToken(), defaultUserRole: "New name" },
body: {
token: admin.getJwtToken(),
defaultUserRole: "New name",
},
});
expect(res.status).toEqual(400);
const successRes = await server.post("/api/team.update", {
body: { token: admin.getJwtToken(), defaultUserRole: "viewer" },
body: {
token: admin.getJwtToken(),
defaultUserRole: "viewer",
},
});
const body = await successRes.json();
expect(successRes.status).toEqual(200);
expect(body.data.defaultUserRole).toBe("viewer");
@@ -41,10 +44,12 @@ describe("#team.update", () => {
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 },
body: {
token: admin.getJwtToken(),
name: team.name,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual(team.name);
});
@@ -52,7 +57,10 @@ describe("#team.update", () => {
it("should require admin", async () => {
const { user } = await seed();
const res = await server.post("/api/team.update", {
body: { token: user.getJwtToken(), name: "New name" },
body: {
token: user.getJwtToken(),
name: "New name",
},
});
expect(res.status).toEqual(403);
});

View File

@@ -1,10 +1,8 @@
// @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";
import auth from "@server/middlewares/authentication";
import { Event, Team } from "@server/models";
import policy from "@server/policies";
import { presentTeam, presentPolicies } from "@server/presenters";
const { authorize } = policy;
const router = new Router();
@@ -33,16 +31,17 @@ router.post("team.update", auth(), async (ctx) => {
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (guestSignin !== undefined) team.guestSignin = guestSignin;
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
if (collaborativeEditing !== undefined) {
team.collaborativeEditing = collaborativeEditing;
}
if (defaultUserRole !== undefined) {
team.defaultUserRole = defaultUserRole;
}
const changes = team.changed();
const data = {};
await team.save();
if (changes) {

View File

@@ -1,27 +1,24 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import webService from "@server/services/web";
import { buildTeam, buildAdmin, buildUser } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
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" });
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",
@@ -29,20 +26,20 @@ describe("#users.list", () => {
},
});
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" });
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",
@@ -51,19 +48,19 @@ describe("#users.list", () => {
},
});
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" });
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",
@@ -72,19 +69,18 @@ describe("#users.list", () => {
},
});
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() },
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);
@@ -94,10 +90,11 @@ describe("#users.list", () => {
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() },
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);
@@ -106,15 +103,15 @@ describe("#users.list", () => {
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() },
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);
@@ -123,16 +120,19 @@ describe("#users.info", () => {
it("should return user with permission", async () => {
const user = await buildUser();
const another = await buildUser({ teamId: user.teamId });
const another = await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken(), id: another.id },
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);
});
@@ -141,9 +141,11 @@ describe("#users.info", () => {
const user = await buildUser();
const another = await buildUser();
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken(), id: another.id },
body: {
token: user.getJwtToken(),
id: another.id,
},
});
expect(res.status).toEqual(403);
});
@@ -152,14 +154,19 @@ describe("#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" }],
invites: [
{
email: "test@example.com",
name: "Test",
role: "member",
},
],
},
});
const body = await res.json();
@@ -172,7 +179,11 @@ describe("#users.invite", () => {
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: { email: "test@example.com", name: "Test", role: "member" },
invites: {
email: "test@example.com",
name: "Test",
role: "member",
},
},
});
expect(res.status).toEqual(400);
@@ -183,7 +194,13 @@ describe("#users.invite", () => {
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "member" }],
invites: [
{
email: "test@example.com",
name: "Test",
role: "member",
},
],
},
});
expect(res.status).toEqual(403);
@@ -194,7 +211,13 @@ describe("#users.invite", () => {
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "admin" }],
invites: [
{
email: "test@example.com",
name: "Test",
role: "admin",
},
],
},
});
const body = await res.json();
@@ -209,7 +232,13 @@ describe("#users.invite", () => {
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "viewer" }],
invites: [
{
email: "test@example.com",
name: "Test",
role: "viewer",
},
],
},
});
const body = await res.json();
@@ -225,7 +254,11 @@ describe("#users.invite", () => {
body: {
token: admin.getJwtToken(),
invites: [
{ email: "test@example.com", name: "Test", role: "arbitary" },
{
email: "test@example.com",
name: "Test",
role: "arbitary",
},
],
},
});
@@ -241,32 +274,42 @@ describe("#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() },
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 });
await buildUser({
teamId: user.teamId,
isAdmin: false,
});
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), confirmation: true },
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 });
await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), confirmation: true },
body: {
token: user.getJwtToken(),
confirmation: true,
},
});
expect(res.status).toEqual(200);
});
@@ -278,16 +321,26 @@ describe("#users.delete", () => {
lastActiveAt: null,
});
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), id: pending.id, confirmation: true },
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 user2 = await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/users.delete", {
body: { token: user.getJwtToken(), id: user2.id, confirmation: true },
body: {
token: user.getJwtToken(),
id: user2.id,
confirmation: true,
},
});
expect(res.status).toEqual(403);
});
@@ -295,20 +348,20 @@ describe("#users.delete", () => {
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" },
body: {
token: user.getJwtToken(),
name: "New name",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("New name");
});
@@ -316,21 +369,20 @@ describe("#users.update", () => {
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 },
body: {
token: admin.getJwtToken(),
id: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
@@ -338,19 +390,22 @@ describe("#users.promote", () => {
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.promote", {
body: { token: user.getJwtToken(), id: user.id },
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
await user.update({
isAdmin: true,
}); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
@@ -359,14 +414,15 @@ describe("#users.demote", () => {
},
});
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
await user.update({
isAdmin: true,
}); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
@@ -376,14 +432,15 @@ describe("#users.demote", () => {
},
});
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
await user.update({
isAdmin: true,
}); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
@@ -393,14 +450,12 @@ describe("#users.demote", () => {
},
});
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(),
@@ -408,7 +463,6 @@ describe("#users.demote", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
@@ -416,19 +470,19 @@ describe("#users.demote", () => {
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.promote", {
body: { token: user.getJwtToken(), id: user.id },
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(),
@@ -436,7 +490,6 @@ describe("#users.suspend", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
@@ -450,7 +503,6 @@ describe("#users.suspend", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
@@ -458,15 +510,16 @@ describe("#users.suspend", () => {
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.suspend", {
body: { token: user.getJwtToken(), id: user.id },
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();
@@ -474,7 +527,6 @@ describe("#users.activate", () => {
suspendedById: admin.id,
suspendedAt: new Date(),
});
expect(user.isSuspended).toBe(true);
const res = await server.post("/api/users.activate", {
body: {
@@ -483,7 +535,6 @@ describe("#users.activate", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
@@ -491,24 +542,28 @@ describe("#users.activate", () => {
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.activate", {
body: { token: user.getJwtToken(), id: user.id },
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 user = await buildUser({
teamId: team.id,
});
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
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);
@@ -519,12 +574,16 @@ describe("#users.count", () => {
it("should count admin users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, isAdmin: true });
const user = await buildUser({
teamId: team.id,
isAdmin: true,
});
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
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);
@@ -535,13 +594,19 @@ describe("#users.count", () => {
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 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() },
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);
@@ -552,12 +617,16 @@ describe("#users.count", () => {
it("should count invited users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
const user = await buildUser({
teamId: team.id,
lastActiveAt: null,
});
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
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);

View File

@@ -1,62 +1,84 @@
// @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 userDestroyer from "@server/commands/userDestroyer";
import userInviter from "@server/commands/userInviter";
import userSuspender from "@server/commands/userSuspender";
import auth from "@server/middlewares/authentication";
import { Event, User, Team } from "@server/models";
import policy from "@server/policies";
import { presentUser, presentPolicies } from "@server/presenters";
import { Op } from "@server/sequelize";
import {
assertIn,
assertSort,
assertPresent,
assertArray,
} from "@server/validation";
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;
let { direction } = ctx.body;
const { sort = "createdAt", query, filter } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, User);
assertSort(sort, User);
if (filter) {
ctx.assertIn(filter, [
"invited",
"viewers",
"admins",
"active",
"all",
"suspended",
]);
assertIn(
filter,
["invited", "viewers", "admins", "active", "all", "suspended"],
"Invalid filter"
);
}
const actor = ctx.state.user;
let where = {
teamId: actor.teamId,
};
switch (filter) {
case "invited": {
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ lastActiveAt: null; teamId: any; }' is not... Remove this comment to see the full error message
where = { ...where, lastActiveAt: null };
break;
}
case "viewers": {
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ isViewer: boolean; teamId: any; }' is not ... Remove this comment to see the full error message
where = { ...where, isViewer: true };
break;
}
case "admins": {
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ isAdmin: boolean; teamId: any; }' is not a... Remove this comment to see the full error message
where = { ...where, isAdmin: true };
break;
}
case "suspended": {
where = { ...where, suspendedAt: { [Op.ne]: null } };
where = {
...where,
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ suspendedAt: { [Op.ne]: null; }; teamId: a... Remove this comment to see the full error message
suspendedAt: {
[Op.ne]: null,
},
};
break;
}
case "all": {
break;
}
default: {
where = { ...where, suspendedAt: { [Op.eq]: null } };
where = {
...where,
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ suspendedAt: { [Op.eq]: null; }; teamId: a... Remove this comment to see the full error message
suspendedAt: {
[Op.eq]: null,
},
};
break;
}
}
@@ -64,6 +86,7 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
if (query) {
where = {
...where,
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ name: { [Op.iLike]: string; }; teamId: any... Remove this comment to see the full error message
name: {
[Op.iLike]: `%${query}%`,
},
@@ -81,14 +104,13 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
where,
}),
]);
ctx.body = {
pagination: {
...ctx.state.pagination,
total,
},
pagination: { ...ctx.state.pagination, total },
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
data: users.map((user) =>
presentUser(user, { includeDetails: can(actor, "readDetails", user) })
presentUser(user, {
includeDetails: can(actor, "readDetails", user),
})
),
policies: presentPolicies(actor, users),
};
@@ -97,7 +119,6 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
router.post("users.count", auth(), async (ctx) => {
const { user } = ctx.state;
const counts = await User.getCounts(user.teamId);
ctx.body = {
data: {
counts,
@@ -108,14 +129,13 @@ router.post("users.count", auth(), async (ctx) => {
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 }),
data: presentUser(user, {
includeDetails,
}),
policies: presentPolicies(actor, [user]),
};
});
@@ -123,13 +143,10 @@ router.post("users.info", auth(), async (ctx) => {
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,
@@ -137,38 +154,36 @@ router.post("users.update", auth(), async (ctx) => {
teamId: user.teamId,
ip: ctx.request.ip,
});
ctx.body = {
data: presentUser(user, { includeDetails: true }),
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");
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 },
data: {
name: user.name,
},
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
data: presentUser(user, {
includeDetails,
}),
policies: presentPolicies(actor, [user]),
};
});
@@ -177,31 +192,27 @@ 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");
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 },
data: {
name: user.name,
},
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
data: presentUser(user, {
includeDetails,
}),
policies: presentPolicies(actor, [user]),
};
});
@@ -209,21 +220,19 @@ router.post("users.demote", auth(), async (ctx) => {
router.post("users.suspend", auth(), async (ctx) => {
const userId = ctx.body.id;
const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required");
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 }),
data: presentUser(user, {
includeDetails,
}),
policies: presentPolicies(actor, [user]),
};
});
@@ -232,40 +241,40 @@ 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");
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 },
data: {
name: user.name,
},
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails }),
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");
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 });
const response = await userInviter({
user,
invites,
ip: ctx.request.ip,
});
ctx.body = {
data: {
sent: response.sent,
@@ -276,22 +285,20 @@ router.post("users.invite", auth(), async (ctx) => {
router.post("users.delete", auth(), async (ctx) => {
const { confirmation, id } = ctx.body;
ctx.assertPresent(confirmation, "confirmation is required");
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,
};

View File

@@ -1,40 +1,42 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { subDays } from "date-fns";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
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";
import { Document, FileOperation } from "@server/models";
import { Op } from "@server/sequelize";
import webService from "@server/services/web";
import { buildDocument, buildFileOperation } from "@server/test/factories";
import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
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);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(1);
});
it("should not destroy documents deleted less than 30 days ago", async () => {
@@ -42,15 +44,17 @@ describe("#utils.gc", () => {
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);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(1);
});
it("should destroy documents deleted more than 30 days ago", async () => {
@@ -58,15 +62,17 @@ describe("#utils.gc", () => {
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);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(0);
});
it("should destroy draft documents deleted more than 30 days ago", async () => {
@@ -74,14 +80,17 @@ describe("#utils.gc", () => {
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);
expect(
await Document.unscoped().count({
paranoid: false,
})
).toEqual(0);
});
it("should expire exports older than 30 days ago", async () => {
@@ -90,18 +99,15 @@ describe("#utils.gc", () => {
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",
@@ -110,7 +116,6 @@ describe("#utils.gc", () => {
},
},
});
expect(res.status).toEqual(200);
expect(data).toEqual(1);
});
@@ -121,18 +126,15 @@ describe("#utils.gc", () => {
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",
@@ -141,7 +143,6 @@ describe("#utils.gc", () => {
},
},
});
expect(res.status).toEqual(200);
expect(data).toEqual(0);
});

View File

@@ -1,12 +1,11 @@
// @flow
import { subDays } from "date-fns";
import Router from "koa-router";
import documentPermanentDeleter from "../../commands/documentPermanentDeleter";
import teamPermanentDeleter from "../../commands/teamPermanentDeleter";
import { AuthenticationError } from "../../errors";
import documentPermanentDeleter from "@server/commands/documentPermanentDeleter";
import teamPermanentDeleter from "@server/commands/teamPermanentDeleter";
import { AuthenticationError } from "@server/errors";
import { Document, Team, FileOperation } from "@server/models";
import { Op } from "@server/sequelize";
import Logger from "../../logging/logger";
import { Document, Team, FileOperation } from "../../models";
import { Op } from "../../sequelize";
const router = new Router();
@@ -14,14 +13,13 @@ router.post("utils.gc", async (ctx) => {
const { token, limit = 500 } = ctx.body;
if (process.env.UTILS_SECRET !== token) {
throw new AuthenticationError("Invalid secret token");
throw AuthenticationError("Invalid secret token");
}
Logger.info(
"utils",
`Permanently destroying upto ${limit} documents older than 30 days…`
);
const documents = await Document.scope("withUnpublished").findAll({
attributes: ["id", "teamId", "text", "deletedAt"],
where: {
@@ -32,16 +30,12 @@ router.post("utils.gc", async (ctx) => {
paranoid: false,
limit,
});
const countDeletedDocument = await documentPermanentDeleter(documents);
Logger.info("utils", `Destroyed ${countDeletedDocument} documents`);
Logger.info(
"utils",
`Expiring all the collection export older than 30 days…`
);
const exports = await FileOperation.unscoped().findAll({
where: {
type: "export",
@@ -53,18 +47,16 @@ router.post("utils.gc", async (ctx) => {
},
},
});
await Promise.all(
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'e' implicitly has an 'any' type.
exports.map(async (e) => {
await e.expire();
})
);
Logger.info(
"utils",
`Permanently destroying upto ${limit} teams older than 30 days…`
);
const teams = await Team.findAll({
where: {
deletedAt: {

View File

@@ -1,26 +1,28 @@
/* eslint-disable flowtype/require-valid-file-annotation */
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
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";
import { View, CollectionUser } from "@server/models";
import webService from "@server/services/web";
import { buildUser } from "@server/test/factories";
import { flushdb, seed } from "@server/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 });
await View.increment({
documentId: document.id,
userId: user.id,
});
const res = await server.post("/api/views.list", {
body: { token: user.getJwtToken(), documentId: document.id },
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);
@@ -30,21 +32,23 @@ describe("#views.list", () => {
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 });
await View.increment({
documentId: document.id,
userId: user.id,
});
const res = await server.post("/api/views.list", {
body: { token: user.getJwtToken(), documentId: document.id },
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);
@@ -53,10 +57,11 @@ describe("#views.list", () => {
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/views.list", {
body: { documentId: document.id },
body: {
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -65,20 +70,24 @@ describe("#views.list", () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post("/api/views.list", {
body: { token: user.getJwtToken(), documentId: document.id },
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 },
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.count).toBe(1);
});
@@ -87,19 +96,19 @@ describe("#views.create", () => {
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 },
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.count).toBe(1);
});
@@ -107,10 +116,11 @@ describe("#views.create", () => {
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/views.create", {
body: { documentId: document.id },
body: {
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
@@ -119,7 +129,10 @@ describe("#views.create", () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post("/api/views.create", {
body: { token: user.getJwtToken(), documentId: document.id },
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
expect(res.status).toEqual(403);
});

View File

@@ -1,23 +1,23 @@
// @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";
import auth from "@server/middlewares/authentication";
import { View, Document, Event } from "@server/models";
import policy from "@server/policies";
import { presentView } from "@server/presenters";
import { assertUuid } from "@server/validation";
const { authorize } = policy;
const router = new Router();
router.post("views.list", auth(), async (ctx) => {
const { documentId } = ctx.body;
ctx.assertUuid(documentId, "documentId is required");
assertUuid(documentId, "documentId is required");
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
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),
};
@@ -25,24 +25,28 @@ router.post("views.list", auth(), async (ctx) => {
router.post("views.create", auth(), async (ctx) => {
const { documentId } = ctx.body;
ctx.assertUuid(documentId, "documentId is required");
assertUuid(documentId, "documentId is required");
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
const view = await View.increment({ documentId, userId: user.id });
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 },
data: {
title: document.title,
},
ip: ctx.request.ip,
});
view.user = user;
ctx.body = {
data: presentView(view),

View File

@@ -1,35 +1,37 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import webService from "../../services/web";
import { buildUser, buildCollection } from "../../test/factories";
import { flushdb } from "../../test/support";
import webService from "@server/services/web";
import { buildUser, buildCollection } from "@server/test/factories";
import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("auth/redirect", () => {
it("should redirect to home", async () => {
const user = await buildUser();
const res = await server.get(
`/auth/redirect?token=${user.getTransferToken()}`,
{ redirect: "manual" }
{
redirect: "manual",
}
);
expect(res.status).toEqual(302);
expect(res.headers.get("location").endsWith("/home")).toBeTruthy();
});
it("should redirect to first collection", async () => {
const collection = await buildCollection();
const user = await buildUser({ teamId: collection.teamId });
const user = await buildUser({
teamId: collection.teamId,
});
const res = await server.get(
`/auth/redirect?token=${user.getTransferToken()}`,
{ redirect: "manual" }
{
redirect: "manual",
}
);
expect(res.status).toEqual(302);
expect(res.headers.get("location").endsWith(collection.url)).toBeTruthy();
});

View File

@@ -1,21 +1,21 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import { addMonths } from "date-fns";
import Koa from "koa";
import bodyParser from "koa-body";
import Router from "koa-router";
import { AuthenticationError } from "../../errors";
import auth from "../../middlewares/authentication";
import validation from "../../middlewares/validation";
import { Collection, Team, View } from "../../models";
import { AuthenticationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { Collection, Team, View } from "@server/models";
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'providers' implicitly has type 'any[]' i... Remove this comment to see the full error message
import providers from "./providers";
const app = new Koa();
const router = new Router();
router.use(passport.initialize());
// dynamically load available authentication provider routes
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'providers' implicitly has an 'any[]' typ... Remove this comment to see the full error message
providers.forEach((provider) => {
if (provider.enabled) {
router.use("/", provider.router.routes());
@@ -27,27 +27,25 @@ router.get("/redirect", auth(), async (ctx) => {
const jwtToken = user.getJwtToken();
if (jwtToken === ctx.params.token) {
throw new AuthenticationError("Cannot extend token");
throw AuthenticationError("Cannot extend token");
}
// ensure that the lastActiveAt on user is updated to prevent replay requests
await user.updateActiveAt(ctx.request.ip, true);
ctx.cookies.set("accessToken", jwtToken, {
httpOnly: false,
expires: addMonths(new Date(), 3),
});
const [team, collection, view] = await Promise.all([
Team.findByPk(user.teamId),
Collection.findFirstCollectionForUser(user),
View.findOne({
where: { userId: user.id },
where: {
userId: user.id,
},
}),
]);
const hasViewedDocuments = !!view;
ctx.redirect(
!hasViewedDocuments && collection
? `${team.url}${collection.url}`
@@ -56,7 +54,6 @@ router.get("/redirect", auth(), async (ctx) => {
});
app.use(bodyParser());
app.use(validation());
app.use(router.routes());
export default app;

View File

@@ -1,20 +1,21 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import { Strategy as AzureStrategy } from "@outlinewiki/passport-azure-ad-oauth2";
import jwt from "jsonwebtoken";
import Router from "koa-router";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import { MicrosoftGraphError } from "../../../errors";
import passportMiddleware from "../../../middlewares/passport";
import { StateStore, request } from "../../../utils/passport";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import { MicrosoftGraphError } from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { StateStore, request } from "@server/utils/passport";
const router = new Router();
const providerName = "azure";
const AZURE_CLIENT_ID = process.env.AZURE_CLIENT_ID;
const AZURE_CLIENT_SECRET = process.env.AZURE_CLIENT_SECRET;
const AZURE_RESOURCE_APP_ID = process.env.AZURE_RESOURCE_APP_ID;
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'scopes' implicitly has type 'any[]' in s... Remove this comment to see the full error message
const scopes = [];
export const config = {
@@ -32,8 +33,10 @@ if (AZURE_CLIENT_ID) {
passReqToCallback: true,
resource: AZURE_RESOURCE_APP_ID,
store: new StateStore(),
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'scopes' implicitly has an 'any[]' type.
scope: scopes,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'req' implicitly has an 'any' type.
async function (req, accessToken, refreshToken, params, _, done) {
try {
// see docs for what the fields in profile represent here:
@@ -46,8 +49,9 @@ if (AZURE_CLIENT_ID) {
`https://graph.microsoft.com/v1.0/me`,
accessToken
);
if (!profileResponse) {
throw new MicrosoftGraphError(
throw MicrosoftGraphError(
"Unable to load user profile from Microsoft Graph API"
);
}
@@ -58,16 +62,19 @@ if (AZURE_CLIENT_ID) {
`https://graph.microsoft.com/v1.0/organization`,
accessToken
);
if (!organizationResponse) {
throw new MicrosoftGraphError(
throw MicrosoftGraphError(
"Unable to load organization info from Microsoft Graph API"
);
}
const organization = organizationResponse.value[0];
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
const email = profile.email || profileResponse.mail;
if (!email) {
throw new MicrosoftGraphError(
throw MicrosoftGraphError(
"'email' property is required but could not be found in user profile."
);
}
@@ -75,7 +82,6 @@ if (AZURE_CLIENT_ID) {
const domain = email.split("@")[1];
const subdomain = domain.split(".")[0];
const teamName = organization.displayName;
const result = await accountProvisioner({
ip: req.ip,
team: {
@@ -84,18 +90,23 @@ if (AZURE_CLIENT_ID) {
subdomain,
},
user: {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
name: profile.name,
email,
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
avatarUrl: profile.picture,
},
authenticationProvider: {
name: providerName,
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
providerId: profile.tid,
},
authentication: {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
providerId: profile.oid,
accessToken,
refreshToken,
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'scopes' implicitly has an 'any[]' type.
scopes,
},
});
@@ -105,7 +116,6 @@ if (AZURE_CLIENT_ID) {
}
}
);
passport.use(strategy);
router.get("azure", passport.authenticate(providerName));

View File

@@ -1,30 +1,25 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import mailer from "../../../mailer";
import webService from "../../../services/web";
import { buildUser, buildGuestUser, buildTeam } from "../../../test/factories";
import { flushdb } from "../../../test/support";
import mailer from "@server/mailer";
import webService from "@server/services/web";
import { buildUser, buildGuestUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("../../../mailer");
beforeEach(async () => {
await flushdb();
// $FlowFixMe does not understand Jest mocks
// @ts-expect-error ts-migrate(2339) FIXME: Property 'mockReset' does not exist on type '(type... Remove this comment to see the full error message
mailer.sendTemplate.mockReset();
});
afterAll(() => server.close());
describe("email", () => {
it("should require email param", async () => {
const res = await server.post("/auth/email", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.error).toEqual("validation_error");
expect(body.ok).toEqual(false);
@@ -32,12 +27,12 @@ describe("email", () => {
it("should respond with redirect location when user is SSO enabled", async () => {
const user = await buildUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
body: {
email: user.email,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
@@ -46,19 +41,19 @@ describe("email", () => {
it("should respond with redirect location when user is SSO enabled on another subdomain", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const user = await buildUser();
await buildTeam({
subdomain: "example",
});
const res = await server.post("/auth/email", {
body: { email: user.email },
headers: { host: "example.localoutline.com" },
body: {
email: user.email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
@@ -66,12 +61,12 @@ describe("email", () => {
it("should respond with success when user is not SSO enabled", async () => {
const user = await buildGuestUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
body: {
email: user.email,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
@@ -79,97 +74,116 @@ describe("email", () => {
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const res = await server.post("/auth/email", {
body: { email: "user@example.com" },
body: {
email: "user@example.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).not.toHaveBeenCalled();
});
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "sso-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
await buildGuestUser({
email,
});
await buildUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
body: {
email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
});
it("should default to current subdomain with guest email", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "guest-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
await buildUser({
email,
});
await buildGuestUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
body: {
email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
});
it("should default to custom domain with SSO", async () => {
const email = "sso-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
await buildGuestUser({
email,
});
await buildUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
body: {
email,
},
headers: {
host: "docs.mycompany.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
});
it("should default to custom domain with guest email", async () => {
const email = "guest-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
await buildUser({
email,
});
await buildGuestUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
body: {
email,
},
headers: {
host: "docs.mycompany.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();

View File

@@ -1,20 +1,16 @@
// @flow
import { subMinutes } from "date-fns";
import Router from "koa-router";
import { find } from "lodash";
import {
parseDomain,
isCustomSubdomain,
} from "../../../../shared/utils/domains";
import { AuthorizationError } from "../../../errors";
import mailer from "../../../mailer";
import errorHandling from "../../../middlewares/errorHandling";
import methodOverride from "../../../middlewares/methodOverride";
import validation from "../../../middlewares/validation";
import { User, Team } from "../../../models";
import { signIn } from "../../../utils/authentication";
import { isCustomDomain } from "../../../utils/domains";
import { getUserForEmailSigninToken } from "../../../utils/jwt";
import { parseDomain, isCustomSubdomain } from "@shared/utils/domains";
import { AuthorizationError } from "@server/errors";
import mailer from "@server/mailer";
import errorHandling from "@server/middlewares/errorHandling";
import methodOverride from "@server/middlewares/methodOverride";
import { User, Team } from "@server/models";
import { signIn } from "@server/utils/authentication";
import { isCustomDomain } from "@server/utils/domains";
import { getUserForEmailSigninToken } from "@server/utils/jwt";
import { assertEmail, assertPresent } from "@server/validation";
const router = new Router();
@@ -24,23 +20,25 @@ export const config = {
};
router.use(methodOverride());
router.use(validation());
router.post("email", errorHandling(), async (ctx) => {
const { email } = ctx.body;
ctx.assertEmail(email, "email is required");
assertEmail(email, "email is required");
const users = await User.scope("withAuthentications").findAll({
where: { email: email.toLowerCase() },
where: {
email: email.toLowerCase(),
},
});
if (users.length) {
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'team' implicitly has type 'any' in some ... Remove this comment to see the full error message
let team;
if (isCustomDomain(ctx.request.hostname)) {
team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: ctx.request.hostname },
where: {
domain: ctx.request.hostname,
},
});
}
@@ -52,12 +50,15 @@ router.post("email", errorHandling(), async (ctx) => {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain },
where: {
subdomain,
},
});
}
// If there are multiple users with this email address then give precedence
// to the one that is active on this subdomain/domain (if any)
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
let user = users.find((user) => team && user.teamId === team.id);
// A user was found for the email address, but they don't belong to the team
@@ -94,7 +95,7 @@ router.post("email", errorHandling(), async (ctx) => {
}
if (!team.guestSignin) {
throw new AuthorizationError();
throw AuthorizationError();
}
// basic rate limit of endpoint to prevent send email abuse
@@ -116,7 +117,6 @@ router.post("email", errorHandling(), async (ctx) => {
token: user.getEmailSigninToken(),
teamUrl: team.url,
});
user.lastSigninEmailSentAt = new Date();
await user.save();
}
@@ -129,17 +129,20 @@ router.post("email", errorHandling(), async (ctx) => {
router.get("email.callback", async (ctx) => {
const { token } = ctx.request.query;
ctx.assertPresent(token, "token is required");
assertPresent(token, "token is required");
try {
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message
const user = await getUserForEmailSigninToken(token);
if (!user.team.guestSignin) {
return ctx.redirect("/?notice=auth-error");
}
if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
}
if (user.isInvited) {
await mailer.sendTemplate("welcome", {
to: user.email,
@@ -147,8 +150,9 @@ router.get("email.callback", async (ctx) => {
});
}
await user.update({ lastActiveAt: new Date() });
await user.update({
lastActiveAt: new Date(),
});
// set cookies on response and redirect to team subdomain
await signIn(ctx, user, user.team, "email", false, false);
} catch (err) {

View File

@@ -1,24 +1,24 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
import { capitalize } from "lodash";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'pass... Remove this comment to see the full error message
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
GoogleWorkspaceRequiredError,
GoogleWorkspaceInvalidError,
} from "../../../errors";
import passportMiddleware from "../../../middlewares/passport";
import { getAllowedDomains } from "../../../utils/authentication";
import { StateStore } from "../../../utils/passport";
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { getAllowedDomains } from "@server/utils/authentication";
import { StateStore } from "@server/utils/passport";
const router = new Router();
const providerName = "google";
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const allowedDomains = getAllowedDomains();
const scopes = [
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
@@ -40,21 +40,21 @@ if (GOOGLE_CLIENT_ID) {
store: new StateStore(),
scope: scopes,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'req' implicitly has an 'any' type.
async function (req, accessToken, refreshToken, profile, done) {
try {
const domain = profile._json.hd;
if (!domain) {
throw new GoogleWorkspaceRequiredError();
throw GoogleWorkspaceRequiredError();
}
if (allowedDomains.length && !allowedDomains.includes(domain)) {
throw new GoogleWorkspaceInvalidError();
throw GoogleWorkspaceInvalidError();
}
const subdomain = domain.split(".")[0];
const teamName = capitalize(subdomain);
const result = await accountProvisioner({
ip: req.ip,
team: {

View File

@@ -1,10 +1,11 @@
// @flow
import { signin } from "../../../../shared/utils/routeHelpers";
import { requireDirectory } from "../../../utils/fs";
import { signin } from "@shared/utils/routeHelpers";
import { requireDirectory } from "@server/utils/fs";
let providers = [];
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'providers' implicitly has type 'any[]' i... Remove this comment to see the full error message
const providers = [];
requireDirectory(__dirname).forEach(([module, id]) => {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'config' does not exist on type 'unknown'... Remove this comment to see the full error message
const { config, default: router } = module;
if (id === "index") {
@@ -34,4 +35,5 @@ requireDirectory(__dirname).forEach(([module, id]) => {
}
});
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'providers' implicitly has an 'any[]' typ... Remove this comment to see the full error message
export default providers;

View File

@@ -1,26 +1,26 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
import get from "lodash/get";
import { get } from "lodash";
import { Strategy } from "passport-oauth2";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
OIDCMalformedUserInfoError,
AuthenticationError,
} from "../../../errors";
import passportMiddleware from "../../../middlewares/passport";
import { getAllowedDomains } from "../../../utils/authentication";
import { StateStore, request } from "../../../utils/passport";
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { getAllowedDomains } from "@server/utils/authentication";
import { StateStore, request } from "@server/utils/passport";
const router = new Router();
const providerName = "oidc";
const OIDC_DISPLAY_NAME = process.env.OIDC_DISPLAY_NAME || "OpenID Connect";
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
const OIDC_AUTH_URI = process.env.OIDC_AUTH_URI;
const OIDC_TOKEN_URI = process.env.OIDC_TOKEN_URI;
const OIDC_USERINFO_URI = process.env.OIDC_USERINFO_URI;
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || "";
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || "";
const OIDC_AUTH_URI = process.env.OIDC_AUTH_URI || "";
const OIDC_TOKEN_URI = process.env.OIDC_TOKEN_URI || "";
const OIDC_USERINFO_URI = process.env.OIDC_USERINFO_URI || "";
const OIDC_SCOPES = process.env.OIDC_SCOPES || "";
const OIDC_USERNAME_CLAIM =
process.env.OIDC_USERNAME_CLAIM || "preferred_username";
@@ -30,7 +30,6 @@ export const config = {
name: OIDC_DISPLAY_NAME,
enabled: !!OIDC_CLIENT_ID,
};
const scopes = OIDC_SCOPES.split(" ");
Strategy.prototype.userProfile = async function (accessToken, done) {
@@ -54,40 +53,45 @@ if (OIDC_CLIENT_ID) {
callbackURL: `${env.URL}/auth/${providerName}.callback`,
passReqToCallback: true,
scope: OIDC_SCOPES,
// @ts-expect-error custom state store
store: new StateStore(),
state: true,
pkce: false,
},
// OpenID Connect standard profile claims can be found in the official
// specification.
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
// Non-standard claims may be configured by individual identity providers.
// Any claim supplied in response to the userinfo request will be
// available on the `profile` parameter
async function (req, accessToken, refreshToken, profile, done) {
async function (
req: any,
accessToken: string,
refreshToken: string,
profile: Record<string, string>,
done: any
) {
try {
if (!profile.email) {
throw new AuthenticationError(
throw AuthenticationError(
`An email field was not returned in the profile parameter, but is required.`
);
}
const parts = profile.email.split("@");
const domain = parts.length && parts[1];
if (!domain) {
throw new OIDCMalformedUserInfoError();
throw OIDCMalformedUserInfoError();
}
if (allowedDomains.length && !allowedDomains.includes(domain)) {
throw new AuthenticationError(
throw AuthenticationError(
`Domain ${domain} is not on the whitelist`
);
}
const subdomain = domain.split(".")[0];
const result = await accountProvisioner({
ip: req.ip,
team: {
@@ -115,7 +119,6 @@ if (OIDC_CLIENT_ID) {
scopes,
},
});
return done(null, result.user, result);
} catch (err) {
return done(err, null);

View File

@@ -1,203 +0,0 @@
// @flow
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import auth from "../../../middlewares/authentication";
import passportMiddleware from "../../../middlewares/passport";
import {
IntegrationAuthentication,
Collection,
Integration,
Team,
} from "../../../models";
import { StateStore } from "../../../utils/passport";
import * as Slack from "../../../utils/slack";
const router = new Router();
const providerName = "slack";
const SLACK_CLIENT_ID = process.env.SLACK_KEY;
const SLACK_CLIENT_SECRET = process.env.SLACK_SECRET;
const scopes = [
"identity.email",
"identity.basic",
"identity.avatar",
"identity.team",
];
export const config = {
name: "Slack",
enabled: !!SLACK_CLIENT_ID,
};
if (SLACK_CLIENT_ID) {
const strategy = new SlackStrategy(
{
clientID: SLACK_CLIENT_ID,
clientSecret: SLACK_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/slack.callback`,
passReqToCallback: true,
store: new StateStore(),
scope: scopes,
},
async function (req, accessToken, refreshToken, profile, done) {
try {
const result = await accountProvisioner({
ip: req.ip,
team: {
name: profile.team.name,
subdomain: profile.team.domain,
avatarUrl: profile.team.image_230,
},
user: {
name: profile.user.name,
email: profile.user.email,
avatarUrl: profile.user.image_192,
},
authenticationProvider: {
name: providerName,
providerId: profile.team.id,
},
authentication: {
providerId: profile.user.id,
accessToken,
refreshToken,
scopes,
},
});
return done(null, result.user, result);
} catch (err) {
return done(err, null);
}
}
);
// For some reason the author made the strategy name capatilised, I don't know
// why but we need everything lowercase so we just monkey-patch it here.
strategy.name = providerName;
passport.use(strategy);
router.get("slack", passport.authenticate(providerName));
router.get("slack.callback", passportMiddleware(providerName));
router.get("slack.commands", auth({ required: false }), async (ctx) => {
const { code, state, error } = ctx.request.query;
const user = ctx.state.user;
ctx.assertPresent(code || error, "code is required");
if (error) {
ctx.redirect(`/settings/integrations/slack?error=${error}`);
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (state) {
try {
const team = await Team.findByPk(state);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
} else {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.commands`;
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "command",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
serviceTeamId: data.team_id,
},
});
ctx.redirect("/settings/integrations/slack");
});
router.get("slack.post", auth({ required: false }), async (ctx) => {
const { code, error, state } = ctx.request.query;
const user = ctx.state.user;
ctx.assertPresent(code || error, "code is required");
const collectionId = state;
ctx.assertUuid(collectionId, "collectionId must be an uuid");
if (error) {
ctx.redirect(`/settings/integrations/slack?error=${error}`);
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the
// appropriate subdomain to complete the oauth flow
if (!user) {
try {
const collection = await Collection.findByPk(state);
const team = await Team.findByPk(collection.teamId);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.post`;
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "post",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: [],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
ctx.redirect("/settings/integrations/slack");
});
}
export default router;

View File

@@ -0,0 +1,211 @@
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'pass... Remove this comment to see the full error message
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import passportMiddleware from "@server/middlewares/passport";
import {
IntegrationAuthentication,
Collection,
Integration,
Team,
} from "@server/models";
import { StateStore } from "@server/utils/passport";
import * as Slack from "@server/utils/slack";
import { assertPresent, assertUuid } from "@server/validation";
const router = new Router();
const providerName = "slack";
const SLACK_CLIENT_ID = process.env.SLACK_KEY;
const SLACK_CLIENT_SECRET = process.env.SLACK_SECRET;
const scopes = [
"identity.email",
"identity.basic",
"identity.avatar",
"identity.team",
];
export const config = {
name: "Slack",
enabled: !!SLACK_CLIENT_ID,
};
if (SLACK_CLIENT_ID) {
const strategy = new SlackStrategy(
{
clientID: SLACK_CLIENT_ID,
clientSecret: SLACK_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/slack.callback`,
passReqToCallback: true,
store: new StateStore(),
scope: scopes,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'req' implicitly has an 'any' type.
async function (req, accessToken, refreshToken, profile, done) {
try {
const result = await accountProvisioner({
ip: req.ip,
team: {
name: profile.team.name,
subdomain: profile.team.domain,
avatarUrl: profile.team.image_230,
},
user: {
name: profile.user.name,
email: profile.user.email,
avatarUrl: profile.user.image_192,
},
authenticationProvider: {
name: providerName,
providerId: profile.team.id,
},
authentication: {
providerId: profile.user.id,
accessToken,
refreshToken,
scopes,
},
});
return done(null, result.user, result);
} catch (err) {
return done(err, null);
}
}
);
// For some reason the author made the strategy name capatilised, I don't know
// why but we need everything lowercase so we just monkey-patch it here.
strategy.name = providerName;
passport.use(strategy);
router.get("slack", passport.authenticate(providerName));
router.get("slack.callback", passportMiddleware(providerName));
router.get(
"slack.commands",
auth({
required: false,
}),
async (ctx) => {
const { code, state, error } = ctx.request.query;
const user = ctx.state.user;
assertPresent(code || error, "code is required");
if (error) {
ctx.redirect(`/settings/integrations/slack?error=${error}`);
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (state) {
try {
const team = await Team.findByPk(state);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
} else {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.commands`;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "command",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
serviceTeamId: data.team_id,
},
});
ctx.redirect("/settings/integrations/slack");
}
);
router.get(
"slack.post",
auth({
required: false,
}),
async (ctx) => {
const { code, error, state } = ctx.request.query;
const user = ctx.state.user;
assertPresent(code || error, "code is required");
const collectionId = state;
assertUuid(collectionId, "collectionId must be an uuid");
if (error) {
ctx.redirect(`/settings/integrations/slack?error=${error}`);
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the
// appropriate subdomain to complete the oauth flow
if (!user) {
try {
const collection = await Collection.findByPk(state);
const team = await Team.findByPk(collection.teamId);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.post`;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "post",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: [],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
ctx.redirect("/settings/integrations/slack");
}
);
}
export default router;

View File

@@ -1,22 +1,20 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import { buildShare, buildDocument } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import webService from "../services/web";
import { buildShare, buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("/share/:id", () => {
it("should return standard title in html when loading share", async () => {
const share = await buildShare({ published: false });
const share = await buildShare({
published: false,
});
const res = await server.get(`/share/${share.id}`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain("<title>Outline</title>");
});
@@ -24,42 +22,40 @@ describe("/share/:id", () => {
it("should return standard title in html when share does not exist", async () => {
const res = await server.get(`/share/junk`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain("<title>Outline</title>");
});
it("should return standard title in html when document is deleted", async () => {
const document = await buildDocument();
const share = await buildShare({ documentId: document.id });
const share = await buildShare({
documentId: document.id,
});
await document.destroy();
const res = await server.get(`/share/${share.id}`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain("<title>Outline</title>");
});
it("should return document title in html when loading published share", async () => {
const document = await buildDocument();
const share = await buildShare({ documentId: document.id });
const share = await buildShare({
documentId: document.id,
});
const res = await server.get(`/share/${share.id}`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain(`<title>${document.title}</title>`);
});
it("should return document title in html when loading published share with nested doc route", async () => {
const document = await buildDocument();
const share = await buildShare({ documentId: document.id });
const share = await buildShare({
documentId: document.id,
});
const res = await server.get(`/share/${share.id}/doc/test-Cl6g1AgPYn`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain(`<title>${document.title}</title>`);
});

View File

@@ -1,4 +1,3 @@
// @flow
import fs from "fs";
import path from "path";
import util from "util";
@@ -7,14 +6,14 @@ import Router from "koa-router";
import send from "koa-send";
import serve from "koa-static";
import isUUID from "validator/lib/isUUID";
import { languages } from "../../shared/i18n";
import env from "../env";
import { languages } from "@shared/i18n";
import env from "@server/env";
import Share from "@server/models/Share";
import { opensearchResponse } from "@server/utils/opensearch";
import prefetchTags from "@server/utils/prefetchTags";
import { robotsResponse } from "@server/utils/robots";
import apexRedirect from "../middlewares/apexRedirect";
import Share from "../models/Share";
import presentEnv from "../presenters/env";
import { opensearchResponse } from "../utils/opensearch";
import prefetchTags from "../utils/prefetchTags";
import { robotsResponse } from "../utils/robots";
const isProduction = process.env.NODE_ENV === "production";
const isTest = process.env.NODE_ENV === "test";
@@ -22,30 +21,34 @@ const koa = new Koa();
const router = new Router();
const readFile = util.promisify(fs.readFile);
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ctx' implicitly has an 'any' type.
const readIndexFile = async (ctx) => {
if (isProduction) {
return readFile(path.join(__dirname, "../../app/index.html"));
}
if (isTest) {
return readFile(path.join(__dirname, "../static/index.html"));
}
const middleware = ctx.devMiddleware;
await new Promise((resolve) => middleware.waitUntilValid(resolve));
return new Promise((resolve, reject) => {
middleware.fileSystem.readFile(
`${ctx.webpackConfig.output.path}/index.html`,
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'err' implicitly has an 'any' type.
(err, result) => {
if (err) {
return reject(err);
}
resolve(result);
}
);
});
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ctx' implicitly has an 'any' type.
const renderApp = async (ctx, next, title = "Outline") => {
if (ctx.request.path === "/realtime/") {
return next();
@@ -55,6 +58,7 @@ const renderApp = async (ctx, next, title = "Outline") => {
const environment = `
window.env = ${JSON.stringify(presentEnv(env))};
`;
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
ctx.body = page
.toString()
.replace(/\/\/inject-env\/\//g, environment)
@@ -63,9 +67,9 @@ const renderApp = async (ctx, next, title = "Outline") => {
.replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || "");
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ctx' implicitly has an 'any' type.
const renderShare = async (ctx, next) => {
const { shareId } = ctx.params;
// Find the share record if publicly published so that the document title
// can be be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliablity
@@ -82,7 +86,6 @@ const renderShare = async (ctx, next) => {
// Allow shares to be embedded in iframes on other websites
ctx.remove("X-Frame-Options");
return renderApp(ctx, next, share?.document?.title);
};
@@ -98,7 +101,7 @@ if (process.env.NODE_ENV === "production") {
try {
await send(ctx, ctx.path.substring(8), {
root: path.join(__dirname, "../../app/"),
setHeaders: (res, path, stat) => {
setHeaders: (res) => {
res.setHeader("Service-Worker-Allowed", "/");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", `max-age=${365 * 24 * 60 * 60}`);
@@ -119,7 +122,7 @@ if (process.env.NODE_ENV === "production") {
}
router.get("/locales/:lng.json", async (ctx) => {
let { lng } = ctx.params;
const { lng } = ctx.params;
if (!languages.includes(lng)) {
ctx.status = 404;
@@ -127,7 +130,7 @@ router.get("/locales/:lng.json", async (ctx) => {
}
await send(ctx, path.join(lng, "translation.json"), {
setHeaders: (res, path, stat) => {
setHeaders: (res) => {
if (process.env.NODE_ENV === "production") {
res.setHeader("Cache-Control", `max-age=${7 * 24 * 60 * 60}`);
}
@@ -137,7 +140,7 @@ router.get("/locales/:lng.json", async (ctx) => {
});
router.get("/robots.txt", (ctx) => {
ctx.body = robotsResponse(ctx);
ctx.body = robotsResponse();
});
router.get("/opensearch.xml", (ctx) => {
@@ -146,6 +149,7 @@ router.get("/opensearch.xml", (ctx) => {
});
router.get("/share/:shareId", renderShare);
router.get("/share/:shareId/*", renderShare);
// catch all for application
@@ -155,6 +159,7 @@ router.get("*", renderApp);
// must be provided when serving the application, see:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin
const timingOrigins = [env.URL];
if (env.SENTRY_DSN) {
timingOrigins.push("https://sentry.io");
}
@@ -163,7 +168,6 @@ koa.use(async (ctx, next) => {
ctx.set("Timing-Allow-Origin", timingOrigins.join(", "));
await next();
});
koa.use(apexRedirect());
koa.use(router.routes());