Files
d4rk_media/server/src/routes/dash.ts
T
D4rkst3randClaude Opus 5 cfd0f0747a fix: sechs Befunde aus dem ersten Start des Servers
Das Grundgeruest war typgeprueft, aber nie gelaufen. Beim ersten Start
haben sich sechs Dinge gezeigt, alle nachgemessen statt vermutet:

1. Die Token-Pruefung hing an '*' und galt damit auch fuer /api/dash/*,
   das daneben liegt. Das Dashboard bekam "Token fehlt oder ist
   unbekannt" auf die Anmeldung, obwohl es nie einen Token haben kann.
   Sie haengt jetzt an den drei eigenen Pfaden.

2. DELETE /api/media/... und GET /api/exists/... sahen am Ziel vorbei.
   c.req.path traegt den Einhaengepunkt mit, das replace(/^\/media\//)
   schnitt ihn nicht weg -- aus vehicles/adder.png wurde
   api/media/vehicles/adder.png. Loeschen fand nie etwas, exists meldete
   immer false. Jetzt :pfad{.+}; Hono liefert den Parameter fertig
   dekodiert (an einer Probe gemessen), ein zweites decodeURIComponent
   waere eine Dekodierung zu viel.

3. Verzeichnisdurchstieg in der SPA-Rueckfallroute: GET /..%5Cpackage.json
   hat unter Windows die Datei ausgeliefert. Hono reicht %5C durch,
   path.join behandelt den Backslash dort als Trenner, und eine
   Eindaemmung gab es nicht. Unter Linux traegt genau dieser Angriff
   nicht -- Glueck, keine Abwehr. Jetzt dieselbe resolve-Pruefung wie in
   storage.ts.

4. Die Auskunft "Oberflaeche ist nicht gebaut" war unerreichbar.
   createReadStream meldet eine fehlende Datei asynchron, das try/catch
   darum fing nichts. Ergebnis war ein leerer 200 samt ENOENT im Log.

5. CSS und JS kamen als application/octet-stream -- die MIME-Tabelle
   kennt nur Medientypen, das Dashboard haette weder Stylesheet noch
   Modul geladen. Die Oberflaeche bekommt eine eigene Tabelle: in der
   geteilten fehlt html mit Absicht, sonst koennte jeder mit einem
   Upload-Token eine Seite unter fivecdn.d4rkst3r.de veroeffentlichen.

6. Kaputtes JSON endete als nackter "Internal Server Error".
   Ausgerechnet das -- ein 500 ohne ein Wort dazu ist der Fehler, wegen
   dem wir hier neu bauen. Jetzt 400 mit Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:39:33 +02:00

266 lines
8.9 KiB
TypeScript

// 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<T>(c: Context<any>): Promise<T | null> {
try {
return await c.req.json<T>()
} 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<Token, 'hash'>[]
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 })
})