SICHERUNG (tools/sichern.ps1, taeglich 04:30 als geplante Aufgabe). Die Datenbank wird nicht kopiert, sondern ueber SQLites eigene Sicherungsschnittstelle herausgeholt (server/src/backup.ts): im WAL-Modus liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu kopieren ist nicht sicher, wenn waehrenddessen geschrieben wird. Die Bilder kommen aus einem NUR LESEND eingehaengten Volume dazu. Und sie prueft sich selbst: auspacken, Datenbank oeffnen, Medien/Token/ Benutzer zaehlen, mit dem laufenden Dienst vergleichen, sonst Fehler. Einmal wirklich zurueckgespielt -- leeres Volume, zweiter Dienst, Anmeldung mit dem echten Passwort, Bild abgerufen, Byte fuer Byte identisch. Eine Sicherung, die nie zurueckgespielt wurde, ist eine Hoffnung. ANMELDEBREMSE. Das Formular steht oeffentlich, und scrypt macht einen Versuch teuer -- aber teuer ist nicht selten. Fuenf freie Versuche je Adresse, dann Sperre ab 30 s mit Verdopplung bis 15 min, 429 samt Retry-After und einem Text, der sagt wie lange. Erfolg setzt zurueck. Nur im Speicher: wer sich aussperrt, startet den Container neu. Durchgemessen bis zur Erholung. VORSCHAUBILDER. sharp erzeugt beim Upload eine 320er WebP-Fassung unter /data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen: 131502 -> 13078 Bytes, Faktor 10; eine Galerieseite faellt von 7,5 MB auf 766 KB. KEINE Spalte in der Datenbank -- ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie faellt bei 404 aufs Vollbild zurueck. Eine zweite Wahrheit, die auseinander- laufen kann, gibt es damit gar nicht erst. Ein Knopf zieht Fehlendes nach und nennt Zahlen statt "fertig". SCHRITT 5. Die Fivemanage-Dateien liegen in legacy/ mit Erklaerung; unsere docker-compose.media.yml heisst jetzt docker-compose.yml. Drei Volumes und drei Abbilder geloescht, rund 670 MB -- nachgezaehlt war vorher, dass kein Bild darin lag. Nebenbefund: `npm i sharp` scheitert auf diesem Rechner nicht an sharp, sondern an better-sqlite3 -- der Host laeuft auf Node 24 (ABI 137), node-gyp uebernimmt und findet keine Bauwerkzeuge. Genau die Falle aus dem Dockerfile. Eingetragen wurde mit --package-lock-only, gebaut wird im Abbild auf Node 22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
8.4 KiB
TypeScript
248 lines
8.4 KiB
TypeScript
// Die Schnittstelle fuer Skripte: hochladen und wieder loeschen.
|
|
//
|
|
// Das ist der Teil, den der FiveM-Server anspricht — deshalb ist er stur
|
|
// einfach gehalten. Ein Aufruf, ein Token, eine Antwort mit der fertigen URL.
|
|
//
|
|
// POST /api/upload
|
|
// Authorization: Bearer d4rk_...
|
|
// X-Path: vehicles/adder.webp
|
|
// X-Encoding: base64
|
|
// <Rumpf>
|
|
//
|
|
// DREI FORMEN FUER DEN RUMPF, und der Grund fuer die dritte ist FiveM:
|
|
//
|
|
// multipart/form-data Feld "file" — so laedt das Dashboard hoch
|
|
// X-Encoding: base64 Base64-Text — so laedt Lua hoch
|
|
// sonst rohe Bytes
|
|
//
|
|
// PerformHttpRequest schickt einen Lua-String; rohe Bytes ueberstehen den Weg
|
|
// nicht zuverlaessig. Im Fotostudio kommt das Bild ohnehin als Data-URL aus
|
|
// dem NUI, ist also schon Base64 — der Umweg ist damit keiner.
|
|
|
|
import { Hono } 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'
|
|
import {
|
|
PathError,
|
|
checkPath,
|
|
deleteFile,
|
|
extensionFor,
|
|
fileExists,
|
|
kannVorschau,
|
|
mimeFor,
|
|
publicUrlFor,
|
|
sha256,
|
|
writeFileAtomic,
|
|
writeThumb,
|
|
} from '../storage.js'
|
|
|
|
type Vars = { token: Token }
|
|
|
|
export const uploadRoutes = new Hono<{ Variables: Vars }>()
|
|
|
|
// 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(
|
|
c: Context<{ Variables: Vars }>,
|
|
): Promise<{ data: Buffer; path?: string; filename?: string }> {
|
|
const contentType = c.req.header('content-type') ?? ''
|
|
|
|
if (contentType.includes('multipart/form-data')) {
|
|
const form = await c.req.parseBody()
|
|
const file = form['file']
|
|
if (!(file instanceof File)) {
|
|
throw new Error('Feld "file" fehlt im Formular')
|
|
}
|
|
return {
|
|
data: Buffer.from(await file.arrayBuffer()),
|
|
path: typeof form['path'] === 'string' ? form['path'] : undefined,
|
|
filename: file.name,
|
|
}
|
|
}
|
|
|
|
const raw = Buffer.from(await c.req.arrayBuffer())
|
|
|
|
if (/base64/i.test(c.req.header('x-encoding') ?? '')) {
|
|
// Data-URLs kommen mit Vorspann ("data:image/webp;base64,"). Den
|
|
// abzuschneiden ist freundlicher, als den Aufrufer dazu zu zwingen.
|
|
const text = raw.toString('utf8').replace(/^data:[^,]*,/, '')
|
|
return { data: Buffer.from(text, 'base64') }
|
|
}
|
|
|
|
return { data: raw }
|
|
}
|
|
|
|
uploadRoutes.post('/upload', async (c) => {
|
|
const token = c.get('token')
|
|
|
|
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
|
|
// abgewiesen wird, BEVOR er im Speicher liegt.
|
|
const announced = Number(c.req.header('content-length') ?? 0)
|
|
if (announced > config.maxUploadBytes) {
|
|
return c.json(
|
|
{ error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` },
|
|
413,
|
|
)
|
|
}
|
|
|
|
let body: Awaited<ReturnType<typeof readBody>>
|
|
try {
|
|
body = await readBody(c)
|
|
} catch (err) {
|
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
|
|
}
|
|
|
|
const { data } = body
|
|
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
|
|
if (data.length > config.maxUploadBytes) {
|
|
return c.json(
|
|
{ error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` },
|
|
413,
|
|
)
|
|
}
|
|
|
|
const digest = sha256(data)
|
|
|
|
// Der Pfad: ausdruecklich gewuenscht, oder aus dem Hash gebaut. Der
|
|
// gewuenschte hat Vorrang und wird NICHT zurechtgebogen.
|
|
const wanted = c.req.header('x-path') ?? body.path ?? body.filename
|
|
let path: string
|
|
try {
|
|
path = wanted
|
|
? checkPath(wanted)
|
|
: checkPath(
|
|
`uploads/${digest.slice(0, 2)}/${digest}.${extensionFor(
|
|
c.req.header('x-mime') ?? 'application/octet-stream',
|
|
)}`,
|
|
)
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
|
|
if (!tokenAllows(token, path)) {
|
|
return c.json(
|
|
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` },
|
|
403,
|
|
)
|
|
}
|
|
|
|
const existing = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
|
|
| Media
|
|
| undefined
|
|
|
|
// Ueberschreiben ist der Normalfall: ein neu aufgenommenes Fahrzeugbild
|
|
// soll das alte ersetzen, sonst braeuchte jeder zweite Lauf einen anderen
|
|
// Namen. Wer das nicht will, sagt es.
|
|
const overwrite = (c.req.header('x-overwrite') ?? 'true').toLowerCase() !== 'false'
|
|
if (existing && !overwrite) {
|
|
return c.json({ error: 'Pfad ist belegt', path, url: publicUrlFor(path) }, 409)
|
|
}
|
|
|
|
const mime = c.req.header('x-mime') ?? mimeFor(path)
|
|
|
|
try {
|
|
await writeFileAtomic(path, data)
|
|
} catch (err) {
|
|
console.error('[upload] Schreiben fehlgeschlagen:', err)
|
|
return c.json({ error: 'Schreiben fehlgeschlagen' }, 500)
|
|
}
|
|
|
|
// Die Vorschau gleich mit. Bewusst NACH dem eigentlichen Schreiben und mit
|
|
// eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der
|
|
// Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck.
|
|
if (kannVorschau(mime)) await writeThumb(path, data)
|
|
|
|
const timestamp = now()
|
|
if (existing) {
|
|
db.prepare(
|
|
`UPDATE media SET size = ?, sha256 = ?, mime = ?, token_id = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
).run(data.length, digest, mime, token.id, timestamp, existing.id)
|
|
} else {
|
|
db.prepare(
|
|
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(path, data.length, digest, mime, token.id, timestamp, timestamp)
|
|
}
|
|
|
|
return c.json({
|
|
url: publicUrlFor(path),
|
|
path,
|
|
size: data.length,
|
|
sha256: digest,
|
|
mime,
|
|
replaced: Boolean(existing),
|
|
})
|
|
})
|
|
|
|
/** 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)
|
|
}
|
|
|
|
let path: string
|
|
try {
|
|
path = checkPath(c.req.param('pfad'))
|
|
} catch (err) {
|
|
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
|
throw err
|
|
}
|
|
|
|
if (!tokenAllows(token, path)) {
|
|
return c.json({ error: 'ausserhalb des erlaubten Bereichs' }, 403)
|
|
}
|
|
if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404)
|
|
|
|
await deleteFile(path)
|
|
db.prepare('DELETE FROM media WHERE path = ?').run(path)
|
|
return c.json({ deleted: path })
|
|
})
|
|
|
|
/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende"
|
|
* beantworten kann, ohne 900 Bilder hochzuladen. */
|
|
uploadRoutes.get('/exists/:pfad{.+}', (c) => {
|
|
let path: string
|
|
try {
|
|
path = checkPath(c.req.param('pfad'))
|
|
} catch {
|
|
return c.json({ exists: false }, 200)
|
|
}
|
|
const row = db.prepare('SELECT size, sha256, updated_at FROM media WHERE path = ?').get(
|
|
path,
|
|
) as Pick<Media, 'size' | 'sha256' | 'updated_at'> | undefined
|
|
|
|
return c.json(
|
|
row
|
|
? { exists: true, path, url: publicUrlFor(path), ...row }
|
|
: { exists: false, path },
|
|
)
|
|
})
|