feat: eigener Medien-Dienst statt Fivemanage Lite — Server-Grundgeruest
Ein Container statt vier: Hono, SQLite, Dateien auf einer Platte. Kein
PostgreSQL, kein MinIO, kein ClickHouse. Das ist nicht die kleine Loesung,
sondern die, die man nicht pflegt — eine Sicherung ist ein cp -a.
Der Ausloeser war nicht der Funktionsumfang von Lite, sondern dessen
Zustand. web/src/utils/http-util.ts wirft jeden Fehler als new Error(...),
die Hooks pruefen auf "instanceof ApiError" — damit wird jeder Fehlschlag
lautlos verschluckt. "Create organization" meldet weder Erfolg noch
Misserfolg; das Ergebnis waren 19 gleichnamige Organisationen, die sich auch
nicht loeschen lassen, weil die Route fehlt. Von allem, was Lite darueber
hinaus kann, haben wir nichts gebraucht.
Zwei Hostnamen, ein Prozess: unter FILES_HOST gibt es ausschliesslich
Dateien — kein Dashboard, keine API, nichts anzumelden. Das haelt die
oeffentliche Adresse frei von Angriffsflaeche und die URLs huebsch, ohne
/f/-Praefix. Die Vorlage im Handy bleibt {model}.webp.
Der Pfad ist der Schluessel: X-Path legt die Datei genau dort ab, wo der
Aufrufer sie erwartet. Bei Lite haetten wir die zurueckgegebenen Adressen in
einer urls.json mitschleppen muessen — vorhersagbare URLs sind fuer das
Fotostudio die ganze Voraussetzung.
storage.ts bereinigt Pfade NICHT, es lehnt sie ab. Ein zurechtgebogener Pfad
legt die Datei unter einem anderen Namen ab als dem erwarteten, und das faellt
erst auf, wenn das Bild fehlt.
Typprueft. Noch nicht gestartet, noch nicht getestet — das ist der naechste
Schritt und steht in ROADMAP.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// Dateien auf der Platte.
|
||||
//
|
||||
// Der Pfad kommt vom Aufrufer — beim Fotostudio ist das genau der Punkt,
|
||||
// vehicles/adder.webp soll vehicles/adder.webp bleiben. Damit ist er auch die
|
||||
// gefaehrlichste Eingabe, die dieser Dienst kennt: ein durchgelassenes ".."
|
||||
// schreibt irgendwohin auf dem Wirt.
|
||||
//
|
||||
// Deshalb wird hier nicht bereinigt, sondern ABGELEHNT. Ein Pfad, der nicht
|
||||
// den Regeln entspricht, wird nicht zurechtgebogen — sonst landet eine Datei
|
||||
// unter einem anderen Namen als dem, den der Aufrufer erwartet, und niemand
|
||||
// merkt es, bis das Bild fehlt.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { config } from './config.js'
|
||||
|
||||
/** Ein Segment: beginnt mit Buchstabe oder Ziffer, danach auch Punkt, Strich
|
||||
* und Unterstrich. Damit sind "..", versteckte Dateien und alles mit
|
||||
* Schraegstrich, Backslash oder Steuerzeichen von vornherein draussen. */
|
||||
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
||||
|
||||
export const MAX_PATH_LENGTH = 200
|
||||
export const MAX_DEPTH = 8
|
||||
|
||||
export class PathError extends Error {}
|
||||
|
||||
/** Prueft den Pfad und gibt ihn normalisiert zurueck. Wirft PathError. */
|
||||
export function checkPath(raw: string): string {
|
||||
const path = raw.trim().replace(/^\/+/, '')
|
||||
|
||||
if (!path) throw new PathError('Pfad ist leer')
|
||||
if (path.length > MAX_PATH_LENGTH) {
|
||||
throw new PathError(`Pfad laenger als ${MAX_PATH_LENGTH} Zeichen`)
|
||||
}
|
||||
|
||||
const segments = path.split('/')
|
||||
if (segments.length > MAX_DEPTH) {
|
||||
throw new PathError(`mehr als ${MAX_DEPTH} Ebenen`)
|
||||
}
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!SEGMENT.test(segment)) {
|
||||
throw new PathError(
|
||||
`"${segment}" ist als Pfadteil nicht erlaubt ` +
|
||||
'(Buchstaben, Ziffern, Punkt, Strich, Unterstrich; ' +
|
||||
'nicht mit Punkt beginnend)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Guertel und Hosentraeger: selbst wenn oben je eine Luecke waere, muss
|
||||
// das Ergebnis unterhalb des Datenordners liegen.
|
||||
const absolute = resolve(config.filesDir, path)
|
||||
if (absolute !== config.filesDir && !absolute.startsWith(config.filesDir + sep)) {
|
||||
throw new PathError('Pfad zeigt aus dem Datenordner heraus')
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
webp: 'image/webp',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
avif: 'image/avif',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
pdf: 'application/pdf',
|
||||
json: 'application/json',
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
}
|
||||
|
||||
export function mimeFor(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase() ?? ''
|
||||
return MIME[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function extensionFor(mime: string): string {
|
||||
const base = mime.split(';')[0]?.trim().toLowerCase()
|
||||
for (const [ext, value] of Object.entries(MIME)) {
|
||||
if (value.split(';')[0] === base) return ext
|
||||
}
|
||||
return 'bin'
|
||||
}
|
||||
|
||||
export const sha256 = (data: Buffer) => createHash('sha256').update(data).digest('hex')
|
||||
|
||||
export const absolutePath = (path: string) => join(config.filesDir, path)
|
||||
|
||||
/** Schreibt die Datei.
|
||||
*
|
||||
* Erst daneben, dann umbenennen: ein Ueberschreiben ist damit entweder ganz
|
||||
* passiert oder gar nicht. Sonst koennte ein abgebrochener Upload das alte
|
||||
* Bild durch ein halbes ersetzen — und halbe Bilder sehen im Handy aus wie
|
||||
* ein Fehler im Handy. */
|
||||
export async function writeFileAtomic(path: string, data: Buffer): Promise<void> {
|
||||
const target = absolutePath(path)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
|
||||
const temp = `${target}.${process.pid}.${Date.now()}.tmp`
|
||||
try {
|
||||
await writeFile(temp, data)
|
||||
await rename(temp, target)
|
||||
} catch (err) {
|
||||
await rm(temp, { force: true }).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFile(path: string): Promise<void> {
|
||||
await rm(absolutePath(path), { force: true })
|
||||
}
|
||||
|
||||
export async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(absolutePath(path))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Die oeffentliche Adresse einer Datei. */
|
||||
export const publicUrlFor = (path: string) => `${config.publicUrl}/${path}`
|
||||
Reference in New Issue
Block a user