diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 661290b..9f5c4bc 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,4 +1,4 @@ -import { db } from '$lib/server/db'; +import { initRepos, users } from '$lib/server/db'; import { Guard } from '$lib/server/guard'; import { getUserFromSession } from '$lib/server/sessions'; import type { Handle, ServerInit } from '@sveltejs/kit'; @@ -7,20 +7,17 @@ import { writeFileSync } from 'fs'; import { nanoid } from 'nanoid'; export const init: ServerInit = async () => { - const anyUser = db.data.users[0]; + await initRepos(); - if (!anyUser) { + if (!(await users.any())) { const pass = nanoid(); - await db.update(({ users }) => { - users.push({ - id: nanoid(), - name: 'admin', - password: bcrypt.hashSync(pass, 10), - admin: true, - groups: [], - devices: [], - }); + await users.create({ + name: 'admin', + password: bcrypt.hashSync(pass, 10), + admin: true, + groups: [], + devices: [], }); console.log(`default admin password: ${pass}`); @@ -33,7 +30,7 @@ export const init: ServerInit = async () => { export const handle: Handle = async ({ event, resolve }) => { const { cookies, locals } = event; - locals.guard = new Guard(getUserFromSession(cookies.get('session'))); + locals.guard = new Guard(await getUserFromSession(cookies.get('session'))); return await resolve(event); }; diff --git a/src/lib/server/commonResponses.ts b/src/lib/server/commonResponses.ts new file mode 100644 index 0000000..ab06acf --- /dev/null +++ b/src/lib/server/commonResponses.ts @@ -0,0 +1,6 @@ +import { fail } from '@sveltejs/kit'; +import type { ZodError } from 'zod'; + +export const FORBIDDEN = fail(403, { error: 'You do not have permission to do that.' }); +export const SUCCESS = { success: true }; +export const PARSE_ERROR = (err: ZodError) => fail(400, { error: err.errors[0].message }); diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 9977316..d99314e 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -1,42 +1,16 @@ -import { JSONFilePreset } from 'lowdb/node'; -import type { Device } from './types/device'; -import type { Group } from './types/group'; -import type { User } from './types/user'; +import type { IDeviceRepo } from './repos/deviceRepo'; +import type { IGroupRepo } from './repos/groupRepo'; +import { LowStorage } from './repos/lowdb/_init'; +import type { IUserRepo } from './repos/userRepo'; -type AppData = { - users: User[]; - groups: Group[]; - devices: Device[]; -}; +export let users: IUserRepo; +export let groups: IGroupRepo; +export let devices: IDeviceRepo; -export const db = await JSONFilePreset('./data/db.json', { - users: [], - groups: [], - devices: [], -}); +export async function initRepos() { + const repos = await LowStorage.init(); -export function getUsersDevices(userId: string): Device[] { - const user = db.data.users.find((u) => u.id === userId)!; - - if (user.admin) return db.data.devices; - - const userDevices = db.data.devices.filter((d) => user.devices.includes(d.id)); - const userGroups = db.data.groups.filter((g) => user.groups.includes(g.id)); - const groupDevices = userGroups.flatMap((g) => - db.data.devices.filter((d) => g.devices.includes(d.id)), - ); - - // concat and dedupe - return [...new Set([...userDevices, ...groupDevices])]; -} - -/** @deprecated */ -export function assignDefaults(defaults: Partial, obj: any, options = { mutate: false }): T { - for (let k of Object.keys(obj)) { - if (obj[k] === undefined) { - delete obj[k]; - } - } - - return options.mutate ? Object.assign(defaults, obj) : Object.assign({}, defaults, obj); + users = repos.users; + groups = repos.groups; + devices = repos.devices; } diff --git a/src/lib/server/db/repos/deviceRepo.ts b/src/lib/server/db/repos/deviceRepo.ts new file mode 100644 index 0000000..8c4e583 --- /dev/null +++ b/src/lib/server/db/repos/deviceRepo.ts @@ -0,0 +1,10 @@ +import type { New, Updated } from '../types'; +import type { Device, DeviceError } from '../types/device'; + +export interface IDeviceRepo { + getAll(): Promise; + getById(id: string): Promise; + create(group: New): Promise; + update(group: Updated): Promise; + delete(groupId: string): Promise; +} diff --git a/src/lib/server/db/repos/groupRepo.ts b/src/lib/server/db/repos/groupRepo.ts new file mode 100644 index 0000000..28b09be --- /dev/null +++ b/src/lib/server/db/repos/groupRepo.ts @@ -0,0 +1,11 @@ +import type { New, Updated } from '../types'; +import type { Group, GroupError } from '../types/group'; + +export interface IGroupRepo { + getAll(): Promise; + getById(id: string): Promise; + create(group: New): Promise; + update(group: Updated): Promise; + delete(groupId: string): Promise; + countUsers(groupId: string): Promise; +} diff --git a/src/lib/server/db/repos/lowdb/_init.ts b/src/lib/server/db/repos/lowdb/_init.ts new file mode 100644 index 0000000..2fb17c5 --- /dev/null +++ b/src/lib/server/db/repos/lowdb/_init.ts @@ -0,0 +1,29 @@ +import { JSONFilePreset } from 'lowdb/node'; +import type { Device } from '../../types/device'; +import type { Group } from '../../types/group'; +import type { User } from '../../types/user'; +import { LowUserRepo } from './userRepo'; +import { LowGroupRepo } from './groupRepo'; +import { LowDeviceRepo } from './deviceRepo'; + +const db = await JSONFilePreset('./data/db.json', { + users: [], + groups: [], + devices: [], +}); + +export namespace LowStorage { + export type LowData = { + users: User[]; + groups: Group[]; + devices: Device[]; + }; + + export async function init() { + return { + users: new LowUserRepo(db), + groups: new LowGroupRepo(db), + devices: new LowDeviceRepo(db), + }; + } +} diff --git a/src/lib/server/db/repos/lowdb/deviceRepo.ts b/src/lib/server/db/repos/lowdb/deviceRepo.ts new file mode 100644 index 0000000..42fea2e --- /dev/null +++ b/src/lib/server/db/repos/lowdb/deviceRepo.ts @@ -0,0 +1,76 @@ +import type { IDeviceRepo } from '../deviceRepo'; +import type { Low } from 'lowdb'; +import { type New, type Updated } from '../../types'; +import { type Device, type DeviceError, DeviceErrors } from '../../types/device'; +import { nanoid } from 'nanoid'; +import type { LowStorage } from './_init'; + +export class LowDeviceRepo implements IDeviceRepo { + private readonly db; + + constructor(db: Low) { + this.db = db; + } + + async getAll(): Promise { + return this.db.data.devices; + } + + async getById(id: string): Promise { + return this.db.data.devices.find((d) => d.id === id); + } + + async create(device: New): Promise { + if (this.db.data.devices.find((d) => d.name === device.name)) { + return DeviceErrors.DUPLICATE_NAME; + } + + await this.db.update(({ devices }) => { + devices.push({ id: nanoid(), ...device }); + }); + + return undefined; + } + + async update(device: Updated): Promise { + if (this.db.data.devices.find((d) => d.name === device.name && d.id !== device.id)) { + return DeviceErrors.DUPLICATE_NAME; + } + + let found = false; + await this.db.update(({ devices }) => { + const existingDevice = devices.find((d) => d.id === device.id); + if (!existingDevice) return; + + Object.assign(existingDevice, device); + found = true; + }); + + return found ? undefined : DeviceErrors.NOT_FOUND; + } + + async delete(deviceId: string): Promise { + let found = false; + + this.db.data.devices = this.db.data.devices.filter((d) => { + if (d.id === deviceId) { + found = true; + return false; + } + return true; + }); + + if (found) { + this.db.data.users.forEach((u) => { + u.devices = u.devices.filter((d) => d !== deviceId); + }); + this.db.data.groups.forEach((g) => { + g.devices = g.devices.filter((d) => d !== deviceId); + }); + + await this.db.write(); + } + + return found ? undefined : DeviceErrors.NOT_FOUND; + } +} diff --git a/src/lib/server/db/repos/lowdb/groupRepo.ts b/src/lib/server/db/repos/lowdb/groupRepo.ts new file mode 100644 index 0000000..417072d --- /dev/null +++ b/src/lib/server/db/repos/lowdb/groupRepo.ts @@ -0,0 +1,86 @@ +import type { IGroupRepo } from '../groupRepo'; +import type { Low } from 'lowdb'; +import { type New, type Updated } from '../../types'; +import { type Group, type GroupError, GroupErrors } from '../../types/group'; +import { nanoid } from 'nanoid'; +import type { LowStorage } from './_init'; + +export class LowGroupRepo implements IGroupRepo { + private readonly db; + + constructor(db: Low) { + this.db = db; + } + + async getAll(): Promise { + return this.db.data.groups; + } + + async getById(id: string): Promise { + return this.db.data.groups.find((g) => g.id === id); + } + + async create(group: New): Promise { + if (this.db.data.groups.find((g) => g.name === group.name)) { + return GroupErrors.DUPLICATE_NAME; + } + + for (const deviceId of group.devices) { + if (!this.db.data.devices.some((d) => d.id === deviceId)) { + return GroupErrors.UNKNOWN_DEVICE; + } + } + + await this.db.update(({ groups }) => { + groups.push({ id: nanoid(), ...group }); + }); + + return undefined; + } + + async update(group: Updated): Promise { + if (this.db.data.groups.find((g) => g.name === group.name && g.id !== group.id)) { + return GroupErrors.DUPLICATE_NAME; + } + + for (const deviceId of group.devices ?? []) { + if (!this.db.data.devices.some((d) => d.id === deviceId)) { + return GroupErrors.UNKNOWN_DEVICE; + } + } + + let found = false; + await this.db.update(({ groups }) => { + const existingGroup = groups.find((g) => g.id === group.id); + if (!existingGroup) return; + + Object.assign(existingGroup, group); + found = true; + }); + + return found ? undefined : GroupErrors.NOT_FOUND; + } + + async delete(groupId: string): Promise { + let found = false; + + this.db.data.groups = this.db.data.groups.filter((g) => { + if (g.id === groupId) { + found = true; + return false; + } + return true; + }); + + if (found) { + this.db.data.users.forEach((u) => (u.groups = u.groups.filter((gid) => gid != groupId))); + await this.db.write(); + } + + return found ? undefined : GroupErrors.NOT_FOUND; + } + + async countUsers(groupId: string): Promise { + return this.db.data.users.filter((u) => u.groups.includes(groupId)).length; + } +} diff --git a/src/lib/server/db/repos/lowdb/userRepo.ts b/src/lib/server/db/repos/lowdb/userRepo.ts new file mode 100644 index 0000000..808ab72 --- /dev/null +++ b/src/lib/server/db/repos/lowdb/userRepo.ts @@ -0,0 +1,113 @@ +import type { Low } from 'lowdb'; +import { type New, type Updated } from '../../types'; +import { UserErrors, type User } from '../../types/user'; +import type { IUserRepo } from '../userRepo'; +import { nanoid } from 'nanoid'; +import type { LowStorage } from './_init'; + +export class LowUserRepo implements IUserRepo { + private readonly db; + + constructor(db: Low) { + this.db = db; + } + + async any() { + return this.db.data.users[0] != undefined; + } + + async getAll() { + return this.db.data.users; + } + + async getById(id: string) { + return this.db.data.users.find((u) => u.id == id); + } + + async getByName(name: string) { + return this.db.data.users.find((u) => u.name == name); + } + + async create(user: New) { + if (!this.db.data.users.find((u) => u.name == user.name)) { + return UserErrors.DUPLICATE_NAME; + } + + for (let id of user.devices) { + if (!this.db.data.devices.some((d) => d.id == id)) { + return UserErrors.UNKNOWN_DEVICE; + } + } + + for (let id of user.groups) { + if (!this.db.data.groups.some((g) => g.id == id)) { + return UserErrors.UNKNOWN_GROUP; + } + } + + await this.db.update(({ users }) => { + users.push({ id: nanoid(), ...user }); + }); + } + + async update(user: Updated) { + if (this.db.data.users.find((u) => u.name == user.name && u.id != user.id)) { + return UserErrors.DUPLICATE_NAME; + } + + for (let id of user.devices ?? []) { + if (!this.db.data.devices.some((d) => d.id == id)) { + return UserErrors.UNKNOWN_DEVICE; + } + } + + for (let id of user.groups ?? []) { + if (!this.db.data.groups.some((g) => g.id == id)) { + return UserErrors.UNKNOWN_GROUP; + } + } + + let found = false; + await this.db.update(({ users }) => { + let existing = users.find((u) => u.id == user.id); + if (!existing) return; + + Object.assign(existing, user); + found = true; + }); + + return found ? undefined : UserErrors.NOT_FOUND; + } + + async fetchDevices(userId: string) { + const user = this.db.data.users.find((u) => u.id === userId)!; + + if (user.admin) return this.db.data.devices; + + const userDevices = this.db.data.devices.filter((d) => user.devices.includes(d.id)); + const userGroups = this.db.data.groups.filter((g) => user.groups.includes(g.id)); + const groupDevices = userGroups.flatMap((g) => + this.db.data.devices.filter((d) => g.devices.includes(d.id)), + ); + + // concat and dedupe + return [...new Set([...userDevices, ...groupDevices])]; + } + + async delete(userId: string) { + let found = false; + + this.db.data.users = this.db.data.users.filter((u) => { + if (u.id == userId) { + found = true; + return false; + } + + return true; + }); + + await this.db.write(); + + return found ? undefined : UserErrors.NOT_FOUND; + } +} diff --git a/src/lib/server/db/repos/userRepo.ts b/src/lib/server/db/repos/userRepo.ts new file mode 100644 index 0000000..6ddbefa --- /dev/null +++ b/src/lib/server/db/repos/userRepo.ts @@ -0,0 +1,14 @@ +import type { New, Updated } from '../types'; +import type { Device } from '../types/device'; +import type { User, UserError } from '../types/user'; + +export interface IUserRepo { + any(): Promise; + getAll(): Promise; + getById(id: string): Promise; + getByName(name: string): Promise; + fetchDevices(userId: string): Promise; + create(user: New): Promise; + update(user: Updated): Promise; + delete(userId: string): Promise; +} diff --git a/src/lib/server/db/types/device.ts b/src/lib/server/db/types/device.ts index 115df58..6645ea6 100644 --- a/src/lib/server/db/types/device.ts +++ b/src/lib/server/db/types/device.ts @@ -1,3 +1,5 @@ +import { AppError } from '.'; + export type Device = { id: string; name: string; @@ -6,3 +8,16 @@ export type Device = { port: number; packets: number; }; + +export class DeviceError extends AppError { + constructor(message: string, status: number) { + super(message, status); + this.name = 'DeviceError'; + Object.setPrototypeOf(this, DeviceError.prototype); + } +} + +export namespace DeviceErrors { + export const NOT_FOUND = new DeviceError('Group not found.', 404); + export const DUPLICATE_NAME = new DeviceError('Device with this name already exists.', 409); +} diff --git a/src/lib/server/db/types/group.ts b/src/lib/server/db/types/group.ts index bce0e08..c04b0ca 100644 --- a/src/lib/server/db/types/group.ts +++ b/src/lib/server/db/types/group.ts @@ -1,5 +1,24 @@ +import { AppError } from '.'; + export type Group = { id: string; name: string; devices: string[]; }; + +export class GroupError extends AppError { + constructor(message: string, status: number) { + super(message, status); + this.name = 'GroupError'; + Object.setPrototypeOf(this, GroupError.prototype); + } +} + +export namespace GroupErrors { + export const NOT_FOUND = new GroupError('Group not found.', 404); + export const DUPLICATE_NAME = new GroupError('Group with this name already exists.', 409); + export const UNKNOWN_DEVICE = new GroupError( + 'One or more of the selected devices do not exist.', + 400, + ); +} diff --git a/src/lib/server/db/types/index.ts b/src/lib/server/db/types/index.ts new file mode 100644 index 0000000..0c2c143 --- /dev/null +++ b/src/lib/server/db/types/index.ts @@ -0,0 +1,38 @@ +import { fail } from '@sveltejs/kit'; + +/** @deprecated unused for now - keeping around just in case but should be removed someday */ +export class Result { + public readonly err; + private readonly res; + + constructor(res?: TOk, err?: TErr) { + this.res = res; + this.err = err; + } + + public unwrap(): TOk { + if (this.err) { + console.error('Tried to unwrap a Result that contained an error'); + throw this.err; + } + + return this.res!; + } +} + +export class AppError extends Error { + public readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'AppError'; + this.status = status; + Object.setPrototypeOf(this, AppError.prototype); + } + + public toFail() { + return fail(this.status, { error: this.message }); + } +} + +export type New = Omit; +export type Updated = Partial & { id: string }; diff --git a/src/lib/server/db/types/user.ts b/src/lib/server/db/types/user.ts index 472ec8c..950b35c 100644 --- a/src/lib/server/db/types/user.ts +++ b/src/lib/server/db/types/user.ts @@ -1,3 +1,5 @@ +import { AppError } from '.'; + export type User = { id: string; name: string; @@ -14,3 +16,24 @@ export function toPublicUser(user: User): PublicUser { delete clonedUser.password; return clonedUser as PublicUser; } + +export class UserError extends AppError { + constructor(message: string, status: number) { + super(message, status); + this.name = 'UserError'; + Object.setPrototypeOf(this, UserError.prototype); + } +} + +export namespace UserErrors { + export const NOT_FOUND = new UserError('User not found.', 404); + export const DUPLICATE_NAME = new UserError('User with this username already exists.', 409); + export const UNKNOWN_GROUP = new UserError( + 'One or more of the selected groups do not exist.', + 400, + ); + export const UNKNOWN_DEVICE = new UserError( + 'One or more of the selected devices do not exist.', + 400, + ); +} diff --git a/src/lib/server/sessions.ts b/src/lib/server/sessions.ts index 9990059..a2c908e 100644 --- a/src/lib/server/sessions.ts +++ b/src/lib/server/sessions.ts @@ -1,5 +1,5 @@ import { nanoid } from 'nanoid'; -import { db } from './db'; +import { users } from './db'; type SessionData = { userId: string; @@ -15,13 +15,13 @@ export function createSession(data: SessionData) { return token; } -export function getUserFromSession(sessionId?: string) { +export async function getUserFromSession(sessionId?: string) { if (!sessionId) return undefined; const data = sessions.get(sessionId); if (!data) return undefined; - return db.data.users.find((u) => u.id === data.userId); + return await users.getById(data.userId); } export function deleteSession(sessionId?: string) { diff --git a/src/routes/dash/devices/+page.server.ts b/src/routes/dash/devices/+page.server.ts index 7efc0a0..bd633ba 100644 --- a/src/routes/dash/devices/+page.server.ts +++ b/src/routes/dash/devices/+page.server.ts @@ -1,9 +1,9 @@ -import { getUsersDevices } from '$lib/server/db'; +import { users } from '$lib/server/db'; import type { ServerLoad } from '@sveltejs/kit'; export const load: ServerLoad = async ({ locals: { guard } }) => { const user = guard.requiresAuth().orRedirects().getUser(); return { - devices: getUsersDevices(user.id), + devices: await users.fetchDevices(user.id), }; }; diff --git a/src/routes/dash/devices/[id]/+page.server.ts b/src/routes/dash/devices/[id]/+page.server.ts index cf561e4..77eeafc 100644 --- a/src/routes/dash/devices/[id]/+page.server.ts +++ b/src/routes/dash/devices/[id]/+page.server.ts @@ -1,6 +1,6 @@ -import { db, getUsersDevices } from '$lib/server/db/index.js'; +import { FORBIDDEN, PARSE_ERROR, SUCCESS } from '$lib/server/commonResponses'; +import { devices, users } from '$lib/server/db/index.js'; import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; -import { nanoid } from 'nanoid'; import validator from 'validator'; import { wake } from 'wake_on_lan'; import { z } from 'zod'; @@ -8,7 +8,7 @@ import { z } from 'zod'; export const load: ServerLoad = async ({ locals: { guard }, params }) => { guard.requiresAdmin().orRedirects(); - const device = db.data.devices.find((d) => d.id === params.id); + const device = await devices.getById(params.id ?? ''); if (!device && params.id !== 'new') { redirect(302, '/dash/devices'); @@ -21,7 +21,7 @@ export const load: ServerLoad = async ({ locals: { guard }, params }) => { export const actions = { update: async ({ request, params, locals: { guard } }) => { - if (guard.requiresAdmin().isFailed()) return fail(403); + if (guard.requiresAdmin().isFailed()) return FORBIDDEN; const form = await request.formData(); @@ -55,54 +55,29 @@ export const actions = { }); if (!parsed.success) { - return fail(400, { error: parsed.error.errors[0].message }); + return PARSE_ERROR(parsed.error); } - if (params.id === 'new') { - await db.update(({ devices }) => { - devices.push({ id: nanoid(), ...parsed.data }); - }); + const err = + params.id === 'new' + ? await devices.create(parsed.data) + : await devices.update({ id: params.id ?? '', ...parsed.data }); - return { success: true }; - } - - let found = false; - - await db.update(({ devices }) => { - let dev = devices.find((d) => d.id === params.id); - if (!dev) return; - - Object.assign(dev, parsed.data); - found = true; - }); - - return found ? { success: true } : fail(404, { error: 'Device not found.' }); + return err ? err.toFail() : SUCCESS; }, + delete: async ({ locals: { guard }, params }) => { - if (guard.requiresAdmin().isFailed()) { - return fail(403); - } + if (guard.requiresAdmin().isFailed()) return FORBIDDEN; - db.data.devices = db.data.devices.filter((d) => d.id !== params.id); - db.data.users.forEach((u) => { - u.devices = u.devices.filter((d) => d !== params.id); - }); - db.data.groups.forEach((g) => { - g.devices = g.devices.filter((d) => d !== params.id); - }); - db.write(); - - return { success: true }; + const err = await devices.delete(params.id ?? ''); + return err ? err.toFail() : SUCCESS; }, + wake: async ({ params, locals: { guard } }) => { guard = guard.requiresAuth(); + if (guard.isFailed()) return FORBIDDEN; - if (guard.isFailed()) { - console.log('Failed guard'); - return fail(403); - } - - const device = getUsersDevices(guard.getUser().id).find((d) => d.id === params.id); + const device = (await users.fetchDevices(guard.getUser().id)).find((d) => d.id === params.id); if (!device) { return fail(404); @@ -115,5 +90,7 @@ export const actions = { port: device.port, num_packets: device.packets, }); + + return SUCCESS; }, } satisfies Actions; diff --git a/src/routes/dash/groups/+page.server.ts b/src/routes/dash/groups/+page.server.ts index 7d6cd03..fbce92c 100644 --- a/src/routes/dash/groups/+page.server.ts +++ b/src/routes/dash/groups/+page.server.ts @@ -1,16 +1,18 @@ -import { db } from '$lib/server/db'; +import { groups } from '$lib/server/db'; import { type ServerLoad } from '@sveltejs/kit'; export const load: ServerLoad = async ({ locals: { guard } }) => { guard.requiresAdmin().orRedirects(); + + const allGroups = await groups.getAll(); + let userCounts: { [key: string]: number } = {}; + + for (let g of allGroups) { + userCounts[g.id] = await groups.countUsers(g.id); + } + return { - groups: db.data.groups, - userCounts: db.data.groups.reduce( - (acc, group) => { - acc[group.id] = db.data.users.filter((u) => u.groups.includes(group.id)).length; - return acc; - }, - {} as Record, - ), + groups: allGroups, + userCounts, }; }; diff --git a/src/routes/dash/groups/[id]/+page.server.ts b/src/routes/dash/groups/[id]/+page.server.ts index 13f2964..66a4576 100644 --- a/src/routes/dash/groups/[id]/+page.server.ts +++ b/src/routes/dash/groups/[id]/+page.server.ts @@ -1,26 +1,26 @@ -import { db } from '$lib/server/db'; -import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; -import { nanoid } from 'nanoid'; +import { FORBIDDEN, PARSE_ERROR, SUCCESS } from '$lib/server/commonResponses'; +import { devices, groups } from '$lib/server/db'; +import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; import { z } from 'zod'; export const load: ServerLoad = async ({ locals: { guard }, params }) => { guard.requiresAdmin().orRedirects(); - const group = db.data.groups.find((g) => g.id === params.id); + const group = await groups.getById(params.id ?? ''); if (!group && params.id !== 'new') { redirect(302, '/dash/groups'); } return { - devices: db.data.devices, + devices: await devices.getAll(), group, }; }; export const actions = { update: async ({ request, locals: { guard }, params }) => { - if (guard.requiresAdmin().isFailed()) return fail(403); + if (guard.requiresAdmin().isFailed()) return FORBIDDEN; const form = await request.formData(); @@ -29,12 +29,7 @@ export const actions = { .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.', - }), - ), + devices: z.array(z.string()), }); const parsed = schema.safeParse({ @@ -43,41 +38,21 @@ export const actions = { }); if (!parsed.success) { - return fail(400, { error: parsed.error.errors[0].message }); + return PARSE_ERROR(parsed.error); } - if (params.id === 'new') { - await db.update(({ groups }) => { - groups.push({ id: nanoid(), ...parsed.data }); - }); + const err = + params.id === 'new' + ? await groups.create({ ...parsed.data }) + : await groups.update({ id: params.id ?? '', ...parsed.data }); - return { success: true }; - } - - let found = false; - - await db.update(({ groups }) => { - let group = groups.find((g) => g.id === params.id); - if (!group) return; - - Object.assign(group, parsed.data); - found = true; - }); - - return found ? { success: true } : fail(400, { error: 'Group not found.' }); + return err ? err.toFail() : SUCCESS; }, delete: async ({ locals: { guard }, params }) => { - if (guard.requiresAdmin().isFailed()) { - return fail(403); - } + if (guard.requiresAdmin().isFailed()) return FORBIDDEN; - db.data.groups = db.data.groups.filter((g) => g.id !== params.id); - db.data.users.forEach((u) => { - u.groups = u.groups.filter((g) => g !== params.id); - }); - db.write(); - - redirect(302, '/dashboard/groups'); + const err = await groups.delete(params.id ?? ''); + return err ? err.toFail() : SUCCESS; }, } satisfies Actions; diff --git a/src/routes/dash/users/+page.server.ts b/src/routes/dash/users/+page.server.ts index 6801ad3..4df5a48 100644 --- a/src/routes/dash/users/+page.server.ts +++ b/src/routes/dash/users/+page.server.ts @@ -1,4 +1,4 @@ -import { db } from '$lib/server/db'; +import { users } from '$lib/server/db'; import { toPublicUser, type User } from '$lib/server/db/types/user'; import type { ServerLoad } from '@sveltejs/kit'; @@ -6,6 +6,6 @@ export const load: ServerLoad = async ({ locals: { guard } }) => { guard.requiresAdmin().orRedirects(); return { - users: db.data.users.map((u) => toPublicUser(u)), + users: (await users.getAll()).map((u) => toPublicUser(u)), }; }; diff --git a/src/routes/dash/users/[id]/+page.server.ts b/src/routes/dash/users/[id]/+page.server.ts index eb536c5..da5feb2 100644 --- a/src/routes/dash/users/[id]/+page.server.ts +++ b/src/routes/dash/users/[id]/+page.server.ts @@ -1,29 +1,30 @@ -import { db } from '$lib/server/db'; +import { FORBIDDEN, PARSE_ERROR, SUCCESS } from '$lib/server/commonResponses'; +import { devices, groups, users } from '$lib/server/db'; +import type { Updated } from '$lib/server/db/types'; import { toPublicUser, type User } from '$lib/server/db/types/user.js'; -import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; +import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; import bcrypt from 'bcryptjs'; -import { nanoid } from 'nanoid'; import { z } from 'zod'; export const load: ServerLoad = async ({ locals: { guard }, params }) => { guard.requiresAdmin().orRedirects(); - let user = db.data.users.find((u) => u.id === params.id); + let user = await users.getById(params.id ?? ''); if (!user && params.id !== 'new') { - redirect(302, '/dashboard/users'); + redirect(302, '/dash/users'); } return { user: toPublicUser(user!), - groups: db.data.groups, - devices: db.data.devices, + groups: await groups.getAll(), + devices: await devices.getAll(), }; }; export const actions = { update: async ({ request, locals: { guard }, params }) => { - if (guard.requiresAdmin().isFailed()) return fail(403); + if (guard.requiresAdmin().isFailed()) return FORBIDDEN; const form = await request.formData(); @@ -35,23 +36,16 @@ export const actions = { admin: z.boolean(), password: z .string() - .optional() - .refine((v) => (params.id === 'new' ? v && v.length > 0 : true), { + .refine((v) => (params.id === 'new' ? v.length > 0 : true), { + // if /new, password must be present message: 'Password is required at user creation.', }) - .refine((v) => !v || v.length >= 8, { + .refine((v) => (v.length > 0 ? v.length >= 8 : true), { + // if password present, must be at least 8 chars 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.', - }), - ), + groups: z.array(z.string()), + devices: z.array(z.string()), }); const parsed = schema.safeParse({ @@ -63,49 +57,35 @@ export const actions = { }); if (!parsed.success) { - return fail(400, { error: parsed.error.errors[0].message }); + return PARSE_ERROR(parsed.error); } if (params.id === 'new') { - await db.update(({ users }) => { - users.push({ - id: nanoid(), - name: parsed.data.name, - admin: parsed.data.admin, - groups: parsed.data.groups, - devices: parsed.data.devices, - password: bcrypt.hashSync(parsed.data.password!, 10), - }); + const err = await users.create({ + ...parsed.data, + password: bcrypt.hashSync(parsed.data.password, 10), }); - return { success: true }; + return err ? err.toFail() : SUCCESS; } - let found = false; - await db.update(({ users }) => { - let user = users.find((u) => u.id === params.id); - if (!user) return; + let updatedUser: Updated = { id: params.id ?? '', ...parsed.data }; - user.name = parsed.data.name; - user.admin = parsed.data.admin; - user.groups = parsed.data.groups; - user.devices = parsed.data.devices; + if (parsed.data.password.length > 0) { + updatedUser.password = bcrypt.hashSync(parsed.data.password, 10); + } else { + // avoid saving empty passwpord + delete updatedUser.password; + } - if (parsed.data.password && parsed.data.password.length > 0) { - user.password = bcrypt.hashSync(parsed.data.password, 10); - } - - found = true; - }); - - return found ? { success: true } : fail(404, { error: 'User not found.' }); + const err = await users.update(updatedUser); + return err ? err.toFail() : SUCCESS; }, - delete: async ({ locals: { guard }, params }) => { - if (guard.requiresAdmin().isFailed()) { - return fail(403); - } - db.data.users = db.data.users.filter((u) => u.id !== params.id); - db.write(); + delete: async ({ locals: { guard }, params }) => { + if (guard.requiresAdmin().isFailed()) return FORBIDDEN; + + const err = await users.delete(params.id ?? ''); + return err ? err.toFail() : SUCCESS; }, } satisfies Actions; diff --git a/src/routes/dash/users/[id]/+page.svelte b/src/routes/dash/users/[id]/+page.svelte index cbefb9d..df722d9 100644 --- a/src/routes/dash/users/[id]/+page.svelte +++ b/src/routes/dash/users/[id]/+page.svelte @@ -17,6 +17,7 @@ }); +

{form?.error}

u.name === username); + const user = await users.getByName(username); if (!user || !bcrypt.compareSync(password, user.password)) { - return { + return fail(403, { error: 'INVALID_CREDENTIALS', - }; + }); } cookies.set(