Validate API request query (#4642)
* fix: refactor to accommodate authentication, transaction and pagination together into ctx.state * feat: allow passing response type to APIContext * feat: preliminary work for initial review * fix: use unknown for base types * fix: api/attachments * fix: api/documents * fix: jsdoc comment for input * fix: replace at() with index access for compatibility * fix: validation err message * fix: error handling * fix: remove unnecessary extend
This commit is contained in:
@@ -1,16 +1,20 @@
|
|||||||
import { Next } from "koa";
|
import { Next } from "koa";
|
||||||
import { z } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
import { ValidationError } from "@server/errors";
|
import { ValidationError } from "@server/errors";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext, BaseReq } from "@server/types";
|
||||||
|
|
||||||
export default function validate<T extends z.ZodTypeAny>(schema: T) {
|
export default function validate<T extends z.ZodType<BaseReq>>(schema: T) {
|
||||||
return async function validateMiddleware(ctx: APIContext<T>, next: Next) {
|
return async function validateMiddleware(ctx: APIContext, next: Next) {
|
||||||
try {
|
try {
|
||||||
ctx.input = schema.parse(ctx.request.body);
|
ctx.input = schema.parse(ctx.request);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
const { path, message } = err.issues[0];
|
const { path, message } = err.issues[0];
|
||||||
const [prefix = "ValidationError"] = path;
|
const errMessage =
|
||||||
throw ValidationError(`${prefix}: ${message}`);
|
path.length > 0 ? `${path[path.length - 1]}: ${message}` : message;
|
||||||
|
throw ValidationError(errMessage);
|
||||||
|
}
|
||||||
|
ctx.throw(err);
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
|
|||||||
8
server/routes/api/BaseSchema.ts
Normal file
8
server/routes/api/BaseSchema.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const BaseSchema = z.object({
|
||||||
|
body: z.unknown(),
|
||||||
|
query: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BaseSchema;
|
||||||
@@ -307,6 +307,24 @@ describe("#attachments.redirect", () => {
|
|||||||
expect(res.status).toEqual(302);
|
expect(res.status).toEqual(302);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return a redirect for the attachment if id supplied via query params", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const attachment = await buildAttachment({
|
||||||
|
teamId: user.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
const res = await server.post(
|
||||||
|
`/api/attachments.redirect?id=${attachment.id}`,
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
},
|
||||||
|
redirect: "manual",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).toEqual(302);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return a redirect for an attachment belonging to a trashed document user has access to", async () => {
|
it("should return a redirect for an attachment belonging to a trashed document user has access to", async () => {
|
||||||
const user = await buildUser();
|
const user = await buildUser();
|
||||||
const collection = await buildCollection({
|
const collection = await buildCollection({
|
||||||
@@ -385,4 +403,16 @@ describe("#attachments.redirect", () => {
|
|||||||
});
|
});
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should fail in absence of id", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post("/api/attachments.redirect", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("id is required");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { authorize } from "@server/policies";
|
|||||||
import { presentAttachment } from "@server/presenters";
|
import { presentAttachment } from "@server/presenters";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
import { getPresignedPost, publicS3Endpoint } from "@server/utils/s3";
|
import { getPresignedPost, publicS3Endpoint } from "@server/utils/s3";
|
||||||
import { assertIn, assertUuid } from "@server/validation";
|
import { assertIn } from "@server/validation";
|
||||||
import * as T from "./schema";
|
import * as T from "./schema";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -24,7 +24,7 @@ router.post(
|
|||||||
validate(T.AttachmentsCreateSchema),
|
validate(T.AttachmentsCreateSchema),
|
||||||
transaction(),
|
transaction(),
|
||||||
async (ctx: APIContext<T.AttachmentCreateReq>) => {
|
async (ctx: APIContext<T.AttachmentCreateReq>) => {
|
||||||
const { name, documentId, contentType, size, preset } = ctx.input;
|
const { name, documentId, contentType, size, preset } = ctx.input.body;
|
||||||
const { auth, transaction } = ctx.state;
|
const { auth, transaction } = ctx.state;
|
||||||
const { user } = auth;
|
const { user } = auth;
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ router.post(
|
|||||||
auth(),
|
auth(),
|
||||||
validate(T.AttachmentDeleteSchema),
|
validate(T.AttachmentDeleteSchema),
|
||||||
async (ctx: APIContext<T.AttachmentDeleteReq>) => {
|
async (ctx: APIContext<T.AttachmentDeleteReq>) => {
|
||||||
const { id } = ctx.input;
|
const { id } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const attachment = await Attachment.findByPk(id, {
|
const attachment = await Attachment.findByPk(id, {
|
||||||
rejectOnEmpty: true,
|
rejectOnEmpty: true,
|
||||||
@@ -141,9 +141,10 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAttachmentsRedirect = async (ctx: APIContext) => {
|
const handleAttachmentsRedirect = async (
|
||||||
const id = ctx.request.body?.id ?? ctx.request.query?.id;
|
ctx: APIContext<T.AttachmentsRedirectReq>
|
||||||
assertUuid(id, "id is required");
|
) => {
|
||||||
|
const id = (ctx.input.body.id ?? ctx.input.query.id) as string;
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const attachment = await Attachment.findByPk(id, {
|
const attachment = await Attachment.findByPk(id, {
|
||||||
@@ -165,7 +166,17 @@ const handleAttachmentsRedirect = async (ctx: APIContext) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get("attachments.redirect", auth(), handleAttachmentsRedirect);
|
router.get(
|
||||||
router.post("attachments.redirect", auth(), handleAttachmentsRedirect);
|
"attachments.redirect",
|
||||||
|
auth(),
|
||||||
|
validate(T.AttachmentsRedirectSchema),
|
||||||
|
handleAttachmentsRedirect
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"attachments.redirect",
|
||||||
|
auth(),
|
||||||
|
validate(T.AttachmentsRedirectSchema),
|
||||||
|
handleAttachmentsRedirect
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { isEmpty } from "lodash";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AttachmentPreset } from "@shared/types";
|
import { AttachmentPreset } from "@shared/types";
|
||||||
|
import BaseSchema from "@server/routes/api/BaseSchema";
|
||||||
|
|
||||||
export const AttachmentsCreateSchema = z.object({
|
export const AttachmentsCreateSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
/** Attachment name */
|
/** Attachment name */
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|
||||||
@@ -16,13 +19,31 @@ export const AttachmentsCreateSchema = z.object({
|
|||||||
|
|
||||||
/** Attachment type */
|
/** Attachment type */
|
||||||
preset: z.nativeEnum(AttachmentPreset),
|
preset: z.nativeEnum(AttachmentPreset),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AttachmentCreateReq = z.infer<typeof AttachmentsCreateSchema>;
|
export type AttachmentCreateReq = z.infer<typeof AttachmentsCreateSchema>;
|
||||||
|
|
||||||
export const AttachmentDeleteSchema = z.object({
|
export const AttachmentDeleteSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
/** Id of the attachment to be deleted */
|
/** Id of the attachment to be deleted */
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AttachmentDeleteReq = z.infer<typeof AttachmentDeleteSchema>;
|
export type AttachmentDeleteReq = z.infer<typeof AttachmentDeleteSchema>;
|
||||||
|
|
||||||
|
export const AttachmentsRedirectSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
/** Id of the attachment to be deleted */
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
/** Id of the attachment to be deleted */
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
|
}).refine((req) => !(isEmpty(req.body.id) && isEmpty(req.query.id)), {
|
||||||
|
message: "id is required",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AttachmentsRedirectReq = z.infer<typeof AttachmentsRedirectSchema>;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Object {
|
|||||||
exports[`#documents.update should require text while appending 1`] = `
|
exports[`#documents.update should require text while appending 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "validation_error",
|
"error": "validation_error",
|
||||||
"message": "ValidationError: text is required while appending",
|
"message": "text is required while appending",
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"status": 400,
|
"status": 400,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ describe("#documents.info", () => {
|
|||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
expect(body.message).toEqual(
|
expect(body.message).toEqual("one of id or shareId is required");
|
||||||
"ValidationError: one of id or shareId is required"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return published document", async () => {
|
it("should return published document", async () => {
|
||||||
@@ -1824,7 +1822,7 @@ describe("#documents.move", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
expect(body.message).toEqual(
|
expect(body.message).toEqual(
|
||||||
"ValidationError: infinite loop detected, cannot nest a document inside itself"
|
"infinite loop detected, cannot nest a document inside itself"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2288,7 +2286,7 @@ describe("#documents.create", () => {
|
|||||||
|
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
expect(body.message).toBe(
|
expect(body.message).toBe(
|
||||||
"ValidationError: collectionId is required to create a template document"
|
"collectionId is required to create a template document"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2306,9 +2304,7 @@ describe("#documents.create", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
expect(body.message).toBe(
|
expect(body.message).toBe("collectionId is required to publish");
|
||||||
"ValidationError: collectionId is required to publish"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not allow creating a nested doc without a collection", async () => {
|
it("should not allow creating a nested doc without a collection", async () => {
|
||||||
@@ -2326,7 +2322,7 @@ describe("#documents.create", () => {
|
|||||||
|
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
expect(body.message).toBe(
|
expect(body.message).toBe(
|
||||||
"ValidationError: collectionId is required to create a nested document"
|
"collectionId is required to create a nested document"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ router.post(
|
|||||||
pagination(),
|
pagination(),
|
||||||
validate(T.DocumentsListSchema),
|
validate(T.DocumentsListSchema),
|
||||||
async (ctx: APIContext<T.DocumentsListReq>) => {
|
async (ctx: APIContext<T.DocumentsListReq>) => {
|
||||||
let { sort } = ctx.input;
|
let { sort } = ctx.input.body;
|
||||||
const {
|
const {
|
||||||
direction,
|
direction,
|
||||||
template,
|
template,
|
||||||
@@ -67,7 +67,7 @@ router.post(
|
|||||||
backlinkDocumentId,
|
backlinkDocumentId,
|
||||||
parentDocumentId,
|
parentDocumentId,
|
||||||
userId: createdById,
|
userId: createdById,
|
||||||
} = ctx.input;
|
} = ctx.input.body;
|
||||||
|
|
||||||
// always filter by the current team
|
// always filter by the current team
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
@@ -176,7 +176,7 @@ router.post(
|
|||||||
pagination(),
|
pagination(),
|
||||||
validate(T.DocumentsArchivedSchema),
|
validate(T.DocumentsArchivedSchema),
|
||||||
async (ctx: APIContext<T.DocumentsArchivedReq>) => {
|
async (ctx: APIContext<T.DocumentsArchivedReq>) => {
|
||||||
const { sort, direction } = ctx.input;
|
const { sort, direction } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const collectionIds = await user.collectionIds();
|
const collectionIds = await user.collectionIds();
|
||||||
const collectionScope: Readonly<ScopeOptions> = {
|
const collectionScope: Readonly<ScopeOptions> = {
|
||||||
@@ -220,7 +220,7 @@ router.post(
|
|||||||
pagination(),
|
pagination(),
|
||||||
validate(T.DocumentsDeletedSchema),
|
validate(T.DocumentsDeletedSchema),
|
||||||
async (ctx: APIContext<T.DocumentsDeletedReq>) => {
|
async (ctx: APIContext<T.DocumentsDeletedReq>) => {
|
||||||
const { sort, direction } = ctx.input;
|
const { sort, direction } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const collectionIds = await user.collectionIds({
|
const collectionIds = await user.collectionIds({
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
@@ -280,7 +280,7 @@ router.post(
|
|||||||
pagination(),
|
pagination(),
|
||||||
validate(T.DocumentsViewedSchema),
|
validate(T.DocumentsViewedSchema),
|
||||||
async (ctx: APIContext<T.DocumentsViewedReq>) => {
|
async (ctx: APIContext<T.DocumentsViewedReq>) => {
|
||||||
const { sort, direction } = ctx.input;
|
const { sort, direction } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const collectionIds = await user.collectionIds();
|
const collectionIds = await user.collectionIds();
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
@@ -333,7 +333,7 @@ router.post(
|
|||||||
pagination(),
|
pagination(),
|
||||||
validate(T.DocumentsDraftsSchema),
|
validate(T.DocumentsDraftsSchema),
|
||||||
async (ctx: APIContext<T.DocumentsDraftsReq>) => {
|
async (ctx: APIContext<T.DocumentsDraftsReq>) => {
|
||||||
const { collectionId, dateFilter, direction, sort } = ctx.input;
|
const { collectionId, dateFilter, direction, sort } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
if (collectionId) {
|
if (collectionId) {
|
||||||
@@ -396,7 +396,7 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
validate(T.DocumentsInfoSchema),
|
validate(T.DocumentsInfoSchema),
|
||||||
async (ctx: APIContext<T.DocumentsInfoReq>) => {
|
async (ctx: APIContext<T.DocumentsInfoReq>) => {
|
||||||
const { id, shareId, apiVersion } = ctx.input;
|
const { id, shareId, apiVersion } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const teamFromCtx = await getTeamFromContext(ctx);
|
const teamFromCtx = await getTeamFromContext(ctx);
|
||||||
const { document, share, collection } = await documentLoader({
|
const { document, share, collection } = await documentLoader({
|
||||||
@@ -442,7 +442,7 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
validate(T.DocumentsExportSchema),
|
validate(T.DocumentsExportSchema),
|
||||||
async (ctx: APIContext<T.DocumentsExportReq>) => {
|
async (ctx: APIContext<T.DocumentsExportReq>) => {
|
||||||
const { id } = ctx.input;
|
const { id } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const accept = ctx.request.headers["accept"];
|
const accept = ctx.request.headers["accept"];
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ router.post(
|
|||||||
auth({ member: true }),
|
auth({ member: true }),
|
||||||
validate(T.DocumentsRestoreSchema),
|
validate(T.DocumentsRestoreSchema),
|
||||||
async (ctx: APIContext<T.DocumentsRestoreReq>) => {
|
async (ctx: APIContext<T.DocumentsRestoreReq>) => {
|
||||||
const { id, collectionId, revisionId } = ctx.input;
|
const { id, collectionId, revisionId } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const document = await Document.findByPk(id, {
|
const document = await Document.findByPk(id, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -604,7 +604,7 @@ router.post(
|
|||||||
dateFilter,
|
dateFilter,
|
||||||
collectionId,
|
collectionId,
|
||||||
userId,
|
userId,
|
||||||
} = ctx.input;
|
} = ctx.input.body;
|
||||||
const { offset, limit } = ctx.state.pagination;
|
const { offset, limit } = ctx.state.pagination;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
let collaboratorIds = undefined;
|
let collaboratorIds = undefined;
|
||||||
@@ -660,7 +660,7 @@ router.post(
|
|||||||
shareId,
|
shareId,
|
||||||
snippetMinWords,
|
snippetMinWords,
|
||||||
snippetMaxWords,
|
snippetMaxWords,
|
||||||
} = ctx.input;
|
} = ctx.input.body;
|
||||||
const { offset, limit } = ctx.state.pagination;
|
const { offset, limit } = ctx.state.pagination;
|
||||||
|
|
||||||
// Unfortunately, this still doesn't adequately handle cases when auth is optional
|
// Unfortunately, this still doesn't adequately handle cases when auth is optional
|
||||||
@@ -765,7 +765,7 @@ router.post(
|
|||||||
auth({ member: true }),
|
auth({ member: true }),
|
||||||
validate(T.DocumentsTemplatizeSchema),
|
validate(T.DocumentsTemplatizeSchema),
|
||||||
async (ctx: APIContext<T.DocumentsTemplatizeReq>) => {
|
async (ctx: APIContext<T.DocumentsTemplatizeReq>) => {
|
||||||
const { id } = ctx.input;
|
const { id } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
const original = await Document.findByPk(id, {
|
const original = await Document.findByPk(id, {
|
||||||
@@ -826,7 +826,7 @@ router.post(
|
|||||||
templateId,
|
templateId,
|
||||||
collectionId,
|
collectionId,
|
||||||
append,
|
append,
|
||||||
} = ctx.input;
|
} = ctx.input.body;
|
||||||
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
let collection: Collection | null | undefined;
|
let collection: Collection | null | undefined;
|
||||||
@@ -887,7 +887,7 @@ router.post(
|
|||||||
auth(),
|
auth(),
|
||||||
validate(T.DocumentsMoveSchema),
|
validate(T.DocumentsMoveSchema),
|
||||||
async (ctx: APIContext<T.DocumentsMoveReq>) => {
|
async (ctx: APIContext<T.DocumentsMoveReq>) => {
|
||||||
const { id, collectionId, parentDocumentId, index } = ctx.input;
|
const { id, collectionId, parentDocumentId, index } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const document = await Document.findByPk(id, {
|
const document = await Document.findByPk(id, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -941,7 +941,7 @@ router.post(
|
|||||||
auth(),
|
auth(),
|
||||||
validate(T.DocumentsArchiveSchema),
|
validate(T.DocumentsArchiveSchema),
|
||||||
async (ctx: APIContext<T.DocumentsArchiveReq>) => {
|
async (ctx: APIContext<T.DocumentsArchiveReq>) => {
|
||||||
const { id } = ctx.input;
|
const { id } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
const document = await Document.findByPk(id, {
|
const document = await Document.findByPk(id, {
|
||||||
@@ -974,7 +974,7 @@ router.post(
|
|||||||
auth(),
|
auth(),
|
||||||
validate(T.DocumentsDeleteSchema),
|
validate(T.DocumentsDeleteSchema),
|
||||||
async (ctx: APIContext<T.DocumentsDeleteReq>) => {
|
async (ctx: APIContext<T.DocumentsDeleteReq>) => {
|
||||||
const { id, permanent } = ctx.input;
|
const { id, permanent } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
if (permanent) {
|
if (permanent) {
|
||||||
@@ -1039,7 +1039,7 @@ router.post(
|
|||||||
auth(),
|
auth(),
|
||||||
validate(T.DocumentsUnpublishSchema),
|
validate(T.DocumentsUnpublishSchema),
|
||||||
async (ctx: APIContext<T.DocumentsUnpublishReq>) => {
|
async (ctx: APIContext<T.DocumentsUnpublishReq>) => {
|
||||||
const { id } = ctx.input;
|
const { id } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
const document = await Document.findByPk(id, {
|
const document = await Document.findByPk(id, {
|
||||||
@@ -1083,9 +1083,7 @@ router.post(
|
|||||||
throw InvalidRequestError("Request type must be multipart/form-data");
|
throw InvalidRequestError("Request type must be multipart/form-data");
|
||||||
}
|
}
|
||||||
|
|
||||||
// String as this is always multipart/form-data
|
const { collectionId, parentDocumentId, publish } = ctx.input.body;
|
||||||
const publish = ctx.input.publish === "true";
|
|
||||||
const { collectionId, parentDocumentId } = ctx.input;
|
|
||||||
|
|
||||||
const file = ctx.request.files
|
const file = ctx.request.files
|
||||||
? Object.values(ctx.request.files)[0]
|
? Object.values(ctx.request.files)[0]
|
||||||
@@ -1173,7 +1171,7 @@ router.post(
|
|||||||
parentDocumentId,
|
parentDocumentId,
|
||||||
templateId,
|
templateId,
|
||||||
template,
|
template,
|
||||||
} = ctx.input;
|
} = ctx.input.body;
|
||||||
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { isEmpty } from "lodash";
|
|||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
||||||
|
import BaseSchema from "@server/routes/api/BaseSchema";
|
||||||
|
|
||||||
const DocumentsSortParamsSchema = z.object({
|
const DocumentsSortParamsSchema = z.object({
|
||||||
/** Specifies the attributes by which documents will be sorted in the list */
|
/** Specifies the attributes by which documents will be sorted in the list */
|
||||||
@@ -39,7 +40,8 @@ const BaseIdSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DocumentsListSchema = DocumentsSortParamsSchema.extend({
|
export const DocumentsListSchema = BaseSchema.extend({
|
||||||
|
body: DocumentsSortParamsSchema.extend({
|
||||||
/** Id of the user who created the doc */
|
/** Id of the user who created the doc */
|
||||||
userId: z.string().uuid().optional(),
|
userId: z.string().uuid().optional(),
|
||||||
|
|
||||||
@@ -60,42 +62,48 @@ export const DocumentsListSchema = DocumentsSortParamsSchema.extend({
|
|||||||
|
|
||||||
/** Boolean which denotes whether the document is a template */
|
/** Boolean which denotes whether the document is a template */
|
||||||
template: z.boolean().optional(),
|
template: z.boolean().optional(),
|
||||||
})
|
}),
|
||||||
// Maintains backwards compatibility
|
// Maintains backwards compatibility
|
||||||
.transform((doc) => {
|
}).transform((req) => {
|
||||||
doc.collectionId = doc.collectionId || doc.collection;
|
req.body.collectionId = req.body.collectionId || req.body.collection;
|
||||||
doc.userId = doc.userId || doc.user;
|
req.body.userId = req.body.userId || req.body.user;
|
||||||
delete doc.collection;
|
delete req.body.collection;
|
||||||
delete doc.user;
|
delete req.body.user;
|
||||||
|
|
||||||
return doc;
|
return req;
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsListReq = z.infer<typeof DocumentsListSchema>;
|
export type DocumentsListReq = z.infer<typeof DocumentsListSchema>;
|
||||||
|
|
||||||
export const DocumentsArchivedSchema = DocumentsSortParamsSchema.extend({});
|
export const DocumentsArchivedSchema = BaseSchema.extend({
|
||||||
|
body: DocumentsSortParamsSchema.extend({}),
|
||||||
|
});
|
||||||
|
|
||||||
export type DocumentsArchivedReq = z.infer<typeof DocumentsArchivedSchema>;
|
export type DocumentsArchivedReq = z.infer<typeof DocumentsArchivedSchema>;
|
||||||
|
|
||||||
export const DocumentsDeletedSchema = DocumentsSortParamsSchema.extend({});
|
export const DocumentsDeletedSchema = BaseSchema.extend({
|
||||||
|
body: DocumentsSortParamsSchema.extend({}),
|
||||||
|
});
|
||||||
|
|
||||||
export type DocumentsDeletedReq = z.infer<typeof DocumentsDeletedSchema>;
|
export type DocumentsDeletedReq = z.infer<typeof DocumentsDeletedSchema>;
|
||||||
|
|
||||||
export const DocumentsViewedSchema = DocumentsSortParamsSchema.extend({});
|
export const DocumentsViewedSchema = BaseSchema.extend({
|
||||||
|
body: DocumentsSortParamsSchema.extend({}),
|
||||||
|
});
|
||||||
|
|
||||||
export type DocumentsViewedReq = z.infer<typeof DocumentsViewedSchema>;
|
export type DocumentsViewedReq = z.infer<typeof DocumentsViewedSchema>;
|
||||||
|
|
||||||
export const DocumentsDraftsSchema = DocumentsSortParamsSchema.merge(
|
export const DocumentsDraftsSchema = BaseSchema.extend({
|
||||||
DateFilterSchema
|
body: DocumentsSortParamsSchema.merge(DateFilterSchema).extend({
|
||||||
).extend({
|
|
||||||
/** Id of the collection to which the document belongs */
|
/** Id of the collection to which the document belongs */
|
||||||
collectionId: z.string().uuid().optional(),
|
collectionId: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsDraftsReq = z.infer<typeof DocumentsDraftsSchema>;
|
export type DocumentsDraftsReq = z.infer<typeof DocumentsDraftsSchema>;
|
||||||
|
|
||||||
export const DocumentsInfoSchema = z
|
export const DocumentsInfoSchema = BaseSchema.extend({
|
||||||
.object({
|
body: z.object({
|
||||||
/** Id of the document to be retrieved */
|
/** Id of the document to be retrieved */
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
|
|
||||||
@@ -107,30 +115,33 @@ export const DocumentsInfoSchema = z
|
|||||||
|
|
||||||
/** Version of the API to be used */
|
/** Version of the API to be used */
|
||||||
apiVersion: z.number().optional(),
|
apiVersion: z.number().optional(),
|
||||||
})
|
}),
|
||||||
.refine((obj) => !(isEmpty(obj.id) && isEmpty(obj.shareId)), {
|
}).refine((req) => !(isEmpty(req.body.id) && isEmpty(req.body.shareId)), {
|
||||||
message: "one of id or shareId is required",
|
message: "one of id or shareId is required",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsInfoReq = z.infer<typeof DocumentsInfoSchema>;
|
export type DocumentsInfoReq = z.infer<typeof DocumentsInfoSchema>;
|
||||||
|
|
||||||
export const DocumentsExportSchema = BaseIdSchema.extend({});
|
export const DocumentsExportSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export type DocumentsExportReq = z.infer<typeof DocumentsExportSchema>;
|
export type DocumentsExportReq = z.infer<typeof DocumentsExportSchema>;
|
||||||
|
|
||||||
export const DocumentsRestoreSchema = BaseIdSchema.extend({
|
export const DocumentsRestoreSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema.extend({
|
||||||
/** Id of the collection to which the document belongs */
|
/** Id of the collection to which the document belongs */
|
||||||
collectionId: z.string().uuid().optional(),
|
collectionId: z.string().uuid().optional(),
|
||||||
|
|
||||||
/** Id of document revision */
|
/** Id of document revision */
|
||||||
revisionId: z.string().uuid().optional(),
|
revisionId: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>;
|
export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>;
|
||||||
|
|
||||||
export const DocumentsSearchSchema = SearchQuerySchema.merge(
|
export const DocumentsSearchSchema = BaseSchema.extend({
|
||||||
DateFilterSchema
|
body: SearchQuerySchema.merge(DateFilterSchema).extend({
|
||||||
).extend({
|
|
||||||
/** Whether to include archived docs in results */
|
/** Whether to include archived docs in results */
|
||||||
includeArchived: z.boolean().optional(),
|
includeArchived: z.boolean().optional(),
|
||||||
|
|
||||||
@@ -154,15 +165,19 @@ export const DocumentsSearchSchema = SearchQuerySchema.merge(
|
|||||||
|
|
||||||
/** Max words to be accomodated in the results snippets */
|
/** Max words to be accomodated in the results snippets */
|
||||||
snippetMaxWords: z.number().default(30),
|
snippetMaxWords: z.number().default(30),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsSearchReq = z.infer<typeof DocumentsSearchSchema>;
|
export type DocumentsSearchReq = z.infer<typeof DocumentsSearchSchema>;
|
||||||
|
|
||||||
export const DocumentsTemplatizeSchema = BaseIdSchema.extend({});
|
export const DocumentsTemplatizeSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export type DocumentsTemplatizeReq = z.infer<typeof DocumentsTemplatizeSchema>;
|
export type DocumentsTemplatizeReq = z.infer<typeof DocumentsTemplatizeSchema>;
|
||||||
|
|
||||||
export const DocumentsUpdateSchema = BaseIdSchema.extend({
|
export const DocumentsUpdateSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema.extend({
|
||||||
/** Doc title to be updated */
|
/** Doc title to be updated */
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
|
||||||
@@ -186,13 +201,15 @@ export const DocumentsUpdateSchema = BaseIdSchema.extend({
|
|||||||
|
|
||||||
/** Boolean to denote if text should be appended */
|
/** Boolean to denote if text should be appended */
|
||||||
append: z.boolean().optional(),
|
append: z.boolean().optional(),
|
||||||
}).refine((obj) => !(obj.append && !obj.text), {
|
}),
|
||||||
|
}).refine((req) => !(req.body.append && !req.body.text), {
|
||||||
message: "text is required while appending",
|
message: "text is required while appending",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsUpdateReq = z.infer<typeof DocumentsUpdateSchema>;
|
export type DocumentsUpdateReq = z.infer<typeof DocumentsUpdateSchema>;
|
||||||
|
|
||||||
export const DocumentsMoveSchema = BaseIdSchema.extend({
|
export const DocumentsMoveSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema.extend({
|
||||||
/** Id of collection to which the doc is supposed to be moved */
|
/** Id of collection to which the doc is supposed to be moved */
|
||||||
collectionId: z.string().uuid(),
|
collectionId: z.string().uuid(),
|
||||||
|
|
||||||
@@ -201,42 +218,51 @@ export const DocumentsMoveSchema = BaseIdSchema.extend({
|
|||||||
|
|
||||||
/** Helps evaluate the new index in collection structure upon move */
|
/** Helps evaluate the new index in collection structure upon move */
|
||||||
index: z.number().gte(0).optional(),
|
index: z.number().gte(0).optional(),
|
||||||
}).refine((obj) => !(obj.parentDocumentId === obj.id), {
|
}),
|
||||||
|
}).refine((req) => !(req.body.parentDocumentId === req.body.id), {
|
||||||
message: "infinite loop detected, cannot nest a document inside itself",
|
message: "infinite loop detected, cannot nest a document inside itself",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsMoveReq = z.infer<typeof DocumentsMoveSchema>;
|
export type DocumentsMoveReq = z.infer<typeof DocumentsMoveSchema>;
|
||||||
|
|
||||||
export const DocumentsArchiveSchema = BaseIdSchema.extend({});
|
export const DocumentsArchiveSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export type DocumentsArchiveReq = z.infer<typeof DocumentsArchiveSchema>;
|
export type DocumentsArchiveReq = z.infer<typeof DocumentsArchiveSchema>;
|
||||||
|
|
||||||
export const DocumentsDeleteSchema = BaseIdSchema.extend({
|
export const DocumentsDeleteSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema.extend({
|
||||||
/** Whether to permanently delete the doc as opposed to soft-delete */
|
/** Whether to permanently delete the doc as opposed to soft-delete */
|
||||||
permanent: z.boolean().optional(),
|
permanent: z.boolean().optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsDeleteReq = z.infer<typeof DocumentsDeleteSchema>;
|
export type DocumentsDeleteReq = z.infer<typeof DocumentsDeleteSchema>;
|
||||||
|
|
||||||
export const DocumentsUnpublishSchema = BaseIdSchema.extend({});
|
export const DocumentsUnpublishSchema = BaseSchema.extend({
|
||||||
|
body: BaseIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export type DocumentsUnpublishReq = z.infer<typeof DocumentsUnpublishSchema>;
|
export type DocumentsUnpublishReq = z.infer<typeof DocumentsUnpublishSchema>;
|
||||||
|
|
||||||
export const DocumentsImportSchema = z.object({
|
export const DocumentsImportSchema = BaseSchema.extend({
|
||||||
/** Whether to publish the imported docs. String due to multi-part form upload */
|
body: z.object({
|
||||||
publish: z.string().optional(),
|
/** Whether to publish the imported docs. String as this is always multipart/form-data */
|
||||||
|
publish: z.preprocess((val) => val === "true", z.boolean()).optional(),
|
||||||
|
|
||||||
/** Import docs to this collection */
|
/** Import docs to this collection */
|
||||||
collectionId: z.string().uuid(),
|
collectionId: z.string().uuid(),
|
||||||
|
|
||||||
/** Import under this parent doc */
|
/** Import under this parent doc */
|
||||||
parentDocumentId: z.string().uuid().nullish(),
|
parentDocumentId: z.string().uuid().nullish(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocumentsImportReq = z.infer<typeof DocumentsImportSchema>;
|
export type DocumentsImportReq = z.infer<typeof DocumentsImportSchema>;
|
||||||
|
|
||||||
export const DocumentsCreateSchema = z
|
export const DocumentsCreateSchema = BaseSchema.extend({
|
||||||
.object({
|
body: z.object({
|
||||||
/** Doc title */
|
/** Doc title */
|
||||||
title: z.string().default(""),
|
title: z.string().default(""),
|
||||||
|
|
||||||
@@ -257,14 +283,15 @@ export const DocumentsCreateSchema = z
|
|||||||
|
|
||||||
/** Whether to create a template doc */
|
/** Whether to create a template doc */
|
||||||
template: z.boolean().optional(),
|
template: z.boolean().optional(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.refine((obj) => !(obj.parentDocumentId && !obj.collectionId), {
|
.refine((req) => !(req.body.parentDocumentId && !req.body.collectionId), {
|
||||||
message: "collectionId is required to create a nested document",
|
message: "collectionId is required to create a nested document",
|
||||||
})
|
})
|
||||||
.refine((obj) => !(obj.template && !obj.collectionId), {
|
.refine((req) => !(req.body.template && !req.body.collectionId), {
|
||||||
message: "collectionId is required to create a template document",
|
message: "collectionId is required to create a template document",
|
||||||
})
|
})
|
||||||
.refine((obj) => !(obj.publish && !obj.collectionId), {
|
.refine((req) => !(req.body.publish && !req.body.collectionId), {
|
||||||
message: "collectionId is required to publish",
|
message: "collectionId is required to publish",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ router.post(
|
|||||||
collectionId,
|
collectionId,
|
||||||
name,
|
name,
|
||||||
auditLog,
|
auditLog,
|
||||||
} = ctx.input;
|
} = ctx.input.body;
|
||||||
|
|
||||||
let where: WhereOptions<Event> = {
|
let where: WhereOptions<Event> = {
|
||||||
name: Event.ACTIVITY_EVENTS,
|
name: Event.ACTIVITY_EVENTS,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import BaseSchema from "@server/routes/api/BaseSchema";
|
||||||
|
|
||||||
export const EventsListSchema = z.object({
|
export const EventsListSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
/** Id of the user who performed the action */
|
/** Id of the user who performed the action */
|
||||||
actorId: z.string().uuid().optional(),
|
actorId: z.string().uuid().optional(),
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ export const EventsListSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EventsListReq = z.infer<typeof EventsListSchema>;
|
export type EventsListReq = z.infer<typeof EventsListSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ParameterizedContext, DefaultContext } from "koa";
|
import { ParameterizedContext, DefaultContext } from "koa";
|
||||||
import { IRouterParamContext } from "koa-router";
|
import { IRouterParamContext } from "koa-router";
|
||||||
import { Transaction } from "sequelize/types";
|
import { Transaction } from "sequelize/types";
|
||||||
|
import { z } from "zod";
|
||||||
import { Client } from "@shared/types";
|
import { Client } from "@shared/types";
|
||||||
|
import BaseSchema from "@server/routes/api/BaseSchema";
|
||||||
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||||
import { FileOperation, Team, User } from "./models";
|
import { FileOperation, Team, User } from "./models";
|
||||||
|
|
||||||
@@ -34,12 +36,17 @@ export type AppState = {
|
|||||||
|
|
||||||
export type AppContext = ParameterizedContext<AppState, DefaultContext>;
|
export type AppContext = ParameterizedContext<AppState, DefaultContext>;
|
||||||
|
|
||||||
export interface APIContext<ReqT = Record<string, unknown>, ResT = unknown>
|
export type BaseReq = z.infer<typeof BaseSchema>;
|
||||||
|
|
||||||
|
export type BaseRes = unknown;
|
||||||
|
|
||||||
|
export interface APIContext<ReqT = BaseReq, ResT = BaseRes>
|
||||||
extends ParameterizedContext<
|
extends ParameterizedContext<
|
||||||
AppState,
|
AppState,
|
||||||
DefaultContext & IRouterParamContext<AppState>,
|
DefaultContext & IRouterParamContext<AppState>,
|
||||||
ResT
|
ResT
|
||||||
> {
|
> {
|
||||||
|
/** Typed and validated version of request, consisting of validated body, query, etc */
|
||||||
input: ReqT;
|
input: ReqT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user