diff --git a/package-lock.json b/package-lock.json index 85ee0cb..dd21f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "lowdb": "^7.0.1", "nanoid": "^5.1.5", "validator": "^13.15.0", - "wake_on_lan": "^1.0.0" + "wake_on_lan": "^1.0.0", + "zod": "^3.24.2" }, "devDependencies": { "@iconify-json/tabler": "^1.2.17", @@ -3218,6 +3219,15 @@ "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index d9c4060..30afe40 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "lowdb": "^7.0.1", "nanoid": "^5.1.5", "validator": "^13.15.0", - "wake_on_lan": "^1.0.0" + "wake_on_lan": "^1.0.0", + "zod": "^3.24.2" }, "overrides": { "@sveltejs/kit": { diff --git a/src/lib/server/fieldValidator.ts b/src/lib/server/fieldValidator.ts deleted file mode 100644 index cd41e10..0000000 --- a/src/lib/server/fieldValidator.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 7f3d2fb..2b7edab 100644 --- a/src/routes/dashboard/devices/[slug]/+page.server.ts +++ b/src/routes/dashboard/devices/[slug]/+page.server.ts @@ -1,9 +1,10 @@ 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'; +import { z } from 'zod'; + export const load: ServerLoad = async ({ locals: { guard }, params }) => { guard.requiresAdmin().orRedirects(); @@ -24,84 +25,51 @@ export const actions = { const form = await request.formData(); - const d: { - name?: string; - mac?: string; - broadcast?: string; - port?: number; - packets?: number; - } = {}; + const schema = z.object({ + name: z + .string({ message: 'Name is required.' }) + .min(3, { message: 'Name must be at least 3 characters.' }) + .max(24, { message: 'Name must be at most 24 characters.' }), + mac: z + .string({ message: 'MAC address is required.' }) + .refine((v) => validator.isMACAddress(v), { message: 'Invalid MAC address.' }), + broadcast: z + .string() + .refine((v) => validator.isIP(v), { message: 'Invalid broadcast IP address.' }) + .optional(), + port: z.coerce + .number({ message: 'Port is invalid.' }) + .min(1, { message: 'Port must be at least 1.' }) + .max(65535, { message: 'Port must be at most 65535.' }) + .optional(), + packets: z.coerce + .number({ message: 'Packets quantity is invalid.' }) + .min(1, { message: 'Packets quantity must be at least 1.' }) + .max(50, { message: 'Packets quantity must be at most 50.' }) + .optional(), + }); - try { - 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(); + const parsed = schema.safeParse({ + name: form.get('name')?.toString(), + mac: form.get('mac')?.toString(), + broadcast: form.get('broadcast')?.toString(), + port: form.get('port')?.toString(), + packets: form.get('packets')?.toString(), + }); - 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(); - - 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, - }); - } + if (!parsed.success) { + return fail(400, { error: parsed.error.errors[0].message }); } 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, + name: parsed.data.name, + mac: parsed.data.mac, + broadcast: parsed.data.broadcast ?? '255.255.255.255', + port: parsed.data.port ?? 9, + packets: parsed.data.packets ?? 3, }); }); @@ -112,11 +80,11 @@ export const actions = { 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; + dev.name = parsed.data.name; + dev.mac = parsed.data.mac; + dev.broadcast = parsed.data.broadcast ?? dev.broadcast; + dev.port = parsed.data.port ?? dev.port; + dev.packets = parsed.data.packets ?? dev.packets; }); redirect(302, '/dashboard/devices'); diff --git a/src/routes/dashboard/groups/[slug]/+page.server.ts b/src/routes/dashboard/groups/[slug]/+page.server.ts index d8200c2..1fdc1a1 100644 --- a/src/routes/dashboard/groups/[slug]/+page.server.ts +++ b/src/routes/dashboard/groups/[slug]/+page.server.ts @@ -1,6 +1,7 @@ import { db } from '$lib/server/db'; import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; import { nanoid } from 'nanoid'; +import { z } from 'zod'; export const load: ServerLoad = async ({ locals: { guard }, params }) => { guard.requiresAdmin().orRedirects(); @@ -19,42 +20,52 @@ export const load: ServerLoad = async ({ locals: { guard }, params }) => { export const actions: Actions = { update: async ({ request, locals: { guard }, params }) => { - 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 devices = form.getAll('devices').map((d) => d.toString()); - if (!name) { - // TODO better validation - return { - error: 'MISSING_FIELDS', - }; + const schema = z.object({ + name: z + .string({ message: 'Name is required.' }) + .min(3, { message: 'Name must be at least 3 characters.' }) + .max(24, { message: 'Name must be at most 24 characters.' }), + + devices: z.array( + z.string().refine((v) => db.data.devices.find((d) => d.id === v), { + message: 'Invalid device ID.', + }), + ), + }); + + const parsed = schema.safeParse({ + name: form.get('name'), + devices: form.getAll('devices'), + }); + + if (!parsed.success) { + return fail(400, { error: parsed.error.errors[0].message }); } if (params.slug === 'new') { await db.update(({ groups }) => { groups.push({ id: nanoid(), - name, - devices, + name: parsed.data.name, + devices: parsed.data.devices, }); }); - } else { - await db.update(({ groups }) => { - let group = groups.find((g) => g.id === params.slug); - if (!group) { - return; - } - - group.name = name; - group.devices = devices; - }); + redirect(302, '/dashboard/groups'); } + await db.update(({ groups }) => { + let group = groups.find((g) => g.id === params.slug); + if (!group) return; + + group.name = parsed.data.name; + group.devices = parsed.data.devices; + }); + redirect(302, '/dashboard/groups'); }, diff --git a/src/routes/dashboard/users/[slug]/+page.server.ts b/src/routes/dashboard/users/[slug]/+page.server.ts index 191ce34..91075a0 100644 --- a/src/routes/dashboard/users/[slug]/+page.server.ts +++ b/src/routes/dashboard/users/[slug]/+page.server.ts @@ -3,6 +3,7 @@ import type { User } from '$lib/server/db/types/user.js'; import { fail, redirect, type Actions } from '@sveltejs/kit'; import bcrypt from 'bcryptjs'; import { nanoid } from 'nanoid'; +import { z } from 'zod'; export const load = async ({ locals: { guard }, params }) => { guard.requiresAdmin().orRedirects(); @@ -27,58 +28,79 @@ export const load = async ({ locals: { guard }, params }) => { export const actions: Actions = { update: async ({ request, locals: { guard }, params }) => { - 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 admin = form.get('admin')?.toString() === 'on'; - const password = form.get('password')?.toString() ?? ''; - const groups = form.getAll('groups').map((g) => g.toString()); - const devices = form.getAll('devices').map((d) => d.toString()); - if (!name) { - // TODO better validation - return { - error: 'MISSING_FIELDS', - }; + const schema = z.object({ + name: z + .string({ message: 'Name is required.' }) + .min(3, { message: 'Name must be at least 3 characters.' }) + .max(24, { message: 'Name must be at most 24 characters.' }), + admin: z.boolean(), + password: z + .string() + .optional() + .refine((v) => (params.slug === 'new' ? v && v.length > 0 : true), { + message: 'Password is required at user creation.', + }) + .refine((v) => !v || v.length >= 8, { + message: 'Password must be at least 8 characters.', + }), + groups: z.array( + z.string().refine((v) => db.data.groups.find((g) => g.id === v), { + message: 'Invalid group ID.', + }), + ), + devices: z.array( + z.string().refine((v) => db.data.devices.find((d) => d.id === v), { + message: 'Invalid device ID.', + }), + ), + }); + + const parsed = schema.safeParse({ + name: form.get('name'), + admin: form.get('admin') === 'on', + password: form.get('password'), + groups: form.getAll('groups'), + devices: form.getAll('devices'), + }); + + if (!parsed.success) { + return fail(400, { error: parsed.error.errors[0].message }); } if (params.slug === 'new') { - if (password.length < 4) { - return { - error: 'PASSWORD_TOO_WEAK', - }; - } - await db.update(({ users }) => { users.push({ id: nanoid(), - name, - admin, - groups, - devices, - password: bcrypt.hashSync(password, 10), + name: parsed.data.name, + admin: parsed.data.admin, + groups: parsed.data.groups, + devices: parsed.data.devices, + password: bcrypt.hashSync(parsed.data.password!, 10), }); }); - } else { - await db.update(({ users }) => { - let user = users.find((u) => u.id === params.slug); - if (!user) { - return; - } - - user.name = name; - user.admin = admin; - user.groups = groups; - user.devices = devices; - if (password.length > 0) { - user.password = bcrypt.hashSync(password, 10); - } - }); + redirect(302, '/dashboard/users'); } + + await db.update(({ users }) => { + let user = users.find((u) => u.id === params.slug); + if (!user) return; + + user.name = parsed.data.name; + user.admin = parsed.data.admin; + user.groups = parsed.data.groups; + user.devices = parsed.data.devices; + + if (parsed.data.password && parsed.data.password.length > 0) { + user.password = bcrypt.hashSync(parsed.data.password, 10); + } + }); + + redirect(302, '/dashboard/users'); }, delete: async ({ locals: { guard }, params }) => { if (guard.requiresAdmin().isFailed()) {