chore: request validation for pins (#5465)
This commit is contained in:
@@ -1,158 +0,0 @@
|
|||||||
import Router from "koa-router";
|
|
||||||
import { Sequelize, Op } from "sequelize";
|
|
||||||
import pinCreator from "@server/commands/pinCreator";
|
|
||||||
import pinDestroyer from "@server/commands/pinDestroyer";
|
|
||||||
import pinUpdater from "@server/commands/pinUpdater";
|
|
||||||
import auth from "@server/middlewares/authentication";
|
|
||||||
import { Collection, Document, Pin } from "@server/models";
|
|
||||||
import { authorize } from "@server/policies";
|
|
||||||
import {
|
|
||||||
presentPin,
|
|
||||||
presentDocument,
|
|
||||||
presentPolicies,
|
|
||||||
} from "@server/presenters";
|
|
||||||
import { APIContext } from "@server/types";
|
|
||||||
import { assertUuid, assertIndexCharacters } from "@server/validation";
|
|
||||||
import pagination from "./middlewares/pagination";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
router.post("pins.create", auth(), async (ctx: APIContext) => {
|
|
||||||
const { documentId, collectionId } = ctx.request.body;
|
|
||||||
const { index } = ctx.request.body;
|
|
||||||
assertUuid(documentId, "documentId is required");
|
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
|
||||||
const document = await Document.findByPk(documentId, {
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
authorize(user, "read", document);
|
|
||||||
|
|
||||||
if (collectionId) {
|
|
||||||
const collection = await Collection.scope({
|
|
||||||
method: ["withMembership", user.id],
|
|
||||||
}).findByPk(collectionId);
|
|
||||||
authorize(user, "update", collection);
|
|
||||||
authorize(user, "pin", document);
|
|
||||||
} else {
|
|
||||||
authorize(user, "pinToHome", document);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index) {
|
|
||||||
assertIndexCharacters(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pin = await pinCreator({
|
|
||||||
user,
|
|
||||||
documentId,
|
|
||||||
collectionId,
|
|
||||||
ip: ctx.request.ip,
|
|
||||||
index,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
data: presentPin(pin),
|
|
||||||
policies: presentPolicies(user, [pin]),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("pins.list", auth(), pagination(), async (ctx: APIContext) => {
|
|
||||||
const { collectionId } = ctx.request.body;
|
|
||||||
const { user } = ctx.state.auth;
|
|
||||||
|
|
||||||
const [pins, collectionIds] = await Promise.all([
|
|
||||||
Pin.findAll({
|
|
||||||
where: {
|
|
||||||
...(collectionId
|
|
||||||
? { collectionId }
|
|
||||||
: { collectionId: { [Op.is]: null } }),
|
|
||||||
teamId: user.teamId,
|
|
||||||
},
|
|
||||||
order: [
|
|
||||||
Sequelize.literal('"pin"."index" collate "C"'),
|
|
||||||
["updatedAt", "DESC"],
|
|
||||||
],
|
|
||||||
offset: ctx.state.pagination.offset,
|
|
||||||
limit: ctx.state.pagination.limit,
|
|
||||||
}),
|
|
||||||
user.collectionIds(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const documents = await Document.defaultScopeWithUser(user.id).findAll({
|
|
||||||
where: {
|
|
||||||
id: pins.map((pin) => pin.documentId),
|
|
||||||
collectionId: collectionIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const policies = presentPolicies(user, [...documents, ...pins]);
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
pagination: ctx.state.pagination,
|
|
||||||
data: {
|
|
||||||
pins: pins.map(presentPin),
|
|
||||||
documents: await Promise.all(
|
|
||||||
documents.map((document: Document) => presentDocument(document))
|
|
||||||
),
|
|
||||||
},
|
|
||||||
policies,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("pins.update", auth(), async (ctx: APIContext) => {
|
|
||||||
const { id, index } = ctx.request.body;
|
|
||||||
assertUuid(id, "id is required");
|
|
||||||
|
|
||||||
assertIndexCharacters(index);
|
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
|
||||||
let pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
|
||||||
|
|
||||||
const document = await Document.findByPk(pin.documentId, {
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pin.collectionId) {
|
|
||||||
authorize(user, "pin", document);
|
|
||||||
} else {
|
|
||||||
authorize(user, "update", pin);
|
|
||||||
}
|
|
||||||
|
|
||||||
pin = await pinUpdater({
|
|
||||||
user,
|
|
||||||
pin,
|
|
||||||
ip: ctx.request.ip,
|
|
||||||
index,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
data: presentPin(pin),
|
|
||||||
policies: presentPolicies(user, [pin]),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("pins.delete", auth(), async (ctx: APIContext) => {
|
|
||||||
const { id } = ctx.request.body;
|
|
||||||
assertUuid(id, "id is required");
|
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
|
||||||
const pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
|
||||||
|
|
||||||
const document = await Document.findByPk(pin.documentId, {
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pin.collectionId) {
|
|
||||||
authorize(user, "unpin", document);
|
|
||||||
} else {
|
|
||||||
authorize(user, "delete", pin);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pinDestroyer({ user, pin, ip: ctx.request.ip });
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
1
server/routes/api/pins/index.ts
Normal file
1
server/routes/api/pins/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./pins";
|
||||||
410
server/routes/api/pins/pins.test.ts
Normal file
410
server/routes/api/pins/pins.test.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { Collection, Document, Pin, User } from "@server/models";
|
||||||
|
import {
|
||||||
|
buildAdmin,
|
||||||
|
buildCollection,
|
||||||
|
buildDocument,
|
||||||
|
buildDraftDocument,
|
||||||
|
buildPin,
|
||||||
|
buildUser,
|
||||||
|
} from "@server/test/factories";
|
||||||
|
import { getTestServer } from "@server/test/support";
|
||||||
|
|
||||||
|
const server = getTestServer();
|
||||||
|
|
||||||
|
describe("#pins.create", () => {
|
||||||
|
let admin: User;
|
||||||
|
let user: User;
|
||||||
|
let anotherUser: User;
|
||||||
|
let document: Document;
|
||||||
|
let collection: Collection;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
admin = await buildAdmin();
|
||||||
|
[user, anotherUser] = await Promise.all([
|
||||||
|
buildUser({ teamId: admin.teamId }),
|
||||||
|
buildUser(),
|
||||||
|
]);
|
||||||
|
collection = await buildCollection({
|
||||||
|
createdById: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
});
|
||||||
|
document = await buildDocument({
|
||||||
|
createdById: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
documentId: "foo",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body.message).toEqual("Authentication required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when documentId is not suppled", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("documentId: required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when documentId is invalid", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
documentId: "foo",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("documentId: must be uuid or url slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when index is invalid", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
documentId: "foo1234567",
|
||||||
|
index: "😀",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("index: must be between x20 to x7E ASCII");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 403 forbidden when user is disallowed to read the document", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: anotherUser.getJwtToken(),
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
expect(body.message).toEqual("Authorization error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 403 forbidden when user is disallowed to update the collection", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
documentId: document.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
expect(body.message).toEqual("Authorization error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 403 forbidden when user is disallowed to pin the document", async () => {
|
||||||
|
const draft = await buildDraftDocument({
|
||||||
|
createdById: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
// A draft document cannot be pinned, neither by a member nor by an admin
|
||||||
|
documentId: draft.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
expect(body.message).toEqual("Authorization error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 403 forbidden when user is disallowed to pin the document to home page", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
expect(body.message).toEqual("Authorization error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok when user is allowed to pin", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data).toBeTruthy();
|
||||||
|
expect(body.data.documentId).toEqual(document.id);
|
||||||
|
expect(body.data.collectionId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok when valid collectionId is supplied", async () => {
|
||||||
|
const res = await server.post("/api/pins.create", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
documentId: document.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data).toBeTruthy();
|
||||||
|
expect(body.data.documentId).toEqual(document.id);
|
||||||
|
expect(body.data.collectionId).toEqual(collection.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#pins.list", () => {
|
||||||
|
let user: User;
|
||||||
|
let pins: Pin[];
|
||||||
|
let docs: Document[];
|
||||||
|
let collection: Collection;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
user = await buildUser();
|
||||||
|
collection = await buildCollection({
|
||||||
|
teamId: user.teamId,
|
||||||
|
createdById: user.id,
|
||||||
|
});
|
||||||
|
docs = await Promise.all([
|
||||||
|
buildDocument({
|
||||||
|
teamId: user.teamId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
}),
|
||||||
|
buildDocument({
|
||||||
|
teamId: user.teamId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
pins = await Promise.all([
|
||||||
|
buildPin({
|
||||||
|
createdById: user.id,
|
||||||
|
documentId: docs[0].id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
}),
|
||||||
|
buildPin({
|
||||||
|
createdById: user.id,
|
||||||
|
documentId: docs[1].id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||||
|
const res = await server.post("/api/pins.list", {
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body.message).toEqual("Authentication required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok returning pinned documents", async () => {
|
||||||
|
const res = await server.post("/api/pins.list", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data).toBeTruthy();
|
||||||
|
expect(body.data.pins).toBeTruthy();
|
||||||
|
expect(body.data.pins).toHaveLength(2);
|
||||||
|
const pinIds = body.data.pins.map((p: any) => p.id);
|
||||||
|
expect(pinIds).toContain(pins[0].id);
|
||||||
|
expect(pinIds).toContain(pins[1].id);
|
||||||
|
const docIds = body.data.documents.map((d: any) => d.id);
|
||||||
|
expect(docIds).toContain(docs[0].id);
|
||||||
|
expect(docIds).toContain(docs[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok returning pinned documents filtered by collectionId supplied", async () => {
|
||||||
|
const res = await server.post("/api/pins.list", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
collectionId: collection.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data).toBeTruthy();
|
||||||
|
expect(body.data.pins).toBeTruthy();
|
||||||
|
expect(body.data.pins).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#pins.update", () => {
|
||||||
|
let user: User;
|
||||||
|
let admin: User;
|
||||||
|
let pin: Pin;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
user = await buildUser();
|
||||||
|
admin = await buildAdmin();
|
||||||
|
const collection = await buildCollection({
|
||||||
|
createdById: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
});
|
||||||
|
const doc = await buildDocument({
|
||||||
|
createdById: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
pin = await buildPin({
|
||||||
|
teamId: admin.teamId,
|
||||||
|
createdById: admin.id,
|
||||||
|
documentId: doc.id,
|
||||||
|
index: "a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||||
|
const res = await server.post("/api/pins.update", {
|
||||||
|
body: {
|
||||||
|
id: pin.id,
|
||||||
|
index: "i",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body.message).toEqual("Authentication required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when id is missing", async () => {
|
||||||
|
const res = await server.post("/api/pins.update", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("id: Required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when index is missing", async () => {
|
||||||
|
const res = await server.post("/api/pins.update", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: pin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("index: Required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when an invalid index is sent", async () => {
|
||||||
|
const res = await server.post("/api/pins.update", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: pin.id,
|
||||||
|
index: "😀",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("index: must be between x20 to x7E ASCII");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 403 forbidden when user is disallowed to update the pin", async () => {
|
||||||
|
const res = await server.post("/api/pins.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: pin.id,
|
||||||
|
index: "b",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
expect(body.message).toEqual("Authorization error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok and when user is allowed to update the pin", async () => {
|
||||||
|
const res = await server.post("/api/pins.update", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: pin.id,
|
||||||
|
index: "b",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data).toBeTruthy();
|
||||||
|
expect(body.data.id).toEqual(pin.id);
|
||||||
|
expect(body.data.index).toEqual("b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#pins.delete", () => {
|
||||||
|
let admin: User;
|
||||||
|
let pin: Pin;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
admin = await buildAdmin();
|
||||||
|
pin = await buildPin({
|
||||||
|
teamId: admin.teamId,
|
||||||
|
createdById: admin.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||||
|
const res = await server.post("/api/pins.delete", {
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body.message).toEqual("Authentication required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when id is missing", async () => {
|
||||||
|
const res = await server.post("/api/pins.delete", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("id: Required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 403 forbidden when user is disallowed to delete the pin", async () => {
|
||||||
|
const user = await buildUser({
|
||||||
|
teamId: admin.teamId,
|
||||||
|
});
|
||||||
|
const res = await server.post("/api/pins.delete", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: pin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
expect(body.message).toEqual("Authorization error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok when user is allowed to delete the pin", async () => {
|
||||||
|
const res = await server.post("/api/pins.delete", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: pin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.success).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
168
server/routes/api/pins/pins.ts
Normal file
168
server/routes/api/pins/pins.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import Router from "koa-router";
|
||||||
|
import { Sequelize, Op } from "sequelize";
|
||||||
|
import pinCreator from "@server/commands/pinCreator";
|
||||||
|
import pinDestroyer from "@server/commands/pinDestroyer";
|
||||||
|
import pinUpdater from "@server/commands/pinUpdater";
|
||||||
|
import auth from "@server/middlewares/authentication";
|
||||||
|
import validate from "@server/middlewares/validate";
|
||||||
|
import { Collection, Document, Pin } from "@server/models";
|
||||||
|
import { authorize } from "@server/policies";
|
||||||
|
import {
|
||||||
|
presentPin,
|
||||||
|
presentDocument,
|
||||||
|
presentPolicies,
|
||||||
|
} from "@server/presenters";
|
||||||
|
import { APIContext } from "@server/types";
|
||||||
|
import pagination from "../middlewares/pagination";
|
||||||
|
import * as T from "./schema";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"pins.create",
|
||||||
|
auth(),
|
||||||
|
validate(T.PinsCreateSchema),
|
||||||
|
async (ctx: APIContext<T.PinsCreateReq>) => {
|
||||||
|
const { documentId, collectionId, index } = ctx.input.body;
|
||||||
|
const { user } = ctx.state.auth;
|
||||||
|
const document = await Document.findByPk(documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
authorize(user, "read", document);
|
||||||
|
|
||||||
|
if (collectionId) {
|
||||||
|
const collection = await Collection.scope({
|
||||||
|
method: ["withMembership", user.id],
|
||||||
|
}).findByPk(collectionId);
|
||||||
|
authorize(user, "update", collection);
|
||||||
|
authorize(user, "pin", document);
|
||||||
|
} else {
|
||||||
|
authorize(user, "pinToHome", document);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = await pinCreator({
|
||||||
|
user,
|
||||||
|
documentId,
|
||||||
|
collectionId,
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: presentPin(pin),
|
||||||
|
policies: presentPolicies(user, [pin]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"pins.list",
|
||||||
|
auth(),
|
||||||
|
validate(T.PinsListSchema),
|
||||||
|
pagination(),
|
||||||
|
async (ctx: APIContext<T.PinsCreateReq>) => {
|
||||||
|
const { collectionId } = ctx.input.body;
|
||||||
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
|
const [pins, collectionIds] = await Promise.all([
|
||||||
|
Pin.findAll({
|
||||||
|
where: {
|
||||||
|
...(collectionId
|
||||||
|
? { collectionId }
|
||||||
|
: { collectionId: { [Op.is]: null } }),
|
||||||
|
teamId: user.teamId,
|
||||||
|
},
|
||||||
|
order: [
|
||||||
|
Sequelize.literal('"pin"."index" collate "C"'),
|
||||||
|
["updatedAt", "DESC"],
|
||||||
|
],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
}),
|
||||||
|
user.collectionIds(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const documents = await Document.defaultScopeWithUser(user.id).findAll({
|
||||||
|
where: {
|
||||||
|
id: pins.map((pin) => pin.documentId),
|
||||||
|
collectionId: collectionIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const policies = presentPolicies(user, [...documents, ...pins]);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data: {
|
||||||
|
pins: pins.map(presentPin),
|
||||||
|
documents: await Promise.all(
|
||||||
|
documents.map((document: Document) => presentDocument(document))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
policies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"pins.update",
|
||||||
|
auth(),
|
||||||
|
validate(T.PinsUpdateSchema),
|
||||||
|
async (ctx: APIContext<T.PinsUpdateReq>) => {
|
||||||
|
const { id, index } = ctx.input.body;
|
||||||
|
const { user } = ctx.state.auth;
|
||||||
|
let pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
||||||
|
|
||||||
|
const document = await Document.findByPk(pin.documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pin.collectionId) {
|
||||||
|
authorize(user, "pin", document);
|
||||||
|
} else {
|
||||||
|
authorize(user, "update", pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
pin = await pinUpdater({
|
||||||
|
user,
|
||||||
|
pin,
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: presentPin(pin),
|
||||||
|
policies: presentPolicies(user, [pin]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"pins.delete",
|
||||||
|
auth(),
|
||||||
|
validate(T.PinsDeleteSchema),
|
||||||
|
async (ctx: APIContext<T.PinsDeleteReq>) => {
|
||||||
|
const { id } = ctx.input.body;
|
||||||
|
|
||||||
|
const { user } = ctx.state.auth;
|
||||||
|
const pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
||||||
|
|
||||||
|
const document = await Document.findByPk(pin.documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pin.collectionId) {
|
||||||
|
authorize(user, "unpin", document);
|
||||||
|
} else {
|
||||||
|
authorize(user, "delete", pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pinDestroyer({ user, pin, ip: ctx.request.ip });
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
52
server/routes/api/pins/schema.ts
Normal file
52
server/routes/api/pins/schema.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import isUUID from "validator/lib/isUUID";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
||||||
|
import BaseSchema from "../BaseSchema";
|
||||||
|
|
||||||
|
export const PinsCreateSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
documentId: z
|
||||||
|
.string({
|
||||||
|
required_error: "required",
|
||||||
|
})
|
||||||
|
.refine((val) => isUUID(val) || SLUG_URL_REGEX.test(val), {
|
||||||
|
message: "must be uuid or url slug",
|
||||||
|
}),
|
||||||
|
collectionId: z.string().uuid().nullish(),
|
||||||
|
index: z
|
||||||
|
.string()
|
||||||
|
.regex(new RegExp("^[\x20-\x7E]+$"), {
|
||||||
|
message: "must be between x20 to x7E ASCII",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PinsCreateReq = z.infer<typeof PinsCreateSchema>;
|
||||||
|
|
||||||
|
export const PinsListSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
collectionId: z.string().uuid().nullish(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PinsListReq = z.infer<typeof PinsCreateSchema>;
|
||||||
|
|
||||||
|
export const PinsUpdateSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
index: z.string().regex(new RegExp("^[\x20-\x7E]+$"), {
|
||||||
|
message: "must be between x20 to x7E ASCII",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PinsUpdateReq = z.infer<typeof PinsUpdateSchema>;
|
||||||
|
|
||||||
|
export const PinsDeleteSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PinsDeleteReq = z.infer<typeof PinsDeleteSchema>;
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Subscription,
|
Subscription,
|
||||||
Notification,
|
Notification,
|
||||||
SearchQuery,
|
SearchQuery,
|
||||||
|
Pin,
|
||||||
} from "@server/models";
|
} from "@server/models";
|
||||||
|
|
||||||
let count = 1;
|
let count = 1;
|
||||||
@@ -548,3 +549,26 @@ export async function buildSearchQuery(
|
|||||||
|
|
||||||
return SearchQuery.create(overrides);
|
return SearchQuery.create(overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildPin(overrides: Partial<Pin> = {}): Promise<Pin> {
|
||||||
|
if (!overrides.teamId) {
|
||||||
|
const team = await buildTeam();
|
||||||
|
overrides.teamId = team.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overrides.createdById) {
|
||||||
|
const user = await buildUser({
|
||||||
|
teamId: overrides.teamId,
|
||||||
|
});
|
||||||
|
overrides.createdById = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overrides.documentId) {
|
||||||
|
const document = await buildDocument({
|
||||||
|
teamId: overrides.teamId,
|
||||||
|
});
|
||||||
|
overrides.documentId = document.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pin.create(overrides);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user