From f349e07be4e6a639dda06aa0dc0110ffd82b7f7c Mon Sep 17 00:00:00 2001 From: "Christopher S. Case" Date: Wed, 22 May 2024 13:32:05 -0500 Subject: [PATCH] added notebook validation. --- package-lock.json | 29 ++- package.json | 1 - src/server/engines/validation/express.ts | 89 ++++++++- .../engines/validation/models/account.ts | 6 +- .../engines/validation/models/common.ts | 12 ++ .../engines/validation/models/notebook.ts | 44 +++++ src/server/routes/accounts.ts | 16 +- src/server/routes/notebook.ts | 170 +++++++++++------- 8 files changed, 271 insertions(+), 96 deletions(-) create mode 100644 src/server/engines/validation/models/common.ts create mode 100644 src/server/engines/validation/models/notebook.ts diff --git a/package-lock.json b/package-lock.json index b368a846..e8feb9e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "trivialperms": "^2.0.0-beta.0", "ts-essentials": "^10.0.0", "zod": "^3.23.8", - "zod-express": "^0.0.8", "zod-validation-error": "^3.3.0" }, "devDependencies": { @@ -1462,6 +1461,7 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -1471,6 +1471,7 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -1498,6 +1499,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -1509,6 +1511,7 @@ "version": "4.19.0", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", + "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -1519,7 +1522,8 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true }, "node_modules/@types/jquery": { "version": "3.5.30", @@ -1549,7 +1553,8 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true }, "node_modules/@types/node": { "version": "20.12.12", @@ -1571,17 +1576,20 @@ "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", + "dev": true }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -1591,6 +1599,7 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*", @@ -7369,16 +7378,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-express": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/zod-express/-/zod-express-0.0.8.tgz", - "integrity": "sha512-zR0EQ6P12zox7v5piKTQCNIhgraVv8ev3Gkt5wxUlxt/3KUkEVrDyCYpfE0O5B2mTGAcfUktdP42dEV6rEKZYA==", - "peerDependencies": { - "@types/express": "^4.17.12", - "express": "^4.18.2", - "zod": "^3.21.4" - } - }, "node_modules/zod-validation-error": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.3.0.tgz", diff --git a/package.json b/package.json index 46103e34..0a2e219b 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "trivialperms": "^2.0.0-beta.0", "ts-essentials": "^10.0.0", "zod": "^3.23.8", - "zod-express": "^0.0.8", "zod-validation-error": "^3.3.0" }, "devDependencies": { diff --git a/src/server/engines/validation/express.ts b/src/server/engines/validation/express.ts index 31729ff1..ffb5e64a 100644 --- a/src/server/engines/validation/express.ts +++ b/src/server/engines/validation/express.ts @@ -2,12 +2,97 @@ // Express Validation Tools // --------------------------------------------------------------------------------------------------------------------- -import { Request, Response, NextFunction } from 'express'; - +import { z } from 'zod'; import { fromError } from 'zod-validation-error'; +import { Request, Response, NextFunction } from 'express'; // --------------------------------------------------------------------------------------------------------------------- +interface ProcessRequestSchema +{ + params ?: z.ZodObject; + query ?: z.ZodObject; + body ?: z.ZodObject; + +} + +export function processRequest(schema : ProcessRequestSchema) : any +{ + return async function(req : Request, res : Response, next : NextFunction) : Promise + { + const errors : any[] = []; + + // Process the params, query, and body of the request, replacing them with the validated values. + try + { + if(schema.params) + { + const paramResults = schema.params.safeParse(req.params); + if(paramResults.success) + { + req.params = paramResults.data; + } + else + { + errors.push({ type: 'Params', errors: paramResults.error }); + } + } + + // Express 5.x has req.query as getter only, so we have to do some shenanigans to modify it + if(schema.query) + { + // Parse the query parameters + const queryResults = schema.query.safeParse(req.query); + + if(queryResults.success) + { + // Delete all existing properties from req.query + for(const key in req.query) + { + delete req.query[key]; + } + + // Copy the validated properties to req.query + for(const key in queryResults.data) + { + req.query[key] = queryResults.data[key]; + } + } + else + { + errors.push({ type: 'Query', errors: queryResults.error }); + } + } + + if(schema.body) + { + const bodyResults = schema.body.safeParse(req.body); + if(bodyResults.success) + { + req.body = bodyResults.data; + } + else + { + errors.push({ type: 'Body', errors: bodyResults.error }); + } + } + + if(errors.length > 0) + { + next(errors); + } + else + { + next(); + } + } + catch (err) + { + next(err); + } + }; +} + export function validationErrorHandler(err : any, req : Request, res : Response, next : NextFunction) : void { if(Array.isArray(err)) diff --git a/src/server/engines/validation/models/account.ts b/src/server/engines/validation/models/account.ts index d9dc5f96..215f2147 100644 --- a/src/server/engines/validation/models/account.ts +++ b/src/server/engines/validation/models/account.ts @@ -4,10 +4,12 @@ import { z } from 'zod'; +// Models +import { HashID } from './common'; + // --------------------------------------------------------------------------------------------------------------------- -export const AccountID = z.string().min(4) - .regex(/^[a-zA-Z0-9]+$/); +export const AccountID = HashID; export const AccountSettings = z.object({ colorMode: z.enum([ 'light', 'dark', 'auto' ]).optional() diff --git a/src/server/engines/validation/models/common.ts b/src/server/engines/validation/models/common.ts new file mode 100644 index 00000000..3ea09f8f --- /dev/null +++ b/src/server/engines/validation/models/common.ts @@ -0,0 +1,12 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Common Validation Models +// --------------------------------------------------------------------------------------------------------------------- + +import { z } from 'zod'; + +// --------------------------------------------------------------------------------------------------------------------- + +export const HashID = z.string().min(4) + .regex(/^[a-zA-Z0-9]+$/); + +// --------------------------------------------------------------------------------------------------------------------- diff --git a/src/server/engines/validation/models/notebook.ts b/src/server/engines/validation/models/notebook.ts new file mode 100644 index 00000000..f93bc3d9 --- /dev/null +++ b/src/server/engines/validation/models/notebook.ts @@ -0,0 +1,44 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Notebook Validation Model +// --------------------------------------------------------------------------------------------------------------------- + +import { z } from 'zod'; + +// Models +import { HashID } from './common'; + +// --------------------------------------------------------------------------------------------------------------------- + +export const NotebookID = HashID; + +export const NotebookPage = z.object({ + id: z.string().min(1), + title: z.string().min(1), + content: z.string().min(1), + notebookID: NotebookID +}); + +export const Notebook = z.object({ + id: NotebookID, + pages: z.array(NotebookPage).optional() +}); + +// --------------------------------------------------------------------------------------------------------------------- +// Request Validations +// --------------------------------------------------------------------------------------------------------------------- + +export const RouteParams = z.object({ + noteID: NotebookID, + pageID: z.string().min(1) + .optional() +}); + +export const NotebookFilter = z.object({ + id: z.union([ NotebookID, z.array(NotebookID) ]).optional(), + email: z.union([ z.string().email(), z.array(z.string().email()) ]) + .optional(), + name: z.union([ z.string().min(1), z.array(z.string().min(1)) ]) + .optional() +}); + +// --------------------------------------------------------------------------------------------------------------------- diff --git a/src/server/routes/accounts.ts b/src/server/routes/accounts.ts index 6b1dbd45..d92af44e 100644 --- a/src/server/routes/accounts.ts +++ b/src/server/routes/accounts.ts @@ -3,7 +3,7 @@ //---------------------------------------------------------------------------------------------------------------------- import express from 'express'; -import { processRequest } from 'zod-express'; +import logging from '@strata-js/util-logging'; // Managers import * as accountMan from '../managers/account'; @@ -11,25 +11,21 @@ import * as permsMan from '../utils/permissions'; // Validation import * as AccountValidators from '../engines/validation/models/account'; -import { validationErrorHandler } from '../engines/validation/express'; +import { processRequest, validationErrorHandler } from '../engines/validation/express'; // Utils import { ensureAuthenticated, errorHandler } from './utils'; -// Logger -import logging from '@strata-js/util-logging'; - -const logger = logging.getLogger(module.filename); - //---------------------------------------------------------------------------------------------------------------------- const router = express.Router(); +const logger = logging.getLogger(module.filename); //---------------------------------------------------------------------------------------------------------------------- router.get( '/', - processRequest({ query: AccountValidators.AccountFilter }, { passErrorToNext: true }), + processRequest({ query: AccountValidators.AccountFilter }), async (req, resp) => { resp.json((await accountMan.list(req.query)).map((accountObj) => @@ -47,7 +43,7 @@ router.get( router.get( '/:accountID', - processRequest({ params: AccountValidators.UpdateParams }, { passErrorToNext: true }), + processRequest({ params: AccountValidators.UpdateParams }), async (req, resp) => { const user = req.user; @@ -81,7 +77,7 @@ router.patch( processRequest({ params: AccountValidators.UpdateParams, body: AccountValidators.Account.partial({ id: true }) - }, { passErrorToNext: true }), + }), async (req, resp) => { // Update the account diff --git a/src/server/routes/notebook.ts b/src/server/routes/notebook.ts index 78990a95..7d2d3458 100644 --- a/src/server/routes/notebook.ts +++ b/src/server/routes/notebook.ts @@ -3,102 +3,140 @@ //---------------------------------------------------------------------------------------------------------------------- import express from 'express'; - -import { convertQueryToRecord, ensureAuthenticated, errorHandler } from './utils'; +import logging from '@strata-js/util-logging'; // Managers import * as noteMan from '../managers/notebook'; import { hasPerm } from '../utils/permissions'; -// Logger -import logging from '@strata-js/util-logging'; -const logger = logging.getLogger(module.filename); +// Validation +import * as NotebookValidators from '../engines/validation/models/notebook'; +import { processRequest, validationErrorHandler } from '../engines/validation/express'; + +// Utils +import { convertQueryToRecord, ensureAuthenticated, errorHandler } from './utils'; //---------------------------------------------------------------------------------------------------------------------- const router = express.Router(); +const logger = logging.getLogger(module.filename); //---------------------------------------------------------------------------------------------------------------------- -router.get('/', async(req, resp) => -{ - if(req.isAuthenticated() && await hasPerm(req.user, 'Notes/canViewAll')) +router.get( + '/', + processRequest({ query: NotebookValidators.NotebookFilter }), + async(req, resp) => { - const query = convertQueryToRecord(req); - const filters = { id: query.id, email: query.email, title: query.title }; - resp.json(await noteMan.list(filters)); + if(req.isAuthenticated() && await hasPerm(req.user, 'Notes/canViewAll')) + { + const query = convertQueryToRecord(req); + const filters = { id: query.id, email: query.email, title: query.title }; + resp.json(await noteMan.list(filters)); + } + else + { + resp + .status(403) + .json({ + type: 'NotAuthorized', + message: `You are not authorized to view all notes.` + }); + } } - else +); + +router.post( + '/', + ensureAuthenticated, + processRequest({ body: NotebookValidators.Notebook.partial({ id: true }) }), + async(req, resp) => { - resp - .status(403) - .json({ - type: 'NotAuthorized', - message: `You are not authorized to view all notes.` - }); + const pages = req.body.pages; + resp.json(await noteMan.add(pages)); } -}); - -router.post('/', ensureAuthenticated, async(req, resp) => -{ - const pages = req.body.pages; - resp.json(await noteMan.add(pages)); -}); - -router.get('/:noteID', async(req, resp) => -{ - resp.json(await noteMan.get(req.params.noteID)); -}); - -router.post('/:noteID/pages', ensureAuthenticated, async(req, resp) => -{ - const page = req.body; - - // We're creating a new page, so we don't allow page id. - delete page.id; +); - // We have to look up the note from the hash. - const note = await noteMan.get(req.params.noteID); - page.notebookID = note.id; +router.get( + '/:noteID', + processRequest({ params: NotebookValidators.RouteParams }), + async(req, resp) => + { + resp.json(await noteMan.get(req.params.noteID)); + } +); + +router.post( + '/:noteID/pages', + ensureAuthenticated, + processRequest({ + params: NotebookValidators.RouteParams, + body: NotebookValidators.NotebookPage.partial({ id: true }) + }), + async(req, resp) => + { + const page = req.body; - // Update the note - resp.json(await noteMan.addPage(note.id, page)); -}); + // We're creating a new page, so we don't allow page id. + delete page.id; -router.patch('/:noteID/pages/:pageID', ensureAuthenticated, async(req, resp) => -{ + // Update the note + resp.json(await noteMan.addPage(req.params.noteID, page)); + } +); + +router.patch( + '/:noteID/pages/:pageID', + processRequest({ + params: NotebookValidators.RouteParams, + body: NotebookValidators.NotebookPage + }), + ensureAuthenticated, + async(req, resp) => + { // Update the note - const newPage = await noteMan.updatePage(req.params.pageID, req.body); - resp.json(newPage); -}); - -router.delete('/:noteID', ensureAuthenticated, async(req, resp) => -{ - // We don't check for existence, so we can be idempotent - resp.json(await noteMan.remove(req.params.noteID)); -}); - -router.delete('/:noteID/pages/:pageID', ensureAuthenticated, async(req, resp) => -{ - const notebook = await noteMan.get(req.params.noteID); - const page = (notebook.pages.filter((pageInst) => pageInst.id == req.params.pageID))[0]; + const newPage = await noteMan.updatePage(req.params.pageID, req.body); + resp.json(newPage); + } +); - if(page) +router.delete( + '/:noteID', + ensureAuthenticated, + processRequest({ params: NotebookValidators.RouteParams }), + async(req, resp) => { - resp.json(await noteMan.removePage(req.params.pageID)); + // We don't check for existence, so we can be idempotent + resp.json(await noteMan.remove(req.params.noteID)); } - else +); + +router.delete( + '/:noteID/pages/:pageID', + ensureAuthenticated, + processRequest({ params: NotebookValidators.RouteParams }), + async(req, resp) => { - console.warn('notebook not found.'); - // We don't throw an error, so we can be idempotent - resp.json({ status: 'ok' }); + try + { + resp.json(await noteMan.removePage(req.params.pageID)); + } + catch (error) + { + // Log this at debug, since we normally don't care if the page doesn't exist + logger.debug('Error removing page:', error); + + // We don't throw an error, so we can be idempotent + resp.json({ status: 'ok' }); + } } -}); +); //---------------------------------------------------------------------------------------------------------------------- // Error Handling //---------------------------------------------------------------------------------------------------------------------- +router.use(validationErrorHandler); router.use(errorHandler(logger)); //----------------------------------------------------------------------------------------------------------------------