From 401ae73a0450491b28da846a45a144350bdf80e3 Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Sun, 6 Aug 2023 22:24:13 +0530 Subject: [PATCH] Request validation for `/api/collections.*` (#5619) --- server/models/Collection.ts | 9 +- .../__snapshots__/collections.test.ts.snap | 0 .../api/{ => collections}/collections.test.ts | 25 + .../api/{ => collections}/collections.ts | 469 ++++++++---------- server/routes/api/collections/index.ts | 1 + server/routes/api/collections/schema.ts | 225 +++++++++ server/routes/api/teams/teams.test.ts | 2 +- server/validation.ts | 10 + 8 files changed, 481 insertions(+), 260 deletions(-) rename server/routes/api/{ => collections}/__snapshots__/collections.test.ts.snap (100%) rename server/routes/api/{ => collections}/collections.test.ts (98%) rename server/routes/api/{ => collections}/collections.ts (67%) create mode 100644 server/routes/api/collections/index.ts create mode 100644 server/routes/api/collections/schema.ts diff --git a/server/models/Collection.ts b/server/models/Collection.ts index b4f0d2337..cfaa8441e 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -305,10 +305,11 @@ class Collection extends ParanoidModel { @Column(DataType.UUID) teamId: string; - static DEFAULT_SORT = { - field: "index", - direction: "asc", - }; + static DEFAULT_SORT: { field: "title" | "index"; direction: "asc" | "desc" } = + { + field: "index", + direction: "asc", + }; /** * Returns an array of unique userIds that are members of a collection, diff --git a/server/routes/api/__snapshots__/collections.test.ts.snap b/server/routes/api/collections/__snapshots__/collections.test.ts.snap similarity index 100% rename from server/routes/api/__snapshots__/collections.test.ts.snap rename to server/routes/api/collections/__snapshots__/collections.test.ts.snap diff --git a/server/routes/api/collections.test.ts b/server/routes/api/collections/collections.test.ts similarity index 98% rename from server/routes/api/collections.test.ts rename to server/routes/api/collections/collections.test.ts index af42974f1..826117676 100644 --- a/server/routes/api/collections.test.ts +++ b/server/routes/api/collections/collections.test.ts @@ -499,6 +499,31 @@ describe("#collections.add_group", () => { expect(res.status).toEqual(200); }); + it("should fail with status 400 bad request when permission is null", async () => { + const user = await buildAdmin(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + permission: null, + }); + const group = await buildGroup({ + teamId: user.teamId, + }); + const res = await server.post("/api/collections.add_group", { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + permission: null, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual( + "permission: Expected 'read' | 'read_write' | 'admin', received null" + ); + }); + it("should require group in team", async () => { const user = await buildUser(); const collection = await buildCollection({ diff --git a/server/routes/api/collections.ts b/server/routes/api/collections/collections.ts similarity index 67% rename from server/routes/api/collections.ts rename to server/routes/api/collections/collections.ts index f1fd27e72..fb3dd73b0 100644 --- a/server/routes/api/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -2,20 +2,18 @@ import fractionalIndex from "fractional-index"; import invariant from "invariant"; import Router from "koa-router"; import { Sequelize, Op, WhereOptions } from "sequelize"; -import { randomElement } from "@shared/random"; import { CollectionPermission, - FileOperationFormat, FileOperationState, FileOperationType, } from "@shared/types"; -import { colorPalette } from "@shared/utils/collections"; import collectionExporter from "@server/commands/collectionExporter"; import teamUpdater from "@server/commands/teamUpdater"; import { ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import { transaction } from "@server/middlewares/transaction"; +import validate from "@server/middlewares/validate"; import { Collection, CollectionUser, @@ -41,138 +39,128 @@ import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { collectionIndexing } from "@server/utils/indexing"; import removeIndexCollision from "@server/utils/removeIndexCollision"; -import { - assertUuid, - assertIn, - assertPresent, - assertHexColor, - assertIndexCharacters, - assertCollectionPermission, - assertBoolean, -} from "@server/validation"; -import pagination from "./middlewares/pagination"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; const router = new Router(); -router.post("collections.create", auth(), async (ctx: APIContext) => { - const { - name, - color = randomElement(colorPalette), - description, - permission, - sharing, - icon, - sort = Collection.DEFAULT_SORT, - } = ctx.request.body; - let { index } = ctx.request.body; - assertPresent(name, "name is required"); +router.post( + "collections.create", + auth(), + validate(T.CollectionsCreateSchema), + async (ctx: APIContext) => { + const { name, color, description, permission, sharing, icon, sort } = + ctx.input.body; + let { index } = ctx.input.body; - if (color) { - assertHexColor(color, "Invalid hex value (please use format #FFFFFF)"); - } + const { user } = ctx.state.auth; + authorize(user, "createCollection", user.team); - const { user } = ctx.state.auth; - authorize(user, "createCollection", user.team); + if (!index) { + const collections = await Collection.findAll({ + where: { + teamId: user.teamId, + deletedAt: null, + }, + attributes: ["id", "index", "updatedAt"], + limit: 1, + order: [ + // using LC_COLLATE:"C" because we need byte order to drive the sorting + Sequelize.literal('"collection"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + }); - if (index) { - assertIndexCharacters(index); - } else { - const collections = await Collection.findAll({ - where: { - teamId: user.teamId, - deletedAt: null, - }, - attributes: ["id", "index", "updatedAt"], - limit: 1, - order: [ - // using LC_COLLATE:"C" because we need byte order to drive the sorting - Sequelize.literal('"collection"."index" collate "C"'), - ["updatedAt", "DESC"], - ], - }); + index = fractionalIndex( + null, + collections.length ? collections[0].index : null + ); + } - index = fractionalIndex( - null, - collections.length ? collections[0].index : null - ); - } - - index = await removeIndexCollision(user.teamId, index); - const collection = await Collection.create({ - name, - description, - icon, - color, - teamId: user.teamId, - createdById: user.id, - permission: permission ? permission : null, - sharing, - sort, - index, - }); - await Event.create({ - name: "collections.create", - collectionId: collection.id, - teamId: collection.teamId, - actorId: user.id, - data: { + index = await removeIndexCollision(user.teamId, index); + const collection = await Collection.create({ name, - }, - ip: ctx.request.ip, - }); - // we must reload the collection to get memberships for policy presenter - const reloaded = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collection.id); - invariant(reloaded, "collection not found"); + description, + icon, + color, + teamId: user.teamId, + createdById: user.id, + permission, + sharing, + sort, + index, + }); + await Event.create({ + name: "collections.create", + collectionId: collection.id, + teamId: collection.teamId, + actorId: user.id, + data: { + name, + }, + ip: ctx.request.ip, + }); + // we must reload the collection to get memberships for policy presenter + const reloaded = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collection.id); + invariant(reloaded, "collection not found"); - ctx.body = { - data: presentCollection(reloaded), - policies: presentPolicies(user, [reloaded]), - }; -}); + ctx.body = { + data: presentCollection(reloaded), + policies: presentPolicies(user, [reloaded]), + }; + } +); -router.post("collections.info", auth(), async (ctx: APIContext) => { - const { id } = ctx.request.body; - assertPresent(id, "id is required"); - const { user } = ctx.state.auth; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id); +router.post( + "collections.info", + auth(), + validate(T.CollectionsInfoSchema), + async (ctx: APIContext) => { + const { id } = ctx.input.body; + const { user } = ctx.state.auth; + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id); - authorize(user, "read", collection); + authorize(user, "read", collection); - ctx.body = { - data: presentCollection(collection), - policies: presentPolicies(user, [collection]), - }; -}); + ctx.body = { + data: presentCollection(collection), + policies: presentPolicies(user, [collection]), + }; + } +); -router.post("collections.documents", auth(), async (ctx: APIContext) => { - const { id } = ctx.request.body; - assertPresent(id, "id is required"); - const { user } = ctx.state.auth; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id); +router.post( + "collections.documents", + auth(), + validate(T.CollectionsDocumentsSchema), + async (ctx: APIContext) => { + const { id } = ctx.input.body; + const { user } = ctx.state.auth; + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id); - authorize(user, "readDocument", collection); + authorize(user, "readDocument", collection); - ctx.body = { - data: collection.documentStructure || [], - }; -}); + ctx.body = { + data: collection.documentStructure || [], + }; + } +); router.post( "collections.import", rateLimiter(RateLimiterStrategy.TenPerHour), auth(), + validate(T.CollectionsImportSchema), transaction(), - async (ctx: APIContext) => { + async (ctx: APIContext) => { const { transaction } = ctx.state; - const { attachmentId, format = FileOperationFormat.MarkdownZip } = - ctx.request.body; - assertUuid(attachmentId, "attachmentId is required"); + const { attachmentId, format } = ctx.input.body; const { user } = ctx.state.auth; authorize(user, "importCollection", user.team); @@ -180,8 +168,6 @@ router.post( const attachment = await Attachment.findByPk(attachmentId); authorize(user, "read", attachment); - assertIn(format, Object.values(FileOperationFormat), "Invalid format"); - const fileOperation = await FileOperation.create( { type: FileOperationType.Import, @@ -218,106 +204,105 @@ router.post( } ); -router.post("collections.add_group", auth(), async (ctx: APIContext) => { - const { - id, - groupId, - permission = CollectionPermission.ReadWrite, - } = ctx.request.body; - assertUuid(id, "id is required"); - assertUuid(groupId, "groupId is required"); - assertCollectionPermission(permission); +router.post( + "collections.add_group", + auth(), + validate(T.CollectionsAddGroupSchema), + async (ctx: APIContext) => { + const { id, groupId, permission } = ctx.input.body; + const { user } = ctx.state.auth; - const { user } = ctx.state.auth; + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id); + authorize(user, "update", collection); - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id); - authorize(user, "update", collection); + const group = await Group.findByPk(groupId); + authorize(user, "read", group); - const group = await Group.findByPk(groupId); - authorize(user, "read", group); - - let membership = await CollectionGroup.findOne({ - where: { - collectionId: id, - groupId, - }, - }); - - if (!membership) { - membership = await CollectionGroup.create({ - collectionId: id, - groupId, - permission, - createdById: user.id, + let membership = await CollectionGroup.findOne({ + where: { + collectionId: id, + groupId, + }, }); - } else if (permission) { - membership.permission = permission; - await membership.save(); + + if (!membership) { + membership = await CollectionGroup.create({ + collectionId: id, + groupId, + permission, + createdById: user.id, + }); + } else { + membership.permission = permission; + await membership.save(); + } + + await Event.create({ + name: "collections.add_group", + collectionId: collection.id, + teamId: collection.teamId, + actorId: user.id, + modelId: groupId, + data: { + name: group.name, + }, + ip: ctx.request.ip, + }); + + ctx.body = { + data: { + collectionGroupMemberships: [ + presentCollectionGroupMembership(membership), + ], + }, + }; } +); - await Event.create({ - name: "collections.add_group", - collectionId: collection.id, - teamId: collection.teamId, - actorId: user.id, - modelId: groupId, - data: { - name: group.name, - }, - ip: ctx.request.ip, - }); +router.post( + "collections.remove_group", + auth(), + validate(T.CollectionsRemoveGroupSchema), + async (ctx: APIContext) => { + const { id, groupId } = ctx.input.body; + const { user } = ctx.state.auth; - ctx.body = { - data: { - collectionGroupMemberships: [ - presentCollectionGroupMembership(membership), - ], - }, - }; -}); + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id); + authorize(user, "update", collection); -router.post("collections.remove_group", auth(), async (ctx: APIContext) => { - const { id, groupId } = ctx.request.body; - assertUuid(id, "id is required"); - assertUuid(groupId, "groupId is required"); + const group = await Group.findByPk(groupId); + authorize(user, "read", group); - const { user } = ctx.state.auth; + await collection.$remove("group", group); + await Event.create({ + name: "collections.remove_group", + collectionId: collection.id, + teamId: collection.teamId, + actorId: user.id, + modelId: groupId, + data: { + name: group.name, + }, + ip: ctx.request.ip, + }); - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id); - authorize(user, "update", collection); - - const group = await Group.findByPk(groupId); - authorize(user, "read", group); - - await collection.$remove("group", group); - await Event.create({ - name: "collections.remove_group", - collectionId: collection.id, - teamId: collection.teamId, - actorId: user.id, - modelId: groupId, - data: { - name: group.name, - }, - ip: ctx.request.ip, - }); - - ctx.body = { - success: true, - }; -}); + ctx.body = { + success: true, + }; + } +); router.post( "collections.group_memberships", auth(), pagination(), - async (ctx: APIContext) => { - const { id, query, permission } = ctx.request.body; - assertUuid(id, "id is required"); + validate(T.CollectionsGroupMembershipsSchema), + async (ctx: APIContext) => { + const { id, query, permission } = ctx.input.body; const { user } = ctx.state.auth; const collection = await Collection.scope({ @@ -380,12 +365,11 @@ router.post( "collections.add_user", auth(), transaction(), - async (ctx: APIContext) => { + validate(T.CollectionsAddUserSchema), + async (ctx: APIContext) => { const { auth, transaction } = ctx.state; const actor = auth.user; - const { id, userId, permission } = ctx.request.body; - assertUuid(id, "id is required"); - assertUuid(userId, "userId is required"); + const { id, userId, permission } = ctx.input.body; const collection = await Collection.scope({ method: ["withMembership", actor.id], @@ -404,10 +388,6 @@ router.post( lock: transaction.LOCK.UPDATE, }); - if (permission) { - assertCollectionPermission(permission); - } - if (!membership) { membership = await CollectionUser.create( { @@ -455,12 +435,11 @@ router.post( "collections.remove_user", auth(), transaction(), - async (ctx: APIContext) => { + validate(T.CollectionsRemoveUserSchema), + async (ctx: APIContext) => { const { auth, transaction } = ctx.state; const actor = auth.user; - const { id, userId } = ctx.request.body; - assertUuid(id, "id is required"); - assertUuid(userId, "userId is required"); + const { id, userId } = ctx.input.body; const collection = await Collection.scope({ method: ["withMembership", actor.id], @@ -496,9 +475,9 @@ router.post( "collections.memberships", auth(), pagination(), - async (ctx: APIContext) => { - const { id, query, permission } = ctx.request.body; - assertUuid(id, "id is required"); + validate(T.CollectionsMembershipsSchema), + async (ctx: APIContext) => { + const { id, query, permission } = ctx.input.body; const { user } = ctx.state.auth; const collection = await Collection.scope({ @@ -520,7 +499,6 @@ router.post( } if (permission) { - assertCollectionPermission(permission); where = { ...where, permission }; } @@ -560,20 +538,13 @@ router.post( "collections.export", rateLimiter(RateLimiterStrategy.TenPerHour), auth(), + validate(T.CollectionsExportSchema), transaction(), - async (ctx: APIContext) => { + async (ctx: APIContext) => { const { transaction } = ctx.state; - const { id } = ctx.request.body; - const { - format = FileOperationFormat.MarkdownZip, - includeAttachments = true, - } = ctx.request.body; - - assertUuid(id, "id is required"); - assertIn(format, Object.values(FileOperationFormat), "Invalid format"); - assertBoolean(includeAttachments, "includeAttachments must be a boolean"); - + const { id, format, includeAttachments } = ctx.input.body; const { user } = ctx.state.auth; + const team = await Team.findByPk(user.teamId); authorize(user, "createExport", team); @@ -605,20 +576,15 @@ router.post( "collections.export_all", rateLimiter(RateLimiterStrategy.FivePerHour), auth(), + validate(T.CollectionsExportAllSchema), transaction(), - async (ctx: APIContext) => { + async (ctx: APIContext) => { const { transaction } = ctx.state; - const { - format = FileOperationFormat.MarkdownZip, - includeAttachments = true, - } = ctx.request.body; + const { format, includeAttachments } = ctx.input.body; const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId); authorize(user, "createExport", team); - assertIn(format, Object.values(FileOperationFormat), "Invalid format"); - assertBoolean(includeAttachments, "includeAttachments must be a boolean"); - const fileOperation = await collectionExporter({ user, team, @@ -640,15 +606,12 @@ router.post( router.post( "collections.update", auth(), + validate(T.CollectionsUpdateSchema), transaction(), - async (ctx: APIContext) => { + async (ctx: APIContext) => { const { transaction } = ctx.state; const { id, name, description, icon, permission, color, sort, sharing } = - ctx.request.body; - - if (color) { - assertHexColor(color, "Invalid hex value (please use format #FFFFFF)"); - } + ctx.input.body; const { user } = ctx.state.auth; const collection = await Collection.scope({ @@ -697,9 +660,6 @@ router.post( } if (permission !== undefined) { - if (permission) { - assertCollectionPermission(permission); - } privacyChanged = permission !== collection.permission; collection.permission = permission ? permission : null; } @@ -782,9 +742,10 @@ router.post( router.post( "collections.list", auth(), + validate(T.CollectionsListSchema), pagination(), - async (ctx: APIContext) => { - const { includeListOnly } = ctx.request.body; + async (ctx: APIContext) => { + const { includeListOnly } = ctx.input.body; const { user } = ctx.state.auth; const collectionIds = await user.collectionIds(); const where: WhereOptions = @@ -830,12 +791,12 @@ router.post( router.post( "collections.delete", auth(), + validate(T.CollectionsDeleteSchema), transaction(), - async (ctx: APIContext) => { + async (ctx: APIContext) => { const { transaction } = ctx.state; - const { id } = ctx.request.body; + const { id } = ctx.input.body; const { user } = ctx.state.auth; - assertUuid(id, "id is required"); const collection = await Collection.scope({ method: ["withMembership", user.id], @@ -886,14 +847,12 @@ router.post( router.post( "collections.move", auth(), + validate(T.CollectionsMoveSchema), transaction(), - async (ctx: APIContext) => { + async (ctx: APIContext) => { const { transaction } = ctx.state; - const id = ctx.request.body.id; - let index = ctx.request.body.index; - assertPresent(index, "index is required"); - assertIndexCharacters(index); - assertUuid(id, "id must be a uuid"); + const { id } = ctx.input.body; + let { index } = ctx.input.body; const { user } = ctx.state.auth; const collection = await Collection.findByPk(id, { diff --git a/server/routes/api/collections/index.ts b/server/routes/api/collections/index.ts new file mode 100644 index 000000000..27393d575 --- /dev/null +++ b/server/routes/api/collections/index.ts @@ -0,0 +1 @@ +export { default } from "./collections"; diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts new file mode 100644 index 000000000..37e4d6ee5 --- /dev/null +++ b/server/routes/api/collections/schema.ts @@ -0,0 +1,225 @@ +import { isUndefined } from "lodash"; +import { z } from "zod"; +import { randomElement } from "@shared/random"; +import { CollectionPermission, FileOperationFormat } from "@shared/types"; +import { colorPalette } from "@shared/utils/collections"; +import { Collection } from "@server/models"; +import { ValidateColor, ValidateIcon, ValidateIndex } from "@server/validation"; +import BaseSchema from "../BaseSchema"; + +export const CollectionsCreateSchema = BaseSchema.extend({ + body: z.object({ + name: z.string(), + color: z + .string() + .regex(ValidateColor.regex, { message: ValidateColor.message }) + .default(randomElement(colorPalette)), + description: z.string().nullish(), + permission: z + .nativeEnum(CollectionPermission) + .nullish() + .transform((val) => (isUndefined(val) ? null : val)), + sharing: z.boolean().default(true), + icon: z + .string() + .max(ValidateIcon.maxLength, { + message: `Must be ${ValidateIcon.maxLength} or fewer characters long`, + }) + .optional(), + sort: z + .object({ + field: z.union([z.literal("title"), z.literal("index")]), + direction: z.union([z.literal("asc"), z.literal("desc")]), + }) + .default(Collection.DEFAULT_SORT), + index: z + .string() + .regex(ValidateIndex.regex, { message: ValidateIndex.message }) + .max(ValidateIndex.maxLength, { + message: `Must be ${ValidateIndex.maxLength} or fewer characters long`, + }) + .optional(), + }), +}); + +export type CollectionsCreateReq = z.infer; + +export const CollectionsInfoSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + }), +}); + +export type CollectionsInfoReq = z.infer; + +export const CollectionsDocumentsSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + }), +}); + +export type CollectionsDocumentsReq = z.infer< + typeof CollectionsDocumentsSchema +>; + +export const CollectionsImportSchema = BaseSchema.extend({ + body: z.object({ + attachmentId: z.string().uuid(), + format: z + .nativeEnum(FileOperationFormat) + .default(FileOperationFormat.MarkdownZip), + }), +}); + +export type CollectionsImportReq = z.infer; + +export const CollectionsAddGroupSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + groupId: z.string().uuid(), + permission: z + .nativeEnum(CollectionPermission) + .default(CollectionPermission.ReadWrite), + }), +}); + +export type CollectionsAddGroupsReq = z.infer; + +export const CollectionsRemoveGroupSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + groupId: z.string().uuid(), + }), +}); + +export type CollectionsRemoveGroupReq = z.infer< + typeof CollectionsRemoveGroupSchema +>; + +export const CollectionsGroupMembershipsSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + query: z.string().optional(), + permission: z.nativeEnum(CollectionPermission).optional(), + }), +}); + +export type CollectionsGroupMembershipsReq = z.infer< + typeof CollectionsGroupMembershipsSchema +>; + +export const CollectionsAddUserSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + permission: z.nativeEnum(CollectionPermission).optional(), + }), +}); + +export type CollectionsAddUserReq = z.infer; + +export const CollectionsRemoveUserSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + }), +}); + +export type CollectionsRemoveUserReq = z.infer< + typeof CollectionsRemoveUserSchema +>; + +export const CollectionsMembershipsSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + query: z.string().optional(), + permission: z.nativeEnum(CollectionPermission).optional(), + }), +}); + +export type CollectionsMembershipsReq = z.infer< + typeof CollectionsMembershipsSchema +>; + +export const CollectionsExportSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + format: z + .nativeEnum(FileOperationFormat) + .default(FileOperationFormat.MarkdownZip), + includeAttachments: z.boolean().default(true), + }), +}); + +export type CollectionsExportReq = z.infer; + +export const CollectionsExportAllSchema = BaseSchema.extend({ + body: z.object({ + format: z + .nativeEnum(FileOperationFormat) + .default(FileOperationFormat.MarkdownZip), + includeAttachments: z.boolean().default(true), + }), +}); + +export type CollectionsExportAllReq = z.infer< + typeof CollectionsExportAllSchema +>; + +export const CollectionsUpdateSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + name: z.string().optional(), + description: z.string().nullish(), + icon: z + .string() + .max(ValidateIcon.maxLength, { + message: `Must be ${ValidateIcon.maxLength} or fewer characters long`, + }) + .nullish(), + permission: z.nativeEnum(CollectionPermission).nullish(), + color: z + .string() + .regex(ValidateColor.regex, { message: ValidateColor.message }) + .nullish(), + sort: z + .object({ + field: z.union([z.literal("title"), z.literal("index")]), + direction: z.union([z.literal("asc"), z.literal("desc")]), + }) + .optional(), + sharing: z.boolean().optional(), + }), +}); + +export type CollectionsUpdateReq = z.infer; + +export const CollectionsListSchema = BaseSchema.extend({ + body: z.object({ + includeListOnly: z.boolean().default(false), + }), +}); + +export type CollectionsListReq = z.infer; + +export const CollectionsDeleteSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + }), +}); + +export type CollectionsDeleteReq = z.infer; + +export const CollectionsMoveSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + index: z + .string() + .regex(ValidateIndex.regex, { message: ValidateIndex.message }) + .max(ValidateIndex.maxLength, { + message: `Must be ${ValidateIndex.maxLength} or fewer characters long`, + }), + }), +}); + +export type CollectionsMoveReq = z.infer; diff --git a/server/routes/api/teams/teams.test.ts b/server/routes/api/teams/teams.test.ts index 668e62f87..59d9678bf 100644 --- a/server/routes/api/teams/teams.test.ts +++ b/server/routes/api/teams/teams.test.ts @@ -287,7 +287,7 @@ describe("#team.update", () => { body: { token: admin.getJwtToken(), id: collection.id, - permission: "", + permission: null, }, }); diff --git a/server/validation.ts b/server/validation.ts index 797d7f410..59932e9a4 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -187,6 +187,7 @@ export class ValidateDocumentId { export class ValidateIndex { public static regex = new RegExp("^[\x20-\x7E]+$"); public static message = "Must be between x20 to x7E ASCII"; + public static maxLength = 100; } export class ValidateURL { @@ -209,3 +210,12 @@ export class ValidateURL { public static message = "Must be a valid url"; } + +export class ValidateColor { + public static regex = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i; + public static message = "Must be a hex value (please use format #FFFFFF)"; +} + +export class ValidateIcon { + public static maxLength = 50; +}