Merge main

This commit is contained in:
Tom Moor
2021-02-07 12:58:17 -08:00
233 changed files with 7243 additions and 4147 deletions

View File

@@ -9,7 +9,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
@@ -45,7 +45,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
@@ -81,7 +81,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": true,
"isSuspended": false,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
@@ -126,7 +126,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": true,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},

43
server/api/auth.test.js Normal file
View File

@@ -0,0 +1,43 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { buildUser, buildTeam } from "../test/factories";
import { flushdb } from "../test/support";
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#auth.info", () => {
it("should return current authentication", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/auth.info", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.user.name).toBe(user.name);
expect(body.data.team.name).toBe(team.name);
});
it("should require the team to not be deleted", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await team.destroy();
const res = await server.post("/api/auth.info", {
body: { token: user.getJwtToken() },
});
expect(res.status).toEqual(401);
});
it("should require authentication", async () => {
const res = await server.post("/api/auth.info");
expect(res.status).toEqual(401);
});
});

View File

@@ -31,7 +31,13 @@ const { authorize } = policy;
const router = new Router();
router.post("collections.create", auth(), async (ctx) => {
const { name, color, description, icon } = ctx.body;
const {
name,
color,
description,
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required");
@@ -48,8 +54,9 @@ router.post("collections.create", auth(), async (ctx) => {
icon,
color,
teamId: user.teamId,
creatorId: user.id,
createdById: user.id,
private: isPrivate,
sort,
});
await Event.create({
@@ -484,16 +491,14 @@ router.post("collections.export_all", auth(), async (ctx) => {
});
router.post("collections.update", auth(), async (ctx) => {
const { id, name, description, icon, color } = ctx.body;
let { id, name, description, icon, color, sort } = ctx.body;
const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required");
if (color) {
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
}
const user = ctx.state.user;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
@@ -517,11 +522,24 @@ router.post("collections.update", auth(), async (ctx) => {
const isPrivacyChanged = isPrivate !== collection.private;
collection.name = name;
collection.description = description;
collection.icon = icon;
collection.color = color;
collection.private = isPrivate;
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 (isPrivate !== undefined) {
collection.private = isPrivate;
}
if (sort !== undefined) {
collection.sort = sort;
}
await collection.save();

View File

@@ -1,8 +1,13 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { Collection, CollectionUser, CollectionGroup } from "../models";
import { buildUser, buildGroup, buildCollection } from "../test/factories";
import { Document, CollectionUser, CollectionGroup } from "../models";
import {
buildUser,
buildGroup,
buildCollection,
buildDocument,
} from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -885,6 +890,8 @@ describe("#collections.create", () => {
expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy();
expect(body.data.name).toBe("Test");
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("asc");
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy();
expect(body.policies[0].abilities.export).toBeTruthy();
@@ -937,6 +944,29 @@ describe("#collections.update", () => {
expect(body.policies.length).toBe(1);
});
it("allows editing sort", async () => {
const { user, collection } = await seed();
const sort = { field: "index", direction: "desc" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("desc");
});
it("allows editing individual fields", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, private: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.private).toBe(true);
expect(body.data.name).toBe(collection.name);
});
it("allows editing from non-private to private collection", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/collections.update", {
@@ -1048,6 +1078,24 @@ describe("#collections.update", () => {
});
expect(res.status).toEqual(403);
});
it("does not allow setting unknown sort fields", async () => {
const { user, collection } = await seed();
const sort = { field: "blah", direction: "desc" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
expect(res.status).toEqual(400);
});
it("does not allow setting unknown sort directions", async () => {
const { user, collection } = await seed();
const sort = { field: "title", direction: "blah" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
expect(res.status).toEqual(400);
});
});
describe("#collections.delete", () => {
@@ -1078,11 +1126,11 @@ describe("#collections.delete", () => {
it("should delete collection", async () => {
const { user, collection } = await seed();
await Collection.create({
name: "Blah",
urlId: "blah",
// to ensure it isn't the last collection
await buildCollection({
teamId: user.teamId,
creatorId: user.id,
createdById: user.id,
});
const res = await server.post("/api/collections.delete", {
@@ -1094,6 +1142,37 @@ describe("#collections.delete", () => {
expect(body.success).toBe(true);
});
it("should delete published documents", async () => {
const { user, collection } = await seed();
// to ensure it isn't the last collection
await buildCollection({
teamId: user.teamId,
createdById: user.id,
});
// archived document should not be deleted
await buildDocument({
collectionId: collection.id,
archivedAt: new Date(),
});
const res = await server.post("/api/collections.delete", {
body: { token: user.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
expect(
await Document.count({
where: {
collectionId: collection.id,
},
})
).toEqual(1);
});
it("allows deleting by read-write collection group user", async () => {
const user = await buildUser();
const collection = await buildCollection({

View File

@@ -38,7 +38,7 @@ const { authorize, cannot } = policy;
const router = new Router();
router.post("documents.list", auth(), pagination(), async (ctx) => {
const {
let {
sort = "updatedAt",
template,
backlinkDocumentId,
@@ -71,6 +71,7 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
where = { ...where, createdById };
}
let documentIds = [];
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
ctx.assertUuid(collectionId, "collection must be a UUID");
@@ -81,6 +82,15 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
}).findByPk(collectionId);
authorize(user, "read", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
documentIds = collection.documentStructure
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
where = { ...where, id: documentIds };
}
// otherwise, filter by all collections the user has access to
} else {
const collectionIds = await user.collectionIds();
@@ -92,6 +102,12 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
where = { ...where, parentDocumentId };
}
// Explicitly passing 'null' as the parentDocumentId allows listing documents
// that have no parent document (aka they are at the root of the collection)
if (parentDocumentId === null) {
where = { ...where, parentDocumentId: { [Op.eq]: null } };
}
if (backlinkDocumentId) {
ctx.assertUuid(backlinkDocumentId, "backlinkDocumentId must be a UUID");
@@ -108,6 +124,10 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
};
}
if (sort === "index") {
sort = "updatedAt";
}
// add the users starred state to the response by default
const starredScope = { method: ["withStarred", user.id] };
const collectionScope = { method: ["withCollection", user.id] };
@@ -124,6 +144,14 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
limit: ctx.state.pagination.limit,
});
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (documentIds.length) {
documents.sort(
(a, b) => documentIds.indexOf(a.id) - documentIds.indexOf(b.id)
);
}
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
@@ -523,18 +551,27 @@ router.post("documents.restore", auth(), async (ctx) => {
throw new NotFoundError();
}
// Passing collectionId allows restoring to a different collection than the
// document was originally within
if (collectionId) {
ctx.assertUuid(collectionId, "collectionId must be a uuid");
authorize(user, "restore", document);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "update", collection);
document.collectionId = collectionId;
}
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(document.collectionId);
// if the collectionId was provided in the request and isn't valid then it will
// be caught as a 403 on the authorize call below. Otherwise we're checking here
// that the original collection still exists and advising to pass collectionId
// if not.
if (!collectionId) {
ctx.assertPresent(collection, "collectionId is required");
}
authorize(user, "update", collection);
if (document.deletedAt) {
authorize(user, "restore", document);
@@ -910,7 +947,7 @@ router.post("documents.update", auth(), async (ctx) => {
transaction = await sequelize.transaction();
if (publish) {
await document.publish({ transaction });
await document.publish(user.id, { transaction });
} else {
await document.save({ autosave, transaction });
}
@@ -1087,7 +1124,7 @@ router.post("documents.unpublish", auth(), async (ctx) => {
authorize(user, "unpublish", document);
await document.unpublish();
await document.unpublish(user.id);
await Event.create({
name: "documents.unpublish",

View File

@@ -433,7 +433,27 @@ describe("#documents.list", () => {
expect(body.data[0].id).toEqual(document.id);
});
it("should not return unpublished documents", async () => {
it("should allow filtering documents with no parent", async () => {
const { user, document } = await seed();
await buildDocument({
title: "child document",
text: "random text",
parentDocumentId: document.id,
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/documents.list", {
body: { token: user.getJwtToken(), parentDocumentId: null },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(document.id);
});
it("should not return draft documents", async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
@@ -493,6 +513,32 @@ describe("#documents.list", () => {
expect(body.data[1].id).toEqual(anotherDoc.id);
});
it("should allow sorting by collection index", async () => {
const { user, document, collection } = await seed();
const anotherDoc = await buildDocument({
title: "another document",
text: "random text",
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
await collection.addDocumentToStructure(anotherDoc, 0);
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
sort: "index",
direction: "ASC",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data[0].id).toEqual(anotherDoc.id);
expect(body.data[1].id).toEqual(document.id);
});
it("should allow filtering by collection", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.list", {
@@ -1334,7 +1380,22 @@ describe("#documents.restore", () => {
expect(body.data.collectionId).toEqual(collection.id);
});
it("should now allow restore of trashed documents to collection user cannot access", async () => {
it("should not allow restore of documents in deleted collection", async () => {
const { user, document, collection } = await seed();
await document.destroy(user.id);
await collection.destroy();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
expect(res.status).toEqual(400);
});
it("should not allow restore of trashed documents to collection user cannot access", async () => {
const { user, document } = await seed();
const collection = await buildCollection();
@@ -1955,7 +2016,7 @@ describe("#documents.delete", () => {
describe("#documents.unpublish", () => {
it("should unpublish a document", async () => {
const { user, document } = await seed();
let { user, document } = await seed();
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
@@ -1964,6 +2025,28 @@ describe("#documents.unpublish", () => {
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
expect(body.data.publishedAt).toBeNull();
document = await Document.unscoped().findByPk(document.id);
expect(document.userId).toEqual(user.id);
});
it("should unpublish another users document", async () => {
const { user, collection } = await seed();
let document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
expect(body.data.publishedAt).toBeNull();
document = await Document.unscoped().findByPk(document.id);
expect(document.userId).toEqual(user.id);
});
it("should fail to unpublish a draft document", async () => {
@@ -1989,7 +2072,7 @@ describe("#documents.unpublish", () => {
expect(res.status).toEqual(403);
});
it("should fail to unpublish a archived document", async () => {
it("should fail to unpublish an archived document", async () => {
const { user, document } = await seed();
await document.archive();

View File

@@ -2,7 +2,7 @@
import Router from "koa-router";
import Sequelize from "sequelize";
import auth from "../middlewares/authentication";
import { Event, Team, User } from "../models";
import { Event, Team, User, Collection } from "../models";
import policy from "../policies";
import { presentEvent } from "../presenters";
import pagination from "./middlewares/pagination";
@@ -12,30 +12,62 @@ const { authorize } = policy;
const router = new Router();
router.post("events.list", auth(), pagination(), async (ctx) => {
let { sort = "createdAt", direction, auditLog = false } = ctx.body;
if (direction !== "ASC") direction = "DESC";
const user = ctx.state.user;
const collectionIds = await user.collectionIds({ paranoid: false });
let {
sort = "createdAt",
actorId,
collectionId,
direction,
name,
auditLog = false,
} = ctx.body;
if (direction !== "ASC") direction = "DESC";
let where = {
name: Event.ACTIVITY_EVENTS,
teamId: user.teamId,
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: {
[Op.eq]: null,
},
},
],
};
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
where = {
...where,
actorId,
};
}
if (collectionId) {
ctx.assertUuid(collectionId, "collection must be a UUID");
where = { ...where, collectionId };
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
} else {
const collectionIds = await user.collectionIds({ paranoid: false });
where = {
...where,
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: {
[Op.eq]: null,
},
},
],
};
}
if (auditLog) {
authorize(user, "auditLog", Team);
where.name = Event.AUDIT_EVENTS;
}
if (name && where.name.includes(name)) {
where.name = name;
}
const events = await Event.findAll({
where,
order: [[sort, direction]],

View File

@@ -13,7 +13,7 @@ describe("#events.list", () => {
it("should only return activity events", async () => {
const { user, admin, document, collection } = await seed();
// private event
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
@@ -29,6 +29,7 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken() },
});
@@ -39,6 +40,100 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(event.id);
});
it("should return audit events", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data[0].id).toEqual(event.id);
expect(body.data[1].id).toEqual(auditEvent.id);
});
it("should allow filtering by actorId", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true, actorId: admin.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(auditEvent.id);
});
it("should allow filtering by event name", async () => {
const { user, admin, document, collection } = await seed();
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
name: "documents.publish",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should return events with deleted actors", async () => {
const { user, admin, document, collection } = await seed();
@@ -64,6 +159,15 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(event.id);
});
it("should require authorization for audit events", async () => {
const { user } = await seed();
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken(), auditLog: true },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/events.list");
const body = await res.json();

View File

@@ -14,6 +14,7 @@ import enforceHttps from "koa-sslify";
import api from "./api";
import auth from "./auth";
import emails from "./emails";
import env from "./env";
import routes from "./routes";
import updates from "./utils/updates";
@@ -21,6 +22,23 @@ const app = new Koa();
const isProduction = process.env.NODE_ENV === "production";
const isTest = process.env.NODE_ENV === "test";
// Construct scripts CSP based on services in use by this installation
const defaultSrc = ["'self'"];
const scriptSrc = [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"gist.github.com",
];
if (env.GOOGLE_ANALYTICS_ID) {
scriptSrc.push("www.google-analytics.com");
}
if (env.CDN_URL) {
scriptSrc.push(env.CDN_URL);
defaultSrc.push(env.CDN_URL);
}
app.use(compress());
if (isProduction) {
@@ -58,6 +76,11 @@ if (isProduction) {
// that means no watching, but recompilation on every request
lazy: false,
watchOptions: {
poll: 1000,
ignored: ["node_modules"],
},
// public path to bind the middleware to
// use the same as in webpack
publicPath: config.output.publicPath,
@@ -98,8 +121,8 @@ if (process.env.SENTRY_DSN) {
maxBreadcrumbs: 0,
ignoreErrors: [
// emitted by Koa when bots attempt to snoop on paths such as wp-admin
// or the user submits a bad request. These are expected in normal running
// of the application
// or the user client submits a bad request. These are expected in normal
// running of the application and don't need to be reported.
"BadRequestError",
"UnauthorizedError",
],
@@ -144,35 +167,26 @@ app.on("error", (error, ctx) => {
app.use(mount("/auth", auth));
app.use(mount("/api", api));
// Sets common security headers by default, such as no-sniff, hsts, hide powered
// by etc
app.use(helmet());
app.use(
contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"gist.github.com",
"www.google-analytics.com",
"browser.sentry-cdn.com",
],
defaultSrc,
scriptSrc,
styleSrc: ["'self'", "'unsafe-inline'", "github.githubassets.com"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*"],
connectSrc: ["*"],
// Removed because connect-src: self + websockets does not work in Safari
// Ref: https://bugs.webkit.org/show_bug.cgi?id=201591
// connectSrc: compact([
// "'self'",
// process.env.AWS_S3_UPLOAD_BUCKET_URL.replace("s3:", "localhost:"),
// "www.google-analytics.com",
// "api.github.com",
// "sentry.io",
// ]),
// Do not use connect-src: because self + websockets does not work in
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
},
})
);
// Allow DNS prefetching for performance, we do not care about leaking requests
// to our own CDN's
app.use(dnsPrefetchControl({ allow: true }));
app.use(referrerPolicy({ policy: "no-referrer" }));
app.use(mount(routes));

View File

@@ -25,6 +25,10 @@ router.post("email", async (ctx) => {
if (user) {
const team = await Team.findByPk(user.teamId);
if (!team) {
ctx.redirect(`/?notice=auth-error`);
return;
}
// If the user matches an email address associated with an SSO
// signin then just forward them directly to that service's

View File

@@ -1,6 +1,7 @@
// @flow
import crypto from "crypto";
import { OAuth2Client } from "google-auth-library";
import invariant from "invariant";
import Router from "koa-router";
import { capitalize } from "lodash";
import Sequelize from "sequelize";
@@ -26,7 +27,7 @@ router.get("google", async (ctx) => {
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
],
prompt: "consent",
prompt: "select_account consent",
});
ctx.redirect(authorizeUrl);
});
@@ -68,15 +69,24 @@ router.get("google.callback", auth({ required: false }), async (ctx) => {
const cbResponse = await fetch(cbUrl);
const avatarUrl = cbResponse.status === 200 ? cbUrl : tileyUrl;
const [team, isFirstUser] = await Team.findOrCreate({
where: {
googleId,
},
defaults: {
name: teamName,
avatarUrl,
},
});
let team, isFirstUser;
try {
[team, isFirstUser] = await Team.findOrCreate({
where: {
googleId,
},
defaults: {
name: teamName,
avatarUrl,
},
});
} catch (err) {
if (err instanceof Sequelize.UniqueConstraintError) {
ctx.redirect(`/?notice=auth-error`);
return;
}
}
invariant(team, "Team must exist");
try {
const [user, isFirstSignin] = await User.findOrCreate({

View File

@@ -1,5 +1,6 @@
// @flow
import addHours from "date-fns/add_hours";
import invariant from "invariant";
import Router from "koa-router";
import Sequelize from "sequelize";
import { slackAuth } from "../../shared/utils/routeHelpers";
@@ -40,15 +41,24 @@ router.get("slack.callback", auth({ required: false }), async (ctx) => {
const data = await Slack.oauthAccess(code);
const [team, isFirstUser] = await Team.findOrCreate({
where: {
slackId: data.team.id,
},
defaults: {
name: data.team.name,
avatarUrl: data.team.image_88,
},
});
let team, isFirstUser;
try {
[team, isFirstUser] = await Team.findOrCreate({
where: {
slackId: data.team.id,
},
defaults: {
name: data.team.name,
avatarUrl: data.team.image_88,
},
});
} catch (err) {
if (err instanceof Sequelize.UniqueConstraintError) {
ctx.redirect(`/?notice=auth-error`);
return;
}
}
invariant(team, "Team must exist");
try {
const [user, isFirstSignin] = await User.findOrCreate({

View File

@@ -60,7 +60,7 @@ export default async function documentCreator({
});
if (publish) {
await document.publish();
await document.publish(user.id);
await Event.create({
name: "documents.publish",

View File

@@ -6,7 +6,7 @@ export default async function documentMover({
user,
document,
collectionId,
parentDocumentId,
parentDocumentId = null, // convert undefined to null so parentId comparison treats them as equal
index,
ip,
}: {
@@ -42,12 +42,24 @@ export default async function documentMover({
transaction,
paranoid: false,
});
const documentJson = await collection.removeDocumentInStructure(
document,
{
save: false,
}
);
const [
documentJson,
fromIndex,
] = await collection.removeDocumentInStructure(document, {
save: false,
});
// if we're reordering from within the same parent
// the original and destination collection are the same,
// so when the initial item is removed above, the list will reduce by 1.
// We need to compensate for this when reordering
const toIndex =
index !== undefined &&
document.parentDocumentId === parentDocumentId &&
document.collectionId === collectionId &&
fromIndex < index
? index - 1
: index;
// if the collection is the same then it will get saved below, this
// line prevents a pointless intermediate save from occurring.
@@ -62,7 +74,7 @@ export default async function documentMover({
const newCollection: Collection = collectionChanged
? await Collection.findByPk(collectionId, { transaction })
: collection;
await newCollection.addDocumentToStructure(document, index, {
await newCollection.addDocumentToStructure(document, toIndex, {
documentJson,
});
result.collections.push(collection);

View File

@@ -28,7 +28,7 @@ describe("documentMover", () => {
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
userId: collection.createdById,
title: "Child document",
text: "content",
});
@@ -59,7 +59,7 @@ describe("documentMover", () => {
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
userId: collection.createdById,
title: "Child document",
text: "content",
});

View File

@@ -3,6 +3,8 @@ import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
import EmptySpace from "./EmptySpace";
const url = process.env.CDN_URL || process.env.URL;
export default () => {
return (
<Table width="100%">
@@ -12,7 +14,7 @@ export default () => {
<EmptySpace height={40} />
<img
alt="Outline"
src={`${process.env.URL}/email/header-logo.png`}
src={`${url}/email/header-logo.png`}
height="48"
width="48"
/>

View File

@@ -1,6 +1,7 @@
// @flow
export default {
URL: process.env.URL,
CDN_URL: process.env.CDN_URL || "",
DEPLOYMENT: process.env.DEPLOYMENT,
SENTRY_DSN: process.env.SENTRY_DSN,
TEAM_LOGO: process.env.TEAM_LOGO,

View File

@@ -1,8 +1,8 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import randomstring from "randomstring";
import { ApiKey } from "../models";
import { buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support";
import { buildUser, buildTeam } from "../test/factories";
import { flushdb } from "../test/support";
import auth from "./authentication";
beforeEach(() => flushdb());
@@ -11,7 +11,7 @@ describe("Authentication middleware", () => {
describe("with JWT", () => {
it("should authenticate with correct token", async () => {
const state = {};
const { user } = await seed();
const user = await buildUser();
const authMiddleware = auth();
await authMiddleware(
@@ -29,7 +29,7 @@ describe("Authentication middleware", () => {
it("should return error with invalid token", async () => {
const state = {};
const { user } = await seed();
const user = await buildUser();
const authMiddleware = auth();
try {
@@ -52,7 +52,7 @@ describe("Authentication middleware", () => {
describe("with API key", () => {
it("should authenticate user with valid API key", async () => {
const state = {};
const { user } = await seed();
const user = await buildUser();
const authMiddleware = auth();
const key = await ApiKey.create({
userId: user.id,
@@ -116,7 +116,7 @@ describe("Authentication middleware", () => {
it("should allow passing auth token as a GET param", async () => {
const state = {};
const { user } = await seed();
const user = await buildUser();
const authMiddleware = auth();
await authMiddleware(
@@ -138,7 +138,7 @@ describe("Authentication middleware", () => {
it("should allow passing auth token in body params", async () => {
const state = {};
const { user } = await seed();
const user = await buildUser();
const authMiddleware = auth();
await authMiddleware(
@@ -159,13 +159,14 @@ describe("Authentication middleware", () => {
it("should return an error for suspended users", async () => {
const state = {};
const admin = await buildUser({});
const admin = await buildUser();
const user = await buildUser({
suspendedAt: new Date(),
suspendedById: admin.id,
});
const authMiddleware = auth();
let error;
try {
await authMiddleware(
{
@@ -177,11 +178,38 @@ describe("Authentication middleware", () => {
},
jest.fn()
);
} catch (e) {
expect(e.message).toEqual(
"Your access has been suspended by the team admin"
);
expect(e.errorData.adminEmail).toEqual(admin.email);
} catch (err) {
error = err;
}
expect(error.message).toEqual(
"Your access has been suspended by the team admin"
);
expect(error.errorData.adminEmail).toEqual(admin.email);
});
it("should return an error for deleted team", async () => {
const state = {};
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await team.destroy();
const authMiddleware = auth();
let error;
try {
await authMiddleware(
{
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
);
} catch (err) {
error = err;
}
expect(error.message).toEqual("Invalid token");
});
});

View File

@@ -37,7 +37,7 @@ export default function validation() {
};
ctx.assertPositiveInteger = (value, message) => {
if (!validator.isInt(value, { min: 0 })) {
if (!validator.isInt(String(value), { min: 0 })) {
throw new ValidationError(message);
}
};

View File

@@ -0,0 +1,14 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('collections', 'sort', {
type: Sequelize.JSONB,
allowNull: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('collections', 'sort');
}
};

View File

@@ -0,0 +1,19 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"collections",
"creatorId",
"createdById"
);
},
down: async (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"collections",
"createdById",
"creatorId"
);
},
};

View File

@@ -1,8 +1,8 @@
// @flow
import { find, concat, remove, uniq } from "lodash";
import { find, findIndex, concat, remove, uniq } from "lodash";
import randomstring from "randomstring";
import slug from "slug";
import { DataTypes, sequelize } from "../sequelize";
import { Op, DataTypes, sequelize } from "../sequelize";
import CollectionUser from "./CollectionUser";
import Document from "./Document";
@@ -24,6 +24,27 @@ const Collection = sequelize.define(
private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN,
documentStructure: DataTypes.JSONB,
sort: {
type: DataTypes.JSONB,
validate: {
isSort(value) {
if (
typeof value !== "object" ||
!value.direction ||
!value.field ||
Object.keys(value).length !== 2
) {
throw new Error("Sort must be an object with field,direction");
}
if (!["asc", "desc"].includes(value.direction)) {
throw new Error("Sort direction must be one of asc,desc");
}
if (!["title", "index"].includes(value.field)) {
throw new Error("Sort field must be one of title,index");
}
},
},
},
},
{
tableName: "collections",
@@ -41,6 +62,11 @@ const Collection = sequelize.define(
}
);
Collection.DEFAULT_SORT = {
field: "index",
direction: "asc",
};
Collection.addHook("beforeSave", async (model) => {
if (model.icon === "collection") {
model.icon = null;
@@ -77,7 +103,7 @@ Collection.associate = (models) => {
});
Collection.belongsTo(models.User, {
as: "user",
foreignKey: "creatorId",
foreignKey: "createdById",
});
Collection.belongsTo(models.Team, {
as: "team",
@@ -156,6 +182,9 @@ Collection.addHook("afterDestroy", async (model: Collection) => {
await Document.destroy({
where: {
collectionId: model.id,
archivedAt: {
[Op.eq]: null,
},
},
});
});
@@ -165,11 +194,11 @@ Collection.addHook("afterCreate", (model: Collection, options) => {
return CollectionUser.findOrCreate({
where: {
collectionId: model.id,
userId: model.creatorId,
userId: model.createdById,
},
defaults: {
permission: "read_write",
createdById: model.creatorId,
createdById: model.createdById,
},
transaction: options.transaction,
});
@@ -350,7 +379,7 @@ Collection.prototype.removeDocumentInStructure = async function (
const match = find(children, { id });
if (match) {
if (!returnValue) returnValue = match;
if (!returnValue) returnValue = [match, findIndex(children, { id })];
remove(children, { id });
}

View File

@@ -131,9 +131,9 @@ describe("#updateDocument", () => {
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
userId: collection.createdById,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title: "Child document",
text: "content",
});
@@ -184,9 +184,9 @@ describe("#removeDocument", () => {
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
userId: collection.createdById,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title: "Child document",
text: "content",
});
@@ -212,9 +212,9 @@ describe("#removeDocument", () => {
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
userId: collection.createdById,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
publishedAt: new Date(),
title: "Child document",
text: "content",

View File

@@ -490,7 +490,7 @@ Document.addHook("afterCreate", async (model) => {
return;
}
await collection.addDocumentToStructure(model);
await collection.addDocumentToStructure(model, 0);
model.collection = collection;
return model;
@@ -575,24 +575,30 @@ Document.prototype.archiveWithChildren = async function (userId, options) {
return this.save(options);
};
Document.prototype.publish = async function (options) {
Document.prototype.publish = async function (userId: string, options) {
if (this.publishedAt) return this.save(options);
const collection = await Collection.findByPk(this.collectionId);
await collection.addDocumentToStructure(this);
await collection.addDocumentToStructure(this, 0);
this.lastModifiedById = userId;
this.publishedAt = new Date();
await this.save(options);
return this;
};
Document.prototype.unpublish = async function (options) {
Document.prototype.unpublish = async function (userId: string, options) {
if (!this.publishedAt) return this;
const collection = await this.getCollection();
await collection.removeDocumentInStructure(this);
// unpublishing a document converts the "ownership" to yourself, so that it
// can appear in your drafts rather than the original creators
this.userId = userId;
this.lastModifiedById = userId;
this.publishedAt = null;
await this.save(options);
@@ -650,8 +656,10 @@ Document.prototype.delete = function (userId: string) {
async (transaction: Transaction): Promise<Document> => {
if (!this.archivedAt && !this.template) {
// delete any children and remove from the document structure
const collection = await this.getCollection();
const collection = await this.getCollection({ transaction });
if (collection) await collection.deleteDocument(this, { transaction });
} else {
await this.destroy({ transaction });
}
await Revision.destroy({
@@ -659,10 +667,13 @@ Document.prototype.delete = function (userId: string) {
transaction,
});
this.lastModifiedById = userId;
this.deletedAt = new Date();
await this.update(
{ lastModifiedById: userId },
{
transaction,
}
);
await this.save({ transaction });
return this;
}
);

View File

@@ -279,4 +279,25 @@ describe("#delete", () => {
expect(document.lastModifiedById).toBe(user.id);
expect(document.deletedAt).toBeTruthy();
});
test("should soft delete templates", async () => {
let document = await buildDocument({ template: true });
let user = await buildUser();
await document.delete(user.id);
document = await Document.findByPk(document.id, { paranoid: false });
expect(document.lastModifiedById).toBe(user.id);
expect(document.deletedAt).toBeTruthy();
});
test("should soft delete archived", async () => {
let document = await buildDocument({ archivedAt: new Date() });
let user = await buildUser();
await document.delete(user.id);
document = await Document.findByPk(document.id, { paranoid: false });
expect(document.lastModifiedById).toBe(user.id);
expect(document.deletedAt).toBeTruthy();
});
});

View File

@@ -62,6 +62,7 @@ Event.ACTIVITY_EVENTS = [
"documents.unarchive",
"documents.pin",
"documents.unpin",
"documents.move",
"documents.delete",
"documents.restore",
"users.create",
@@ -86,6 +87,7 @@ Event.AUDIT_EVENTS = [
"documents.unpin",
"documents.move",
"documents.delete",
"documents.restore",
"groups.create",
"groups.update",
"groups.delete",

View File

@@ -69,6 +69,7 @@ const Team = sequelize.define(
slackData: DataTypes.JSONB,
},
{
paranoid: true,
getterMethods: {
url() {
if (this.domain) {
@@ -143,7 +144,8 @@ Team.prototype.provisionFirstCollection = async function (userId) {
description:
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
teamId: this.id,
creatorId: userId,
createdById: userId,
sort: Collection.DEFAULT_SORT,
});
// For the first collection we go ahead and create some intitial documents to get
@@ -173,13 +175,13 @@ Team.prototype.provisionFirstCollection = async function (userId) {
parentDocumentId: null,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
userId: collection.createdById,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title,
text,
});
await document.publish();
await document.publish(collection.createdById);
}
};

View File

@@ -77,7 +77,7 @@ allow(User, "delete", Collection, (user, collection) => {
}
if (user.isAdmin) return true;
if (user.id === collection.creatorId) return true;
if (user.id === collection.createdById) return true;
throw new AdminRequiredError();
});

View File

@@ -7,7 +7,7 @@ Object {
"id": "123",
"isAdmin": undefined,
"isSuspended": undefined,
"language": undefined,
"language": "en_US",
"lastActiveAt": undefined,
"name": "Test User",
}
@@ -20,7 +20,7 @@ Object {
"id": "123",
"isAdmin": undefined,
"isSuspended": undefined,
"language": undefined,
"language": "en_US",
"lastActiveAt": undefined,
"name": "Test User",
}

View File

@@ -9,12 +9,14 @@ type Document = {
url: string,
};
const sortDocuments = (documents: Document[]): Document[] => {
const orderedDocs = naturalSort(documents, "title");
const sortDocuments = (documents: Document[], sort): Document[] => {
const orderedDocs = naturalSort(documents, sort.field, {
direction: sort.direction,
});
return orderedDocs.map((document) => ({
...document,
children: sortDocuments(document.children),
children: sortDocuments(document.children, sort),
}));
};
@@ -24,17 +26,26 @@ export default function present(collection: Collection) {
url: collection.url,
name: collection.name,
description: collection.description,
sort: collection.sort,
icon: collection.icon,
color: collection.color || "#4E5C6E",
private: collection.private,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt,
documents: undefined,
documents: collection.documentStructure || [],
};
// Force alphabetical sorting
data.documents = sortDocuments(collection.documentStructure);
// Handle the "sort" field being empty here for backwards compatability
if (!data.sort) {
data.sort = { field: "title", direction: "asc" };
}
// "index" field is manually sorted and is represented by the documentStructure
// already saved in the database, no further sort is needed
if (data.sort.field !== "index") {
data.documents = sortDocuments(collection.documentStructure, data.sort);
}
return data;
}

View File

@@ -24,7 +24,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
userData.isAdmin = user.isAdmin;
userData.isSuspended = user.isSuspended;
userData.avatarUrl = user.avatarUrl;
userData.language = user.language;
userData.language = user.language || process.env.DEFAULT_LANGUAGE || "en_US";
if (options.includeDetails) {
userData.email = user.email;

View File

@@ -7,9 +7,10 @@ import Router from "koa-router";
import sendfile from "koa-sendfile";
import serve from "koa-static";
import { languages } from "../shared/i18n";
import environment from "./env";
import env from "./env";
import apexRedirect from "./middlewares/apexRedirect";
import { opensearchResponse } from "./utils/opensearch";
import prefetchTags from "./utils/prefetchTags";
import { robotsResponse } from "./utils/robots";
const isProduction = process.env.NODE_ENV === "production";
@@ -44,13 +45,13 @@ const renderApp = async (ctx, next) => {
}
const page = await readIndexFile(ctx);
const env = `
window.env = ${JSON.stringify(environment)};
const environment = `
window.env = ${JSON.stringify(env)};
`;
ctx.body = page
.toString()
.replace(/\/\/inject-env\/\//g, env)
.replace(/\/\/inject-sentry-dsn\/\//g, process.env.SENTRY_DSN || "")
.replace(/\/\/inject-env\/\//g, environment)
.replace(/\/\/inject-prefetch\/\//g, prefetchTags)
.replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || "");
};
@@ -110,7 +111,19 @@ router.get("/share/*", (ctx, next) => {
// catch all for application
router.get("*", renderApp);
// middleware
// In order to report all possible performance metrics to Sentry this header
// 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");
}
koa.use(async (ctx, next) => {
ctx.set("Timing-Allow-Origin", timingOrigins.join(", "));
await next();
});
koa.use(apexRedirect());
koa.use(router.routes());

View File

@@ -17,7 +17,9 @@ export default class Backlinks {
await Promise.all(
linkIds.map(async (linkId) => {
const linkedDocument = await Document.findByPk(linkId);
if (linkedDocument.id === event.documentId) return;
if (!linkedDocument || linkedDocument.id === event.documentId) {
return;
}
await Backlink.findOrCreate({
where: {

View File

@@ -9,6 +9,56 @@ const Backlinks = new BacklinksService();
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
describe("documents.publish", () => {
test("should create new backlink records", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument({
text: `[this is a link](${otherDocument.url})`,
});
await Backlinks.on({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: document.createdById,
});
const backlinks = await Backlink.findAll({
where: { reverseDocumentId: document.id },
});
expect(backlinks.length).toBe(1);
});
test("should not fail when linked document is destroyed", async () => {
const otherDocument = await buildDocument();
await otherDocument.destroy();
const document = await buildDocument({
version: null,
text: `[ ] checklist item`,
});
document.text = `[this is a link](${otherDocument.url})`;
await document.save();
await Backlinks.on({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: document.createdById,
});
const backlinks = await Backlink.findAll({
where: { reverseDocumentId: document.id },
});
expect(backlinks.length).toBe(0);
});
});
describe("documents.update", () => {
test("should not fail on a document with no previous revisions", async () => {
const otherDocument = await buildDocument();

View File

@@ -4,10 +4,12 @@
<title>Outline</title>
<meta name="slack-app-id" content="//inject-slack-app-id//" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…">
//inject-prefetch//
<link
rel="shortcut icon"
type="image/png"
href="favicon-32.png"
href="/favicon-32.png"
sizes="32x32"
/>
<link rel="manifest" href="/manifest.json" />
@@ -42,29 +44,7 @@
<script>
//inject-env//
</script>
<script
src="https://browser.sentry-cdn.com/5.22.3/bundle.min.js"
integrity="sha384-A1qzcXXJWl+bzYr+r8AdFzSaLbdcbYRFmG37MEDKr4EYjtraUyoZ6UiMw31jHcV9"
crossorigin="anonymous"
></script>
<script>
if ("//inject-sentry-dsn//" && window.Sentry) {
window.Sentry.init({
dsn: "//inject-sentry-dsn//",
ignoreErrors: [
"ResizeObserver loop limit exceeded",
"AuthorizationError",
"BadRequestError",
"NetworkError",
"NotFoundError",
"OfflineError",
"ServiceUnavailableError",
"UpdateRequiredError",
"ChunkLoadError",
],
});
}
if (window.localStorage && window.localStorage.getItem("theme") === "dark") {
window.document.querySelector("#root").style.background = "#111319";
}

View File

@@ -113,7 +113,7 @@ export async function buildCollection(overrides: Object = {}) {
return Collection.create({
name: `Test Collection ${count}`,
description: "Test collection description",
creatorId: overrides.userId,
createdById: overrides.userId,
...overrides,
});
}

View File

@@ -61,20 +61,20 @@ const seed = async () => {
name: "Collection",
urlId: "collection",
teamId: team.id,
creatorId: user.id,
createdById: user.id,
});
const document = await Document.create({
parentDocumentId: null,
collectionId: collection.id,
teamId: team.id,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
userId: collection.createdById,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title: "First ever document",
text: "# Much test support",
});
await document.publish();
await document.publish(collection.createdById);
await collection.reload();
return {

View File

@@ -2,7 +2,7 @@
import subMinutes from "date-fns/sub_minutes";
import JWT from "jsonwebtoken";
import { AuthenticationError } from "../errors";
import { User } from "../models";
import { Team, User } from "../models";
function getJWTPayload(token) {
let payload;
@@ -28,7 +28,15 @@ export async function getUserForJWT(token: string): Promise<User> {
}
}
const user = await User.findByPk(payload.id);
const user = await User.findByPk(payload.id, {
include: [
{
model: Team,
as: "team",
required: true,
},
],
});
if (payload.type === "transfer") {
// If the user has made a single API request since the transfer token was

View File

@@ -2,48 +2,63 @@
import fs from "fs";
import path from "path";
import * as React from "react";
import webpackConfig from "../../webpack.config";
import ReactDOMServer from "react-dom/server";
import env from "../env";
const PUBLIC_PATH = webpackConfig.output.publicPath;
const prefetchTags = [];
const prefetchTags = [
<link
rel="dns-prefetch"
href={process.env.AWS_S3_UPLOAD_BUCKET_URL}
key="dns"
/>,
];
if (process.env.AWS_S3_UPLOAD_BUCKET_URL) {
prefetchTags.push(
<link
rel="dns-prefetch"
href={process.env.AWS_S3_UPLOAD_BUCKET_URL}
key="dns"
/>
);
}
let manifestData = {};
try {
const manifest = fs.readFileSync(
path.join(__dirname, "../../app/manifest.json"),
"utf8"
);
const manifestData = JSON.parse(manifest);
Object.values(manifestData).forEach((filename) => {
if (typeof filename !== "string") return;
if (filename.endsWith(".js")) {
manifestData = JSON.parse(manifest);
} catch (err) {
// no-op
}
let index = 0;
Object.values(manifestData).forEach((filename) => {
if (typeof filename !== "string") return;
if (!env.CDN_URL) return;
if (filename.endsWith(".js")) {
// Preload resources you have high-confidence will be used in the current
// page.Prefetch resources likely to be used for future navigations
const shouldPreload =
filename.includes("/main") ||
filename.includes("/runtime") ||
filename.includes("/vendors");
// only prefetch the first few javascript chunks or it gets out of hand fast
const shouldPrefetch = ++index <= 6;
if (shouldPreload || shouldPrefetch) {
prefetchTags.push(
<link
rel="prefetch"
href={`${PUBLIC_PATH}${filename}`}
rel={shouldPreload ? "preload" : "prefetch"}
href={filename}
key={filename}
as="script"
/>
);
} else if (filename.endsWith(".css")) {
prefetchTags.push(
<link
rel="prefetch"
href={`${PUBLIC_PATH}${filename}`}
key={filename}
as="style"
/>
);
}
});
} catch (_e) {
// no-op
}
} else if (filename.endsWith(".css")) {
prefetchTags.push(
<link rel="prefetch" href={filename} key={filename} as="style" />
);
}
});
export default prefetchTags;
export default ReactDOMServer.renderToString(prefetchTags);

View File

@@ -16,7 +16,7 @@ const s3 = new AWS.S3({
s3ForcePathStyle: AWS_S3_FORCE_PATH_STYLE,
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
endpoint: new AWS.Endpoint(process.env.AWS_S3_UPLOAD_BUCKET_URL),
region: AWS_REGION,
signatureVersion: "v4",
});
@@ -84,6 +84,14 @@ export const publicS3Endpoint = (isServerUpload?: boolean) => {
"localhost:"
).replace(/\/$/, "");
// support old path-style S3 uploads and new virtual host uploads by checking
// for the bucket name in the endpoint url before appending.
const isVirtualHost = host.includes(AWS_S3_UPLOAD_BUCKET_NAME);
if (isVirtualHost) {
return host;
}
return `${host}/${
isServerUpload && isDocker ? "s3/" : ""
}${AWS_S3_UPLOAD_BUCKET_NAME}`;