wip: form validation

This commit is contained in:
axel 2025-04-12 18:02:41 +02:00
parent fec1cdb561
commit a0968f5350
4 changed files with 170 additions and 50 deletions

23
package-lock.json generated
View File

@ -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": {

View File

@ -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": {

View File

@ -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<T> {
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<T>(entry: FormDataEntryValue | null) {
return new FieldValidator<T>(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());
}
}

View File

@ -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<string>(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<string>(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<string | undefined>(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<number | undefined>(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<number | undefined>(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 }) => {