added zod form validation
This commit is contained in:
parent
a0968f5350
commit
7ae80f5e88
12
package-lock.json
generated
12
package-lock.json
generated
@ -15,7 +15,8 @@
|
|||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.0",
|
||||||
"wake_on_lan": "^1.0.0"
|
"wake_on_lan": "^1.0.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/tabler": "^1.2.17",
|
"@iconify-json/tabler": "^1.2.17",
|
||||||
@ -3218,6 +3219,15 @@
|
|||||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.0",
|
||||||
"wake_on_lan": "^1.0.0"
|
"wake_on_lan": "^1.0.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@sveltejs/kit": {
|
"@sveltejs/kit": {
|
||||||
|
|||||||
@ -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<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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import { db, getUsersDevices } from '$lib/server/db/index.js';
|
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 { fail, redirect, type ServerLoad } from '@sveltejs/kit';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import { wake } from 'wake_on_lan';
|
import { wake } from 'wake_on_lan';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
|
|
||||||
@ -24,84 +25,51 @@ export const actions = {
|
|||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|
||||||
const d: {
|
const schema = z.object({
|
||||||
name?: string;
|
name: z
|
||||||
mac?: string;
|
.string({ message: 'Name is required.' })
|
||||||
broadcast?: string;
|
.min(3, { message: 'Name must be at least 3 characters.' })
|
||||||
port?: number;
|
.max(24, { message: 'Name must be at most 24 characters.' }),
|
||||||
packets?: number;
|
mac: z
|
||||||
} = {};
|
.string({ message: 'MAC address is required.' })
|
||||||
|
.refine((v) => validator.isMACAddress(v), { message: 'Invalid MAC address.' }),
|
||||||
try {
|
broadcast: z
|
||||||
d.name = FieldValidator.from<string>(form.get('name'))
|
.string()
|
||||||
.required({ httpStatus: 400, message: 'Please enter a name.' })
|
.refine((v) => validator.isIP(v), { message: 'Invalid broadcast IP address.' })
|
||||||
.validator(
|
.optional(),
|
||||||
(v) =>
|
port: z.coerce
|
||||||
db.data.devices.find(
|
.number({ message: 'Port is invalid.' })
|
||||||
(d) => d.name.toLowerCase() === v?.toLowerCase() && d.id !== params.slug,
|
.min(1, { message: 'Port must be at least 1.' })
|
||||||
) === undefined,
|
.max(65535, { message: 'Port must be at most 65535.' })
|
||||||
{ httpStatus: 409, message: 'This device name is already in use.' },
|
.optional(),
|
||||||
)
|
packets: z.coerce
|
||||||
.validator((v) => validator.isLength(v!, { min: 3, max: 50 }), {
|
.number({ message: 'Packets quantity is invalid.' })
|
||||||
httpStatus: 400,
|
.min(1, { message: 'Packets quantity must be at least 1.' })
|
||||||
message: 'Device name must be between 3 and 50 characters.',
|
.max(50, { message: 'Packets quantity must be at most 50.' })
|
||||||
})
|
.optional(),
|
||||||
.parser((v) => v!.trim())
|
|
||||||
.value();
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(400, { error: parsed.error.errors[0].message });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.slug === 'new') {
|
if (params.slug === 'new') {
|
||||||
await db.update(({ devices }) => {
|
await db.update(({ devices }) => {
|
||||||
devices.push({
|
devices.push({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
name: d.name!,
|
name: parsed.data.name,
|
||||||
mac: d.mac!,
|
mac: parsed.data.mac,
|
||||||
broadcast: d.broadcast ?? '255.255.255.255',
|
broadcast: parsed.data.broadcast ?? '255.255.255.255',
|
||||||
port: d.port ?? 9,
|
port: parsed.data.port ?? 9,
|
||||||
packets: d.packets ?? 3,
|
packets: parsed.data.packets ?? 3,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,11 +80,11 @@ export const actions = {
|
|||||||
let dev = devices.find((d) => d.id === params.slug);
|
let dev = devices.find((d) => d.id === params.slug);
|
||||||
if (!dev) return;
|
if (!dev) return;
|
||||||
|
|
||||||
dev.name = d.name!;
|
dev.name = parsed.data.name;
|
||||||
dev.mac = d.mac!;
|
dev.mac = parsed.data.mac;
|
||||||
dev.broadcast = d.broadcast ?? dev.broadcast;
|
dev.broadcast = parsed.data.broadcast ?? dev.broadcast;
|
||||||
dev.port = d.port ?? dev.port;
|
dev.port = parsed.data.port ?? dev.port;
|
||||||
dev.packets = d.packets ?? dev.packets;
|
dev.packets = parsed.data.packets ?? dev.packets;
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect(302, '/dashboard/devices');
|
redirect(302, '/dashboard/devices');
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
@ -19,41 +20,51 @@ export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
update: async ({ request, locals: { guard }, params }) => {
|
update: async ({ request, locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) {
|
if (guard.requiresAdmin().isFailed()) return fail(403);
|
||||||
return fail(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const name = form.get('name')?.toString();
|
|
||||||
const devices = form.getAll('devices').map((d) => d.toString());
|
|
||||||
|
|
||||||
if (!name) {
|
const schema = z.object({
|
||||||
// TODO better validation
|
name: z
|
||||||
return {
|
.string({ message: 'Name is required.' })
|
||||||
error: 'MISSING_FIELDS',
|
.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') {
|
if (params.slug === 'new') {
|
||||||
await db.update(({ groups }) => {
|
await db.update(({ groups }) => {
|
||||||
groups.push({
|
groups.push({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
name,
|
name: parsed.data.name,
|
||||||
devices,
|
devices: parsed.data.devices,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
|
redirect(302, '/dashboard/groups');
|
||||||
|
}
|
||||||
|
|
||||||
await db.update(({ groups }) => {
|
await db.update(({ groups }) => {
|
||||||
let group = groups.find((g) => g.id === params.slug);
|
let group = groups.find((g) => g.id === params.slug);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
if (!group) {
|
group.name = parsed.data.name;
|
||||||
return;
|
group.devices = parsed.data.devices;
|
||||||
}
|
|
||||||
|
|
||||||
group.name = name;
|
|
||||||
group.devices = devices;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
redirect(302, '/dashboard/groups');
|
redirect(302, '/dashboard/groups');
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { User } from '$lib/server/db/types/user.js';
|
|||||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const load = async ({ locals: { guard }, params }) => {
|
export const load = async ({ locals: { guard }, params }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
@ -27,58 +28,79 @@ export const load = async ({ locals: { guard }, params }) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
update: async ({ request, locals: { guard }, params }) => {
|
update: async ({ request, locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) {
|
if (guard.requiresAdmin().isFailed()) return fail(403);
|
||||||
return fail(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = await request.formData();
|
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) {
|
const schema = z.object({
|
||||||
// TODO better validation
|
name: z
|
||||||
return {
|
.string({ message: 'Name is required.' })
|
||||||
error: 'MISSING_FIELDS',
|
.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 (params.slug === 'new') {
|
||||||
if (password.length < 4) {
|
|
||||||
return {
|
|
||||||
error: 'PASSWORD_TOO_WEAK',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.update(({ users }) => {
|
await db.update(({ users }) => {
|
||||||
users.push({
|
users.push({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
name,
|
name: parsed.data.name,
|
||||||
admin,
|
admin: parsed.data.admin,
|
||||||
groups,
|
groups: parsed.data.groups,
|
||||||
devices,
|
devices: parsed.data.devices,
|
||||||
password: bcrypt.hashSync(password, 10),
|
password: bcrypt.hashSync(parsed.data.password!, 10),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
|
redirect(302, '/dashboard/users');
|
||||||
|
}
|
||||||
|
|
||||||
await db.update(({ users }) => {
|
await db.update(({ users }) => {
|
||||||
let user = users.find((u) => u.id === params.slug);
|
let user = users.find((u) => u.id === params.slug);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
if (!user) {
|
user.name = parsed.data.name;
|
||||||
return;
|
user.admin = parsed.data.admin;
|
||||||
}
|
user.groups = parsed.data.groups;
|
||||||
|
user.devices = parsed.data.devices;
|
||||||
|
|
||||||
user.name = name;
|
if (parsed.data.password && parsed.data.password.length > 0) {
|
||||||
user.admin = admin;
|
user.password = bcrypt.hashSync(parsed.data.password, 10);
|
||||||
user.groups = groups;
|
|
||||||
user.devices = devices;
|
|
||||||
if (password.length > 0) {
|
|
||||||
user.password = bcrypt.hashSync(password, 10);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
redirect(302, '/dashboard/users');
|
||||||
},
|
},
|
||||||
delete: async ({ locals: { guard }, params }) => {
|
delete: async ({ locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) {
|
if (guard.requiresAdmin().isFailed()) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user