diff --git a/server/src/index.ts b/server/src/index.ts index 0ca9350..ea78364 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -18,7 +18,7 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { join } from 'node:path' +import { join, resolve, sep } from 'node:path' import { serve } from '@hono/node-server' import { Hono } from 'hono' import { logger } from 'hono/logger' @@ -120,38 +120,101 @@ app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3))) // index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router. // fileURLToPath und nicht .pathname: unter Windows liefert letzteres // "/C:/..." und jedes stat() darauf schlaegt fehl. -const webDir = fileURLToPath(new URL('../web/', import.meta.url)) +const webDir = resolve(fileURLToPath(new URL('../web/', import.meta.url))) + +/** Der absolute Pfad zu einer Datei der Oberflaeche — oder null, wenn er aus + * dem Ordner herauszeigt. + * + * Dieselbe Guertel-und-Hosentraeger-Regel wie in storage.ts, und aus demselben + * Grund: der Pfad kommt vom Aufrufer. Ohne die Pruefung hat + * `GET /..%5Cpackage.json` unter Windows die Datei ausgeliefert — Hono reicht + * %5C unveraendert durch, und path.join behandelt den Backslash dort als + * Trenner. Unter Linux traegt derselbe Angriff nicht, aber das ist Glueck und + * keine Abwehr. */ +function webFile(candidate: string): string | null { + const target = resolve(webDir, decodeSafely(candidate).replace(/^[/\\]+/, '')) + if (target !== webDir && !target.startsWith(webDir + sep)) return null + return target +} + +/** %5C und Konsorten aufloesen, bevor geprueft wird — sonst prueft die + * Eindaemmung eine andere Zeichenkette als die, die spaeter im Dateisystem + * landet. Ein kaputtes Prozentzeichen ist kein Grund abzustuerzen. */ +function decodeSafely(raw: string): string { + try { + return decodeURIComponent(raw) + } catch { + return raw + } +} + +/** Die Dateitypen der Oberflaeche — bewusst eine EIGENE Tabelle und nicht die + * aus storage.ts. + * + * Dort fehlen html, css und js mit gutem Grund: eine hochgeladene .html wird + * unter dem Dateihost als application/octet-stream ausgeliefert und damit + * heruntergeladen statt ausgefuehrt. Stuende html in der geteilten Tabelle, + * koennte jeder mit einem Upload-Token eine Seite unter fivecdn.d4rkst3r.de + * veroeffentlichen. Die gebaute Oberflaeche liegt dagegen im Abbild und kommt + * von uns — hier ist der richtige Typ noetig, sonst laedt der Browser weder + * Stylesheet noch Modul. */ +const WEB_MIME: Record = { + html: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', + js: 'text/javascript; charset=utf-8', + mjs: 'text/javascript; charset=utf-8', + map: 'application/json; charset=utf-8', + ico: 'image/x-icon', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', +} + +const webMimeFor = (path: string) => + WEB_MIME[path.split('.').pop()?.toLowerCase() ?? ''] ?? mimeFor(path) + app.get('*', async (c) => { if (c.req.path.startsWith('/api/')) return c.json({ error: 'unbekannt' }, 404) const candidate = c.req.path === '/' ? '/index.html' : c.req.path + const target = webFile(candidate) + if (target) { + try { + const info = await stat(target) + if (info.isFile()) { + return c.body(createReadStream(target) as any, 200, { + 'Content-Type': webMimeFor(candidate), + 'Cache-Control': candidate.includes('/assets/') + ? 'public, max-age=31536000, immutable' + : 'no-cache', + }) + } + } catch { + /* faellt unten auf index.html */ + } + } + + // Erst nachsehen, dann streamen. createReadStream meldet eine fehlende + // Datei ASYNCHRON ueber ein 'error'-Ereignis — ein try/catch darum faengt + // nichts. Vorher stand hier genau das, und das Ergebnis war bei ungebauter + // Oberflaeche ein leerer 200 samt ENOENT im Log statt der Auskunft unten. + const index = join(webDir, 'index.html') try { - const target = join(webDir, candidate.replace(/^\//, '')) - const info = await stat(target) - if (info.isFile()) { - return c.body(createReadStream(target) as any, 200, { - 'Content-Type': mimeFor(candidate), - 'Cache-Control': candidate.includes('/assets/') - ? 'public, max-age=31536000, immutable' - : 'no-cache', + if ((await stat(index)).isFile()) { + return c.body(createReadStream(index) as any, 200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache', }) } } catch { - /* faellt unten auf index.html */ + /* faellt auf die Auskunft unten */ } - try { - return c.body(createReadStream(join(webDir, 'index.html')) as any, 200, { - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-cache', - }) - } catch { - return c.text( - 'Die Oberflaeche ist nicht gebaut. Der Dienst laeuft trotzdem — ' + - 'die API steht unter /api.', - 200, - ) - } + return c.text( + 'Die Oberflaeche ist nicht gebaut. Der Dienst laeuft trotzdem — ' + + 'die API steht unter /api.', + 200, + ) }) // --------------------------------------------------------------------- Start diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 8c3423a..1e776f0 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -6,6 +6,7 @@ // 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' @@ -25,13 +26,27 @@ 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 { username, password } = await c.req.json<{ - username?: string - password?: string - }>() + 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) @@ -81,10 +96,9 @@ dashRoutes.use('*', async (c, next) => { }) dashRoutes.post('/auth/password', async (c) => { - const { current, next: fresh } = await c.req.json<{ - current?: string - next?: string - }>() + 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) @@ -145,7 +159,9 @@ dashRoutes.delete('/media/:id', async (c) => { /** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */ dashRoutes.post('/media/delete', async (c) => { - const { ids } = await c.req.json<{ ids?: number[] }>() + 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) } @@ -197,11 +213,13 @@ dashRoutes.get('/tokens', (c) => { }) dashRoutes.post('/tokens', async (c) => { - const { name, prefix, canDelete } = await c.req.json<{ + 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) diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 2363f64..1e06632 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -20,7 +20,7 @@ // dem NUI, ist also schon Base64 — der Umweg ist damit keiner. import { Hono } from 'hono' -import type { Context } from 'hono' +import type { Context, Next } from 'hono' import { config } from '../config.js' import { db, now, type Media, type Token } from '../db.js' import { tokenAllows, tokenFromHeader } from '../auth.js' @@ -40,14 +40,23 @@ type Vars = { token: Token } export const uploadRoutes = new Hono<{ Variables: Vars }>() -uploadRoutes.use('*', async (c, next) => { +// Die Token-Pruefung haengt an den drei eigenen Pfaden und NICHT an '*'. +// +// Mit '*' galt sie fuer alles unterhalb des Einhaengepunkts — auch fuer +// /api/dash/*, das daneben liegt. Das Dashboard bekam dann "Token fehlt oder +// ist unbekannt" auf die Anmeldung, obwohl es nie einen Token haben kann. +const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => { const token = tokenFromHeader(c.req.header('authorization')) if (!token) { return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401) } c.set('token', token) await next() -}) +} + +uploadRoutes.use('/upload', requireToken) +uploadRoutes.use('/media/*', requireToken) +uploadRoutes.use('/exists/*', requireToken) /** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */ async function readBody( @@ -179,8 +188,14 @@ uploadRoutes.post('/upload', async (c) => { }) }) -/** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID. */ -uploadRoutes.delete('/media/*', async (c) => { +/** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID. + * + * `:pfad{.+}` und nicht `*`: ein Sternchen wird von Hono nicht als Parameter + * erfasst, und `c.req.path` traegt den Einhaengepunkt mit — aus + * DELETE /api/media/vehicles/adder.png wurde damit der Pfad + * "api/media/vehicles/adder.png". Der Parameter kommt bereits dekodiert, + * ein zweites decodeURIComponent waere eine Dekodierung zu viel. */ +uploadRoutes.delete('/media/:pfad{.+}', async (c) => { const token = c.get('token') if (!token.can_delete) { return c.json({ error: 'dieser Token darf nicht loeschen' }, 403) @@ -188,7 +203,7 @@ uploadRoutes.delete('/media/*', async (c) => { let path: string try { - path = checkPath(decodeURIComponent(c.req.path.replace(/^\/media\//, ''))) + path = checkPath(c.req.param('pfad')) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err @@ -206,10 +221,10 @@ uploadRoutes.delete('/media/*', async (c) => { /** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende" * beantworten kann, ohne 900 Bilder hochzuladen. */ -uploadRoutes.get('/exists/*', (c) => { +uploadRoutes.get('/exists/:pfad{.+}', (c) => { let path: string try { - path = checkPath(decodeURIComponent(c.req.path.replace(/^\/exists\//, ''))) + path = checkPath(c.req.param('pfad')) } catch { return c.json({ exists: false }, 200) }