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>
This commit is contained in:
+69
-6
@@ -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,17 +120,70 @@ 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<string, string> = {
|
||||
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 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),
|
||||
'Content-Type': webMimeFor(candidate),
|
||||
'Cache-Control': candidate.includes('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
@@ -139,19 +192,29 @@ app.get('*', async (c) => {
|
||||
} 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 {
|
||||
return c.body(createReadStream(join(webDir, 'index.html')) as any, 200, {
|
||||
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 auf die Auskunft unten */
|
||||
}
|
||||
|
||||
return c.text(
|
||||
'Die Oberflaeche ist nicht gebaut. Der Dienst laeuft trotzdem — ' +
|
||||
'die API steht unter /api.',
|
||||
200,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------- Start
|
||||
|
||||
+29
-11
@@ -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<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 { 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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user