From a0968f535096966205f94504c3c72fe6af6d1a07 Mon Sep 17 00:00:00 2001 From: axel Date: Sat, 12 Apr 2025 18:02:41 +0200 Subject: [PATCH] wip: form validation --- package-lock.json | 23 ++- package.json | 2 + src/lib/server/fieldValidator.ts | 58 ++++++++ .../dashboard/devices/[slug]/+page.server.ts | 137 ++++++++++++------ 4 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 src/lib/server/fieldValidator.ts diff --git a/package-lock.json b/package-lock.json index a33b78e..85ee0cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "my-app", "version": "0.0.1", "dependencies": { + "@types/validator": "^13.12.3", "@types/wake_on_lan": "^0.0.33", "bcryptjs": "^3.0.2", "drizzle-orm": "^0.41.0", "lowdb": "^7.0.1", "nanoid": "^5.1.5", + "validator": "^13.15.0", "wake_on_lan": "^1.0.0" }, "devDependencies": { @@ -1492,6 +1494,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/validator": { + "version": "13.12.3", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.3.tgz", + "integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==", + "license": "MIT" + }, "node_modules/@types/wake_on_lan": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/wake_on_lan/-/wake_on_lan-0.0.33.tgz", @@ -3034,10 +3042,19 @@ } } }, + "node_modules/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", - "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", + "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 24eb781..d9c4060 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,13 @@ "vite": "^6.2.5" }, "dependencies": { + "@types/validator": "^13.12.3", "@types/wake_on_lan": "^0.0.33", "bcryptjs": "^3.0.2", "drizzle-orm": "^0.41.0", "lowdb": "^7.0.1", "nanoid": "^5.1.5", + "validator": "^13.15.0", "wake_on_lan": "^1.0.0" }, "overrides": { diff --git a/src/lib/server/fieldValidator.ts b/src/lib/server/fieldValidator.ts new file mode 100644 index 0000000..cd41e10 --- /dev/null +++ b/src/lib/server/fieldValidator.ts @@ -0,0 +1,58 @@ +type FieldValidatorErrorData = { + httpStatus: number; + message: string; +}; + +export class FieldValidatorError extends Error { + public readonly httpStatus: number; + + public constructor(data: FieldValidatorErrorData) { + super(data.message); + this.httpStatus = data.httpStatus; + this.name = 'FieldValidatorError'; + } +} + +export class FieldValidator { + private readonly entry; + + private validators: { + func: (value: string | undefined) => boolean; + err: FieldValidatorErrorData; + }[] = []; + + private parserFunc?: (value: string | undefined) => T; + + public constructor(entry: FormDataEntryValue | null) { + this.entry = entry; + } + + public static from(entry: FormDataEntryValue | null) { + return new FieldValidator(entry); + } + + public validator(func: (value: string | undefined) => boolean, err: FieldValidatorErrorData) { + this.validators.push({ func, err }); + return this; + } + + public required(err: FieldValidatorErrorData) { + this.validators.push({ func: (value) => value !== undefined, err }); + return this; + } + + public parser(parser: (value: string | undefined) => T) { + this.parserFunc = parser; + return this; + } + + public value(): T { + for (const { func, err } of this.validators) { + if (!func(this.entry?.toString())) { + throw new FieldValidatorError(err); + } + } + + return this.parserFunc!(this.entry?.toString()); + } +} diff --git a/src/routes/dashboard/devices/[slug]/+page.server.ts b/src/routes/dashboard/devices/[slug]/+page.server.ts index d710b48..7f3d2fb 100644 --- a/src/routes/dashboard/devices/[slug]/+page.server.ts +++ b/src/routes/dashboard/devices/[slug]/+page.server.ts @@ -1,8 +1,9 @@ import { db, getUsersDevices } from '$lib/server/db/index.js'; +import { FieldValidator, FieldValidatorError } from '$lib/server/fieldValidator.js'; import { fail, redirect, type ServerLoad } from '@sveltejs/kit'; import { nanoid } from 'nanoid'; +import validator from 'validator'; import { wake } from 'wake_on_lan'; - export const load: ServerLoad = async ({ locals: { guard }, params }) => { guard.requiresAdmin().orRedirects(); @@ -19,63 +20,105 @@ export const load: ServerLoad = async ({ locals: { guard }, params }) => { export const actions = { update: async ({ request, cookies, params, locals: { guard } }) => { - if (guard.requiresAdmin().isFailed()) { - return fail(403); - } + if (guard.requiresAdmin().isFailed()) return fail(403); const form = await request.formData(); - const name = form.get('name')?.toString(); - const mac = form.get('mac')?.toString(); - const broadcast = form.get('broadcast')?.toString(); - const port = form.get('port')?.toString(); - const packets = form.get('packets')?.toString(); - if (!name || !mac) { - // TODO better validation - return { - error: 'MISSING_FIELDS', - }; - } + const d: { + name?: string; + mac?: string; + broadcast?: string; + port?: number; + packets?: number; + } = {}; try { - if (params.slug === 'new') { - await db.update(({ devices }) => { - devices.push({ - id: nanoid(), - name, - mac, - broadcast: broadcast ?? '255.255.255.255', - port: port ? parseInt(port) : 9, - packets: packets ? parseInt(packets) : 3, - }); - }); - } else { - await db.update(({ devices }) => { - let dev = devices.find((d) => d.id === params.slug); + d.name = FieldValidator.from(form.get('name')) + .required({ httpStatus: 400, message: 'Please enter a name.' }) + .validator( + (v) => + db.data.devices.find( + (d) => d.name.toLowerCase() === v?.toLowerCase() && d.id !== params.slug, + ) === undefined, + { httpStatus: 409, message: 'This device name is already in use.' }, + ) + .validator((v) => validator.isLength(v!, { min: 3, max: 50 }), { + httpStatus: 400, + message: 'Device name must be between 3 and 50 characters.', + }) + .parser((v) => v!.trim()) + .value(); - if (!dev) { - return; - } + d.mac = FieldValidator.from(form.get('mac')) + .required({ httpStatus: 400, message: 'Please enter a MAC address.' }) + .validator( + (v) => db.data.devices.find((d) => d.mac === v && d.id !== params.slug) === undefined, + { httpStatus: 409, message: 'MAC address already in use.' }, + ) + .validator((v) => validator.isMACAddress(v!), { + httpStatus: 400, + message: 'MAC address is invalid.', + }) + .parser((v) => v!.toUpperCase()) + .value(); - dev.name = name; - dev.mac = mac; - dev.broadcast = broadcast ?? dev.broadcast; - dev.port = port ? parseInt(port) : dev.port; - dev.packets = packets ? parseInt(packets) : dev.packets; + d.broadcast = FieldValidator.from(form.get('broadcast')) + .validator((v) => (v ? validator.isIP(v) : true), { + httpStatus: 400, + message: 'Broadcast address is invalid.', + }) + .parser((v) => v) + .value(); + + d.port = FieldValidator.from(form.get('port')) + .validator((v) => (v ? validator.isPort(v) : true), { + httpStatus: 400, + message: 'Broadcast port is invalid.', + }) + .parser((v) => (v ? parseInt(v) : undefined)) + .value(); + + d.packets = FieldValidator.from(form.get('packets')) + .validator((v) => (v ? validator.isInt(v, { min: 1, max: 50 }) : true), { + httpStatus: 400, + message: 'Packets amount is invalid.', + }) + .parser((v) => (v ? parseInt(v) : undefined)) + .value(); + } catch (e) { + if (e instanceof FieldValidatorError) { + return fail(e.httpStatus, { + error: e.message, }); } - } catch (e: any) { - if (e.code === 'P2002') { - return fail(409, { - error: - 'This name or this MAC adress is already in use. Please make sure they are unique.', - }); - } else { - console.error(e); - return fail(500, { error: 'DATABASE_ERROR' }); - } } + if (params.slug === 'new') { + await db.update(({ devices }) => { + devices.push({ + id: nanoid(), + name: d.name!, + mac: d.mac!, + broadcast: d.broadcast ?? '255.255.255.255', + port: d.port ?? 9, + packets: d.packets ?? 3, + }); + }); + + redirect(302, '/dashboard/devices'); + } + + await db.update(({ devices }) => { + let dev = devices.find((d) => d.id === params.slug); + if (!dev) return; + + dev.name = d.name!; + dev.mac = d.mac!; + dev.broadcast = d.broadcast ?? dev.broadcast; + dev.port = d.port ?? dev.port; + dev.packets = d.packets ?? dev.packets; + }); + redirect(302, '/dashboard/devices'); }, delete: async ({ locals: { guard }, params }) => {