// Die Schnittstelle fuer das Dashboard. // // Eine Regel zieht sich durch alles hier: JEDE Antwort sagt, was passiert ist // — auch die erfolgreiche. Bei Fivemanage hat genau das gefehlt, und das // Ergebnis waren neunzehn gleichnamige Organisationen, weil ein Knopf ohne // Rueckmeldung wie ein kaputter Knopf aussieht. import { Hono } from 'hono' import type { Context } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { config } from '../config.js' import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js' import { SESSION_COOKIE, createSession, destroySession, generateToken, hashPassword, tokenHash, userForSession, verifyPassword, } from '../auth.js' import { checkPath, deleteFile, publicUrlFor, PathError } from '../storage.js' type Vars = { user: User } export const dashRoutes = new Hono<{ Variables: Vars }>() /** Den JSON-Rumpf lesen — oder null, wenn keiner ankam. * * c.req.json() wirft bei kaputtem JSON, und Hono macht daraus einen nackten * "Internal Server Error" ohne ein Wort dazu. Genau diese Sorte Antwort ist * der Grund, warum wir hier neu bauen. */ async function jsonBody(c: Context): Promise { try { return await c.req.json() } catch { return null } } const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const // ---------------------------------------------------------------- Anmeldung dashRoutes.post('/auth/login', async (c) => { const body = await jsonBody<{ username?: string; password?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) const { username, password } = body if (!username || !password) { return c.json({ error: 'Benutzername und Passwort noetig' }, 400) } const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as | User | undefined // Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches // Passwort": alles andere verraet, welche Namen es gibt. if (!user || !verifyPassword(password, user.password_hash)) { return c.json({ error: 'Benutzername oder Passwort stimmt nicht' }, 401) } const session = createSession(user.id) setCookie(c, SESSION_COOKIE, session.id, { httpOnly: true, secure: !config.dev, sameSite: 'Lax', path: '/', expires: new Date(session.expiresAt), }) return c.json({ user: { id: user.id, username: user.username } }) }) dashRoutes.post('/auth/logout', (c) => { const sid = getCookie(c, SESSION_COOKIE) if (sid) destroySession(sid) deleteCookie(c, SESSION_COOKIE, { path: '/' }) return c.json({ ok: true }) }) dashRoutes.get('/auth/me', (c) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) if (!user) return c.json({ error: 'nicht angemeldet' }, 401) return c.json({ user: { id: user.id, username: user.username } }) }) // Ab hier gilt: angemeldet oder nichts. dashRoutes.use('*', async (c, next) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) if (!user) return c.json({ error: 'nicht angemeldet' }, 401) c.set('user', user) await next() }) dashRoutes.post('/auth/password', async (c) => { const body = await jsonBody<{ current?: string; next?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) const { current, next: fresh } = body const user = c.get('user') if (!current || !fresh) return c.json({ error: 'beide Passwoerter noetig' }, 400) if (fresh.length < 8) return c.json({ error: 'mindestens 8 Zeichen' }, 400) if (!verifyPassword(current, user.password_hash)) { return c.json({ error: 'aktuelles Passwort stimmt nicht' }, 401) } db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run( hashPassword(fresh), user.id, ) // Alle anderen Sitzungen beenden — ein Passwortwechsel, der alte // Anmeldungen weiterlaufen laesst, ist keiner. db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run( user.id, getCookie(c, SESSION_COOKIE), ) return c.json({ ok: true }) }) // ------------------------------------------------------------------- Medien dashRoutes.get('/media', (c) => { const query = c.req.query('query')?.trim() ?? '' const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) const where = query ? 'WHERE path LIKE ?' : '' const params = query ? [`%${query}%`] : [] const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as { n: number } const rows = db .prepare( `SELECT * FROM media ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`, ) .all(...params, limit, offset) as Media[] return c.json({ total: total.n, limit, offset, items: rows.map((row) => ({ ...row, url: publicUrlFor(row.path) })), }) }) dashRoutes.delete('/media/:id', async (c) => { const id = Number(c.req.param('id')) const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined if (!row) return c.json({ error: 'nicht gefunden' }, 404) await deleteFile(row.path) db.prepare('DELETE FROM media WHERE id = ?').run(id) return c.json({ deleted: row.path }) }) /** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */ dashRoutes.post('/media/delete', async (c) => { const body = await jsonBody<{ ids?: number[] }>(c) if (!body) return c.json(KEIN_JSON, 400) const { ids } = body if (!Array.isArray(ids) || ids.length === 0) { return c.json({ error: 'keine Auswahl' }, 400) } const select = db.prepare('SELECT * FROM media WHERE id = ?') const remove = db.prepare('DELETE FROM media WHERE id = ?') const deleted: string[] = [] for (const id of ids) { const row = select.get(id) as Media | undefined if (!row) continue await deleteFile(row.path) remove.run(id) deleted.push(row.path) } return c.json({ deleted, count: deleted.length }) }) dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') .get() as { files: number; bytes: number } const tokens = db.prepare('SELECT COUNT(*) AS n FROM tokens').get() as { n: number } // Die groessten Ordner — die erste Frage bei "wo ist mein Platz hin". const folders = db .prepare( `SELECT CASE WHEN instr(path, '/') > 0 THEN substr(path, 1, instr(path, '/') - 1) ELSE '(Wurzel)' END AS folder, COUNT(*) AS files, SUM(size) AS bytes FROM media GROUP BY folder ORDER BY bytes DESC LIMIT 10`, ) .all() as { folder: string; files: number; bytes: number }[] return c.json({ ...media, tokens: tokens.n, folders, publicUrl: config.publicUrl }) }) // ------------------------------------------------------------------- Tokens dashRoutes.get('/tokens', (c) => { const rows = db .prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC') .all() as Omit[] return c.json({ items: rows }) }) dashRoutes.post('/tokens', async (c) => { const body = await jsonBody<{ name?: string prefix?: string canDelete?: boolean }>(c) if (!body) return c.json(KEIN_JSON, 400) const { name, prefix, canDelete } = body if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400) // Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft. let cleanPrefix = '' if (prefix?.trim()) { try { cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, '')) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err } } const token = generateToken() const info = db .prepare( `INSERT INTO tokens (name, hash, prefix, can_delete, created_at) VALUES (?, ?, ?, ?, ?)`, ) .run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now()) // Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in // der Datenbank, und auch wir koennen ihn nicht mehr zeigen. return c.json({ id: Number(info.lastInsertRowid), name: name.trim(), prefix: cleanPrefix, canDelete: Boolean(canDelete), token, }) }) dashRoutes.delete('/tokens/:id', (c) => { const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(Number(c.req.param('id'))) if (info.changes === 0) return c.json({ error: 'nicht gefunden' }, 404) return c.json({ ok: true }) }) dashRoutes.post('/maintenance/prune-sessions', (c) => { pruneSessions() return c.json({ ok: true }) })