diff --git a/server/routes/api/stars.ts b/server/routes/api/stars.ts deleted file mode 100644 index bf72a252c..000000000 --- a/server/routes/api/stars.ts +++ /dev/null @@ -1,157 +0,0 @@ -import Router from "koa-router"; -import { Sequelize } from "sequelize"; -import starCreator from "@server/commands/starCreator"; -import starDestroyer from "@server/commands/starDestroyer"; -import starUpdater from "@server/commands/starUpdater"; -import { sequelize } from "@server/database/sequelize"; -import auth from "@server/middlewares/authentication"; -import { Document, Star, Collection } from "@server/models"; -import { authorize } from "@server/policies"; -import { - presentStar, - presentDocument, - presentPolicies, -} from "@server/presenters"; -import { APIContext } from "@server/types"; -import { starIndexing } from "@server/utils/indexing"; -import { assertUuid, assertIndexCharacters } from "@server/validation"; -import pagination from "./middlewares/pagination"; - -const router = new Router(); - -router.post("stars.create", auth(), async (ctx: APIContext) => { - const { documentId, collectionId } = ctx.request.body; - const { index } = ctx.request.body; - const { user } = ctx.state.auth; - - assertUuid( - documentId || collectionId, - "documentId or collectionId is required" - ); - - if (documentId) { - const document = await Document.findByPk(documentId, { - userId: user.id, - }); - authorize(user, "star", document); - } - - if (collectionId) { - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "star", collection); - } - - if (index) { - assertIndexCharacters(index); - } - - const star = await sequelize.transaction(async (transaction) => - starCreator({ - user, - documentId, - collectionId, - ip: ctx.request.ip, - index, - transaction, - }) - ); - ctx.body = { - data: presentStar(star), - policies: presentPolicies(user, [star]), - }; -}); - -router.post("stars.list", auth(), pagination(), async (ctx: APIContext) => { - const { user } = ctx.state.auth; - - const [stars, collectionIds] = await Promise.all([ - Star.findAll({ - where: { - userId: user.id, - }, - order: [ - Sequelize.literal('"star"."index" collate "C"'), - ["updatedAt", "DESC"], - ], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }), - user.collectionIds(), - ]); - - const nullIndex = stars.findIndex((star) => star.index === null); - - if (nullIndex !== -1) { - const indexedStars = await starIndexing(user.id); - stars.forEach((star) => { - star.index = indexedStars[star.id]; - }); - } - - const documentIds = stars - .map((star) => star.documentId) - .filter(Boolean) as string[]; - const documents = documentIds.length - ? await Document.defaultScopeWithUser(user.id).findAll({ - where: { - id: documentIds, - collectionId: collectionIds, - }, - }) - : []; - - const policies = presentPolicies(user, [...documents, ...stars]); - - ctx.body = { - pagination: ctx.state.pagination, - data: { - stars: stars.map(presentStar), - documents: await Promise.all( - documents.map((document: Document) => presentDocument(document)) - ), - }, - policies, - }; -}); - -router.post("stars.update", auth(), async (ctx: APIContext) => { - const { id, index } = ctx.request.body; - assertUuid(id, "id is required"); - - assertIndexCharacters(index); - - const { user } = ctx.state.auth; - let star = await Star.findByPk(id); - authorize(user, "update", star); - - star = await starUpdater({ - user, - star, - ip: ctx.request.ip, - index, - }); - - ctx.body = { - data: presentStar(star), - policies: presentPolicies(user, [star]), - }; -}); - -router.post("stars.delete", auth(), async (ctx: APIContext) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); - - const { user } = ctx.state.auth; - const star = await Star.findByPk(id); - authorize(user, "delete", star); - - await starDestroyer({ user, star, ip: ctx.request.ip }); - - ctx.body = { - success: true, - }; -}); - -export default router; diff --git a/server/routes/api/stars/index.ts b/server/routes/api/stars/index.ts new file mode 100644 index 000000000..7c89d705f --- /dev/null +++ b/server/routes/api/stars/index.ts @@ -0,0 +1 @@ +export { default } from "./stars"; diff --git a/server/routes/api/stars/schema.ts b/server/routes/api/stars/schema.ts new file mode 100644 index 000000000..0cd460c8d --- /dev/null +++ b/server/routes/api/stars/schema.ts @@ -0,0 +1,54 @@ +import { isEmpty } from "lodash"; +import { z } from "zod"; +import { ValidateDocumentId, ValidateIndex } from "@server/validation"; +import BaseSchema from "../BaseSchema"; + +export const StarsCreateSchema = BaseSchema.extend({ + body: z + .object({ + documentId: z + .string() + .refine(ValidateDocumentId.isValid, { + message: ValidateDocumentId.message, + }) + .optional(), + collectionId: z.string().uuid().optional(), + index: z + .string() + .regex(ValidateIndex.regex, { + message: ValidateIndex.message, + }) + .optional(), + }) + .refine( + (body) => !(isEmpty(body.documentId) && isEmpty(body.collectionId)), + { + message: "One of documentId or collectionId is required", + } + ), +}); + +export type StarsCreateReq = z.infer; + +export const StarsListSchema = BaseSchema; + +export type StarsListReq = z.infer; + +export const StarsUpdateSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + index: z.string().regex(ValidateIndex.regex, { + message: ValidateIndex.message, + }), + }), +}); + +export type StarsUpdateReq = z.infer; + +export const StarsDeleteSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + }), +}); + +export type StarsDeleteReq = z.infer; diff --git a/server/routes/api/stars.test.ts b/server/routes/api/stars/stars.test.ts similarity index 53% rename from server/routes/api/stars.test.ts rename to server/routes/api/stars/stars.test.ts index d5a5dc1bd..58ffb01e2 100644 --- a/server/routes/api/stars.test.ts +++ b/server/routes/api/stars/stars.test.ts @@ -4,6 +4,22 @@ import { getTestServer } from "@server/test/support"; const server = getTestServer(); describe("#stars.create", () => { + it("should fail with status 400 bad request when both documentId and collectionId are missing", async () => { + const user = await buildUser(); + + const res = await server.post("/api/stars.create", { + body: { + token: user.getJwtToken(), + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual( + "body: One of documentId or collectionId is required" + ); + }); + it("should create a star", async () => { const user = await buildUser(); const document = await buildDocument({ @@ -57,7 +73,52 @@ describe("#stars.list", () => { }); }); +describe("#stars.update", () => { + it("should fail with status 400 bad request when id is missing", async () => { + const user = await buildUser(); + const res = await server.post("/api/stars.update", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + + it("should succeed with status 200 ok", async () => { + const user = await buildUser(); + const star = await buildStar({ + userId: user.id, + }); + const res = await server.post("/api/stars.update", { + body: { + token: user.getJwtToken(), + id: star.id, + index: "i", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toBeTruthy(); + expect(body.data.id).toEqual(star.id); + expect(body.data.index).toEqual("i"); + }); +}); + describe("#stars.delete", () => { + it("should fail with status 400 bad request when id is missing", async () => { + const user = await buildUser(); + const res = await server.post("/api/stars.delete", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + it("should delete users star", async () => { const user = await buildUser(); const star = await buildStar({ diff --git a/server/routes/api/stars/stars.ts b/server/routes/api/stars/stars.ts new file mode 100644 index 000000000..af5a6e782 --- /dev/null +++ b/server/routes/api/stars/stars.ts @@ -0,0 +1,165 @@ +import Router from "koa-router"; +import { Sequelize } from "sequelize"; +import starCreator from "@server/commands/starCreator"; +import starDestroyer from "@server/commands/starDestroyer"; +import starUpdater from "@server/commands/starUpdater"; +import { sequelize } from "@server/database/sequelize"; +import auth from "@server/middlewares/authentication"; +import validate from "@server/middlewares/validate"; +import { Document, Star, Collection } from "@server/models"; +import { authorize } from "@server/policies"; +import { + presentStar, + presentDocument, + presentPolicies, +} from "@server/presenters"; +import { APIContext } from "@server/types"; +import { starIndexing } from "@server/utils/indexing"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "stars.create", + auth(), + validate(T.StarsCreateSchema), + async (ctx: APIContext) => { + const { documentId, collectionId, index } = ctx.input.body; + const { user } = ctx.state.auth; + + if (documentId) { + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "star", document); + } + + if (collectionId) { + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "star", collection); + } + + const star = await sequelize.transaction(async (transaction) => + starCreator({ + user, + documentId, + collectionId, + ip: ctx.request.ip, + index, + transaction, + }) + ); + ctx.body = { + data: presentStar(star), + policies: presentPolicies(user, [star]), + }; + } +); + +router.post( + "stars.list", + auth(), + pagination(), + validate(T.StarsListSchema), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + + const [stars, collectionIds] = await Promise.all([ + Star.findAll({ + where: { + userId: user.id, + }, + order: [ + Sequelize.literal('"star"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + user.collectionIds(), + ]); + + const nullIndex = stars.findIndex((star) => star.index === null); + + if (nullIndex !== -1) { + const indexedStars = await starIndexing(user.id); + stars.forEach((star) => { + star.index = indexedStars[star.id]; + }); + } + + const documentIds = stars + .map((star) => star.documentId) + .filter(Boolean) as string[]; + const documents = documentIds.length + ? await Document.defaultScopeWithUser(user.id).findAll({ + where: { + id: documentIds, + collectionId: collectionIds, + }, + }) + : []; + + const policies = presentPolicies(user, [...documents, ...stars]); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + stars: stars.map(presentStar), + documents: await Promise.all( + documents.map((document: Document) => presentDocument(document)) + ), + }, + policies, + }; + } +); + +router.post( + "stars.update", + auth(), + validate(T.StarsUpdateSchema), + async (ctx: APIContext) => { + const { id, index } = ctx.input.body; + + const { user } = ctx.state.auth; + let star = await Star.findByPk(id); + authorize(user, "update", star); + + star = await starUpdater({ + user, + star, + ip: ctx.request.ip, + index, + }); + + ctx.body = { + data: presentStar(star), + policies: presentPolicies(user, [star]), + }; + } +); + +router.post( + "stars.delete", + auth(), + validate(T.StarsDeleteSchema), + async (ctx: APIContext) => { + const { id } = ctx.input.body; + + const { user } = ctx.state.auth; + const star = await Star.findByPk(id); + authorize(user, "delete", star); + + await starDestroyer({ user, star, ip: ctx.request.ip }); + + ctx.body = { + success: true, + }; + } +); + +export default router; diff --git a/server/validation.ts b/server/validation.ts index c1995448f..a8e9dd0ea 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -1,6 +1,8 @@ import { isArrayLike } from "lodash"; import { Primitive } from "utility-types"; import validator from "validator"; +import isUUID from "validator/lib/isUUID"; +import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { CollectionPermission } from "../shared/types"; import { validateColorHex } from "../shared/utils/color"; import { validateIndexCharacters } from "../shared/utils/indexCharacters"; @@ -165,3 +167,22 @@ export const assertCollectionPermission = ( ) => { assertIn(value, [...Object.values(CollectionPermission), null], message); }; + +export class ValidateDocumentId { + /** + * Checks if documentId is valid. A valid documentId is either + * a UUID or a url slug matching a particular regex. + * + * @param documentId + * @returns true if documentId is valid, false otherwise + */ + public static isValid = (documentId: string) => + isUUID(documentId) || SLUG_URL_REGEX.test(documentId); + + public static message = "Must be uuid or url slug"; +} + +export class ValidateIndex { + public static regex = new RegExp("^[\x20-\x7E]+$"); + public static message = "Must be between x20 to x7E ASCII"; +}