Merge main
This commit is contained in:
@@ -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
43
server/api/auth.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
14
server/migrations/20201230031607-collection-sort.js
Normal file
14
server/migrations/20201230031607-collection-sort.js
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, & 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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user