Files
d4rk_media/server/src/storage.ts
T
D4rkst3randClaude Opus 5 a373b962d0 Beim Hochladen bleibt die Schreibweise -- sie war eine stille Verstuemmelung
Der Betreiber setzte in FiveM `inventory:imagepath` auf das Portal, und die
meisten Item-Icons blieben leer. Gemessen:

    WEAPON_COMBATPISTOL.png  -> 404
    weapon_combatpistol.png  -> 200

ox_inventory fragt Item-Bilder unter dem EXAKTEN Item-Namen ab; seine Waffen
heissen WEAPON_*. Der Dienst laeuft auf Linux, dort ist die Schreibweise ein
Unterschied. Zur Gegenprobe die mitgelieferten Bilder von ox_inventory selbst:
113 von 198 sind grossgeschrieben.

Die Ursache lag hier: `zaehmen()` machte beim Hochladen per Fenster jeden
Namen klein -- "vorhersagbar". Fuer die ZEICHEN war das richtig (Leerzeichen,
Klammern, Umlaute haben in einer Adresse nichts verloren), fuer die
SCHREIBWEISE war es nie noetig: checkPath erlaubt [A-Za-z0-9] seit jeher, und
in einer URL ist ein Grossbuchstabe kein Problem.

Also bleibt die Schreibweise jetzt stehen. Umlaute werden weiter ersetzt, nun
in beiden Formen -- sonst wuerde aus "Gruen.png" ein "gr-n.png", dieselbe
stille Verstuemmelung eine Ebene tiefer. Die ENDUNG bleibt klein: daran haengt
nur der Dateityp, und ".PNG" waere eine Adresse, die niemand so tippt.

Dazu 109 vorhandene Bilder umbenannt (ueber moveFile, damit Vorschau und WebP
mitwandern; jede Umbenennung steht im Ereignisprotokoll). Vorher: 0 Kollisionen
im Trockenlauf. Nachher gemessen: WEAPON_COMBATPISTOL 200, und 156 von 226
Items haben ein exakt passendes Bild statt 47.

EIGENER FEHLER, DER DAS VERZOEGERT HAT: mein erster Abgleich verglich beide
Seiten kleingeschrieben und hat damit genau den Unterschied wegnormalisiert,
auf den es ankam -- ich meldete 56 leere Felder statt 174. Eine Pruefung, die
das Falsche misst, ist schlimmer als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:48:36 +02:00

665 lines
28 KiB
TypeScript

// 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 { execFile } from 'node:child_process'
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)) {
// Die Meldung nennt die Regel so, wie sie ist. Vorher stand hier
// "nicht mit Punkt beginnend" — das ist nur die Haelfte: das erste
// Zeichen muss ein Buchstabe oder eine Ziffer sein, ein fuehrender
// Unterstrich faellt also ebenso durch. Aufgefallen an
// "_probe.webp", und das ist kein erfundener Fall: das Fotostudio
// legt seine Leerbilder als "_plate" ab.
throw new PathError(
`"${segment}" ist als Pfadteil nicht erlaubt ` +
'(erstes Zeichen: Buchstabe oder Ziffer; danach auch ' +
'Punkt, Strich, Unterstrich)',
)
}
}
// 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',
// Lesbares. ALLES ALS text/plain, auch das Markdown -- und das ist die
// Stelle, an der man sich vertun kann: eine eigene Art wie text/markdown
// brächte nichts, aber text/html brächte eine Luecke. Wer einen
// Upload-Token hat, koennte damit eine Seite unter unserem Namen
// veroeffentlichen; als text/plain zeigt der Browser den Quelltext an und
// fuehrt nichts aus.
//
// Deshalb stehen html, css und js hier NICHT, und das soll auch so
// bleiben. Die Oberflaeche hat dafuer ihre eigene Tabelle in index.ts.
md: 'text/plain; charset=utf-8',
markdown: 'text/plain; charset=utf-8',
csv: 'text/plain; charset=utf-8',
lua: 'text/plain; charset=utf-8',
xml: 'text/plain; charset=utf-8',
yml: 'text/plain; charset=utf-8',
yaml: 'text/plain; charset=utf-8',
toml: 'text/plain; charset=utf-8',
ini: 'text/plain; charset=utf-8',
cfg: 'text/plain; charset=utf-8',
sql: 'text/plain; charset=utf-8',
log: 'text/plain; charset=utf-8',
}
export function mimeFor(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? ''
return MIME[ext] ?? 'application/octet-stream'
}
/** Aus einem Dateinamen von der Platte einen zulaessigen Pfadteil machen.
*
* Der Weg vom Dashboard ist ein anderer als der vom Skript: dort schickt
* jemand `X-Path` und weiss genau, was er will. Hier zieht jemand
* "Mein Video (final) 2.MP4" ins Fenster, und `checkPath` wuerde das mit
* Recht ablehnen — Klammern, Leerzeichen, alles nicht erlaubt.
*
* Deshalb wird der Name hier zurechtgebogen, und zwar VORHERSAGBAR:
* Kleinbuchstaben, alles Fremde wird zum Strich, Striche werden
* zusammengefasst. Aus obigem wird "mein-video-final-2.mp4". Wer den
* Dateinamen exakt behalten will, benennt ihn vorher um — das ist ehrlicher,
* als so zu tun, als koennte jeder Name eine URL werden.
*
* `eindeutig` ist der Rettungsanker fuer den Fall, dass vom Namen NICHTS
* uebrig bleibt — bei "ЖУРНАЛ.txt" oder " .png". Ohne ihn hiessen beide
* "datei.txt" bzw. "datei.png", und die zweite Datei ueberschriebe die erste
* stillschweigend. Genau das ist beim Messen passiert. Ueblicherweise gibt
* der Aufrufer die ersten Stellen des SHA-256 mit: gleicher Inhalt ergibt
* denselben Namen, verschiedener Inhalt verschiedene. */
export function safeFilename(name: string, eindeutig?: string): string {
const punkt = name.lastIndexOf('.')
const roh = punkt > 0 ? name.slice(0, punkt) : name
const endung = punkt > 0 ? name.slice(punkt + 1) : ''
// DIE SCHREIBWEISE BLEIBT, seit dem 22.08.2026.
//
// Hier stand `.toLowerCase()` mit der Begruendung "vorhersagbar". Das war
// fuer die Zeichen richtig und fuer die GROSSSCHREIBUNG falsch, und es hat
// etwas kaputtgemacht, das niemand mit dem Hochladen in Verbindung brachte:
//
// ox_inventory fragt Item-Bilder unter dem EXAKTEN Item-Namen ab. Seine
// Waffen heissen WEAPON_COMBATPISTOL. Der Ordner lag auf einem
// Linux-Wirt, dort ist Gross- und Kleinschreibung ein Unterschied --
// also 404, also leeres Feld im Inventar. Gemessen am 22.08.2026:
// 109 von 226 Items betroffen, und ox_inventory liefert seine eigenen
// Bilder genau deshalb GROSS aus (113 von 198).
//
// Der Grund fuer das Zaehmen bleibt richtig -- Leerzeichen, Klammern und
// Umlaute haben in einer Adresse nichts verloren. Nur die Schreibweise
// gehoerte nie dazu: `checkPath` erlaubt `[A-Za-z0-9]` seit jeher, und in
// einer URL ist ein Grossbuchstabe kein Problem.
//
// Umlaute werden weiterhin ersetzt, jetzt in beiden Schreibweisen -- sonst
// wuerde aus "Gruen.png" ein "gr-n.png", und das waere dieselbe stille
// Verstuemmelung eine Ebene tiefer.
const zaehmen = (s: string) =>
s
.replace(/ä/g, 'ae')
.replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe')
.replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue')
.replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[^A-Za-z0-9]+/, '')
.replace(/[-.]+$/, '')
const stamm = zaehmen(roh)
// DIE ENDUNG BLEIBT KLEIN. Am Stamm haengt die Bedeutung -- an der Endung
// haengt nur der Dateityp, und die schreibt alle Welt klein. Wer
// "Bild.PNG" hochlaedt, will kein zweites Format, sondern ein PNG; ein
// ".PNG" waere eine Adresse, die niemand so tippt.
const ende = zaehmen(endung).toLowerCase()
// Bleibt nichts uebrig — etwa bei einem rein kyrillischen Namen —, ist das
// kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und
// sieht im Ergebnis, was daraus geworden ist.
const basis = stamm || (eindeutig ? `datei-${eindeutig}` : 'datei')
return ende ? `${basis}.${ende}` : basis
}
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)
/** Ein Bild auf eine Hoechstkante rechnen -- oder es lassen.
*
* WOFUER NICHT: als Schutz vor riesigen Bildern. Das waere gemessen falsch --
* ein 12000 x 12000 grosses PNG kostet 374 ms und 47 MB, weil libvips
* kachelweise arbeitet und nie das ganze Bild dekodiert.
*
* WOFUER DANN: Plattenplatz und Ladezeit. Ein Bildschirmfoto aus dem Spiel ist
* 3840 breit; auf einem Handy wird es auf 400 gezeigt. Die restlichen 3440
* liegen auf der Platte und gehen bei jedem Abruf ueber die Leitung.
*
* `withoutEnlargement` ist entscheidend: ein 200 Pixel breites Icon wird NICHT
* auf 2048 aufgeblasen. Sonst machte die Einstellung kleine Dateien groesser.
*
* Gibt den neuen Puffer zurueck, oder den alten, wenn nichts zu tun war. Wirft
* nie -- ein Bild, das sich nicht rechnen laesst, wird abgelegt wie es kam. */
export async function vielleichtKleiner(
data: Buffer,
mime: string,
maxKante: number | null,
): Promise<{ data: Buffer; verkleinert: false | { von: string; auf: string } }> {
if (!maxKante || maxKante < 16) return { data, verkleinert: false }
if (!kannVorschau(mime)) return { data, verkleinert: false }
try {
const { default: sharp } = await import('sharp')
const bild = sharp(data, { animated: false })
const vorher = await bild.metadata()
const groesste = Math.max(vorher.width ?? 0, vorher.height ?? 0)
if (!groesste || groesste <= maxKante) return { data, verkleinert: false }
// IM URSPRUNGSFORMAT bleiben: aus einem PNG ein WebP zu machen aendert
// die Endung nicht mit, und dann liegt eine .png da, die keine ist.
// Die sparsame Fassung entsteht ohnehin daneben.
const neu = await bild
.resize({ width: maxKante, height: maxKante, fit: 'inside', withoutEnlargement: true })
.toBuffer()
const nachher = await sharp(neu).metadata()
return {
data: neu,
verkleinert: {
von: `${vorher.width}x${vorher.height}`,
auf: `${nachher.width}x${nachher.height}`,
},
}
} catch (err) {
console.error(`[kleiner] ${err instanceof Error ? err.message : err}`)
return { data, verkleinert: false }
}
}
/** 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
}
}
// -------------------------------------------------------------- Vorschaubilder
//
// WARUM UEBERHAUPT. Ein freigestelltes Fahrzeugbild wiegt rund 124 KB. Eine
// Galerieseite zeigt 60 davon — das sind 7,2 MB, nur damit jemand sieht, WELCHE
// Bilder da sind. Bei 900 Fahrzeugen liegen 109 MB in der Ablage. Die Vorschau
// wiegt ein Fuenfzigstel und reicht fuer eine Kachel voellig.
//
// WO SIE LIEGEN. Unter /data/thumbs, im selben Baum wie die Bilder, mit
// ".webp" hinten dran. Also thumbs/vehicles/adder.webp.webp — haesslich, aber
// eindeutig: haengte man die Endung nicht an, kollidierten adder.png und
// adder.webp im Vorschauordner.
//
// KEINE SPALTE IN DER DATENBANK. Ob es eine Vorschau gibt, sagt das
// Dateisystem. Eine Spalte waere eine zweite Wahrheit, die auseinanderlaufen
// kann — und das Dashboard faellt ohnehin auf das Vollbild zurueck, wenn die
// Vorschau fehlt.
export const THUMB_BREITE = 320
export const thumbPath = (path: string) => join(config.dataDir, 'thumbs', `${path}.webp`)
export const kannVorschau = (mime: string) =>
/^image\/(png|jpeg|webp|avif|gif)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Eine Vorschau erzeugen. Schlaegt sie fehl, ist das KEIN Grund, den Upload
* scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der
* Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */
export async function writeThumb(path: string, data: Buffer): Promise<boolean> {
const ziel = thumbPath(path)
try {
// Der Import steht hier drin und nicht oben: sharp zieht eine native
// Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein
// Bild kommt.
const { default: sharp } = await import('sharp')
await mkdir(dirname(ziel), { recursive: true })
const bild = await sharp(data, { animated: false })
// withoutEnlargement: ein 64 Pixel breites Bild wird nicht auf 320
// aufgeblasen — das kostet Platz und sieht schlechter aus als das
// Original.
.resize({ width: THUMB_BREITE, withoutEnlargement: true })
.webp({ quality: 72 })
.toBuffer()
const temp = `${ziel}.${process.pid}.tmp`
await writeFile(temp, bild)
await rename(temp, ziel)
return true
} catch (err) {
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
return false
}
}
// ------------------------------------------- Vorschau fuer Video und Ton
//
// Bis hierhin bekam alles, was kein Bild ist, ein Fragezeichen auf die Kachel.
// Bei 3600 Bildern faellt das nicht auf; bei einer Handvoll Clips ist die
// Galerie dann eine Liste von Dateinamen.
//
// VIDEO: ein Standbild. sharp kann das nicht -- libvips dekodiert kein Video.
// Also ffmpeg, und zwar auf die Datei AUF DER PLATTE und nicht auf den Puffer:
// ein Container wie MP4 hat seine Inhaltsangabe je nach Kodierer am Ende, und
// ohne Springen findet ffmpeg sie nicht.
//
// TON: das eingebettete Titelbild, wenn eines da ist. Fuer ffmpeg ist das
// schlicht die Videospur der Datei -- derselbe Aufruf, andere Zeitmarke.
// Ist keines da, entsteht keine Vorschau, und die Kachel zeigt ein Notenzeichen
// statt eines leeren Rahmens.
export const kannBewegtVorschau = (mime: string) =>
/^(video|audio)\//.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Wie lange wir ffmpeg zugestehen.
*
* Ein beschaedigter Container kann einen Dekodierer beliebig lange
* beschaeftigen. 20 Sekunden reichen fuer ein Standbild aus jeder gesunden
* Datei -- was laenger braucht, bekommt eben keine Vorschau. */
const FFMPEG_MS = 20_000
/** Was in der Datei steckt: wie lang, und ob es ueberhaupt ein Bild gibt.
*
* BEIDES AUS EINEM AUFRUF. `ffmpeg -i datei` ohne Ausgabeziel bricht mit einem
* Fehler ab ("At least one output file must be specified") -- seinen Kopf hat
* es da aber laengst gedruckt, und darin steht alles, was wir brauchen. Der
* Rueckgabewert wird deshalb bewusst ignoriert und nur stderr gelesen. Kein
* `-f null -`: das dekodierte die ganze Datei, um am Ende dasselbe zu wissen.
*
* OHNE ffprobe, und das ist eine gemessene Entscheidung: das Werkzeug haette
* als statische Binaerdatei 135 MB ins Abbild gebracht. Gegengeprueft an
* beiden Dateien, die hier liegen, auf zwei Nachkommastellen identisch:
*
* ffprobe 29.767967 | ffmpeg Duration: 00:00:29.77
* ffprobe 645.642449 | ffmpeg Duration: 00:10:45.64
*
* Fuer eine Beschriftung wie "0:29" ist das genauer als noetig.
*
* `hatBild` WIRD AUS DER SPURENLISTE GELESEN und nicht aus einer
* Fehlermeldung. Genau daran ist der erste Anlauf gescheitert: er erkannte
* "keine Bildspur" am Wortlaut, und der ist zwischen den Fassungen anders --
*
* ffmpeg 5.1 "Stream map '0:v:0' matches no streams."
* ffmpeg 7.1 "Failed to set value '0:v:0' for option 'map'"
*
* -- worauf eine voellig gesunde MP3 als "1 fehlgeschlagen" gezaehlt wurde.
* Die Zeile "Stream #0:1: Video:" dagegen steht seit jeher so da. */
export type MedienInfo = { dauer: number | null; hatBild: boolean }
export async function medienInfo(path: string): Promise<MedienInfo> {
const { stderr } = await ffmpegRoh(['-i', absolutePath(path)])
// "Duration: 00:10:45.64," -- bei Stroemen ohne Laengenangabe "Duration: N/A".
const treffer = /Duration:\s*(\d+):(\d\d):(\d\d(?:\.\d+)?)/.exec(stderr)
const sekunden = treffer
? Number(treffer[1]) * 3600 + Number(treffer[2]) * 60 + Number(treffer[3])
: NaN
return {
dauer: Number.isFinite(sekunden) && sekunden > 0 ? sekunden : null,
// Ein eingebettetes Titelbild ist fuer ffmpeg eine Bildspur:
// "Stream #0:1: Video: mjpeg ... (attached pic)".
hatBild: /Stream #\d+:\d+[^\n]*:\s*Video:/.test(stderr),
}
}
/** Was bei einem Versuch herauskam.
*
* DREI Zustaende und nicht zwei, und das ist nachgemessen: eine MP3 OHNE
* eingebettetes Titelbild ist voellig in Ordnung -- sie hat schlicht keine
* Bildspur. Mit einem blossen true/false zaehlte der Nachzieh-Knopf sie als
* "1 fehlgeschlagen", und dann sucht jemand einen Fehler, den es nicht gibt.
* Genau so ist es beim ersten Lauf passiert. */
export type BewegtErgebnis = 'ok' | 'keinBild' | 'fehler'
/** Ein Standbild bzw. Titelbild als Vorschau, samt Laenge.
*
* Gibt BEIDES zurueck, weil beides aus demselben Blick in die Datei kommt: ein
* zweiter Aufruf nur fuer die Sekundenzahl waere die doppelte Arbeit.
*
* Wie writeThumb: ein Fehlschlag ist KEIN Grund, den Upload scheitern zu
* lassen. */
export async function writeBewegtThumb(
path: string,
mime: string,
): Promise<{ ergebnis: BewegtErgebnis; dauer: number | null }> {
const ziel = thumbPath(path)
const quelle = absolutePath(path)
const istVideo = mime.startsWith('video')
const info = await medienInfo(path)
// Keine Bildspur: bei Ton der Normalfall (die meisten MP3 tragen kein
// Titelbild). Hier wird ffmpeg gar nicht erst ein zweites Mal bemueht.
if (!info.hatBild) return { ergebnis: 'keinBild', dauer: info.dauer }
try {
await mkdir(dirname(ziel), { recursive: true })
// Bei Video NICHT den Anfang nehmen, wenn sich das vermeiden laesst:
// viele Clips beginnen mit Schwarz oder einem Einblender, und eine
// schwarze Kachel ist so aussagekraeftig wie gar keine. Ein Zehntel
// hinein ist fast immer Bild.
//
// Bei Ton NICHT springen: das eingebettete Titelbild ist ein einzelnes
// Standbild, und ein -ss darauf findet nichts mehr.
const start = istVideo && info.dauer ? Math.min(info.dauer * 0.1, 10) : 0
const temp = `${ziel}.${process.pid}.tmp.webp`
await ffmpeg([
// -ss VOR -i: dann springt ffmpeg, statt bis dorthin zu dekodieren.
...(start > 0 ? ['-ss', start.toFixed(2)] : []),
'-i', quelle,
// Nur die Bildspur, genau ein Bild. Bei einer MP3 ist das
// eingebettete Titelbild genau diese Spur.
'-map', '0:v:0',
'-frames:v', '1',
// Nicht aufblasen: ein 64 Pixel breites Titelbild bleibt klein.
'-vf', `scale='min(${THUMB_BREITE},iw)':-2`,
'-f', 'webp',
'-y', temp,
])
await rename(temp, ziel)
return { ergebnis: 'ok', dauer: info.dauer }
} catch (err) {
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
return { ergebnis: 'fehler', dauer: info.dauer }
}
}
/** ffmpeg aufrufen, OHNE zu urteilen: gibt zurueck, was herauskam, samt
* Rueckgabewert. Fuer den Fall, dass ein Fehlschlag die Antwort IST -- siehe
* medienDauer.
*
* execFile und NICHT exec: ohne Shell dazwischen gibt es keine Zeichenkette,
* in der ein Dateiname etwas anderes bedeuten koennte. Der Pfad ist zwar durch
* checkPath gegangen, aber diese Abwehr kostet nichts. */
function ffmpegRoh(args: string[]): Promise<{ code: number | null; stderr: string }> {
return new Promise((erfuellen) => {
execFile(
'ffmpeg',
args,
{ timeout: FFMPEG_MS, maxBuffer: 4 * 1024 * 1024 },
(fehler, _stdout, stderr) => {
erfuellen({
code: fehler ? ((fehler as { code?: number }).code ?? 1) : 0,
stderr,
})
},
)
})
}
/** Dasselbe, aber ein Fehlschlag ist ein Fehlschlag. */
async function ffmpeg(args: string[]): Promise<void> {
const { code, stderr } = await ffmpegRoh(args)
if (code !== 0) {
// Die letzte Zeile von ffmpeg ist die, die den Grund nennt.
throw new Error(stderr.trim().split('\n').pop() ?? `ffmpeg endete mit ${code}`)
}
}
/** Eine Datei samt Vorschau an einen anderen Pfad legen.
*
* DER PFAD IST DER SCHLUESSEL — deshalb ist Verschieben hier kein Kopieren
* mit anschliessendem Loeschen, sondern ein `rename`: das ist auf derselben
* Platte unteilbar. Ein abgebrochenes Kopieren hinterliesse die Datei zweimal
* oder gar nicht.
*
* Die Vorschau zieht mit. Bliebe sie liegen, zeigte die Galerie am neuen Ort
* kein Bild und am alten Ort das eines Bildes, das dort nicht mehr liegt. */
export async function moveFile(von: string, nach: string): Promise<void> {
const ziel = absolutePath(nach)
await mkdir(dirname(ziel), { recursive: true })
await rename(absolutePath(von), ziel)
const abgeleitet: [string, string][] = [
[thumbPath(von), thumbPath(nach)],
[webpPfad(von), webpPfad(nach)],
]
for (const [vonPfad, nachPfad] of abgeleitet) {
try {
await mkdir(dirname(nachPfad), { recursive: true })
await rename(vonPfad, nachPfad)
} catch {
// Nicht vorhanden — kein Fehler. Beides ist abgeleitet und laesst
// sich jederzeit neu rechnen.
}
}
}
// ---------------------------------------------------------- Die sparsame Fassung
//
// DAS PROBLEM. Die uebernommenen Item-Bilder sind PNG. Als WebP waeren sie ein
// Bruchteil davon. Nur: DER PFAD IST DIE ADRESSE -- aus items/foo.png darf
// nicht items/foo.webp werden, sonst zeigt jedes Skript ins Leere, das den
// Namen schon kennt.
//
// DIE LOESUNG steht im Protokoll selbst. Der Browser sagt mit `Accept`, was er
// versteht. Wir legen die sparsame Fassung DANEBEN und liefern sie unter
// DERSELBEN Adresse aus, wenn der Aufrufer sie mag. Wer sie nicht mag -- Lua,
// curl, ein aelteres Werkzeug -- bekommt unveraendert das Original. Niemand
// muss etwas umstellen.
//
// Dazu gehoert `Vary: Accept` in der Antwort. Ohne die Zeile legt ein
// Zwischenspeicher die WebP-Fassung fuer ALLE ab, auch fuer die, die sie nicht
// lesen koennen. Unser Proxy speichert /f/ zwar gar nicht zwischen -- aber das
// ist eine Einstellung, die jemand aendern kann, und darauf soll die
// Richtigkeit nicht beruhen.
export const webpPfad = (path: string) => join(config.dataDir, 'webp', `${path}.webp`)
/** Fuer welche Arten sich eine sparsame Fassung lohnt.
*
* WebP selbst NICHT: das waere ein Umkodieren ohne Gewinn. GIF auch nicht --
* dort braeuchte es animiertes WebP, und der Gewinn ist klein gegen das
* Risiko, dass die Bewegung dabei verlorengeht. */
export const lohntWebp = (mime: string) =>
/^image\/(png|jpeg)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Die sparsame Fassung erzeugen. Gibt ihre Groesse zurueck, oder null.
*
* Schlaegt es fehl, ist das KEIN Fehler fuer den Aufrufer: dann wird eben
* weiter das Original ausgeliefert, so wie bisher. */
export async function writeWebp(path: string, data: Buffer): Promise<number | null> {
const ziel = webpPfad(path)
try {
const { default: sharp } = await import('sharp')
await mkdir(dirname(ziel), { recursive: true })
// OHNE Groessenaenderung -- anders als bei der Vorschau. Es geht um die
// Kodierung, nicht um die Aufloesung: wer ein 4K-Bild hochlaedt, will
// ein 4K-Bild, nur kleiner in Bytes.
const bild = await sharp(data, { animated: false }).webp({ quality: 82 }).toBuffer()
// NUR wenn es sich lohnt. Bei kleinen oder schon dichten PNG faellt
// WebP durchaus groesser aus -- dann waere die "sparsame" Fassung eine
// Verschwendung mit zusaetzlicher Verwaltung obendrauf. Eine alte
// Fassung, die nun nicht mehr lohnt, muss dabei weg: sonst liefert der
// Dienst weiter die groessere aus.
if (bild.length >= data.length) {
await rm(ziel, { force: true })
return null
}
const temp = `${ziel}.${process.pid}.tmp`
await writeFile(temp, bild)
await rename(temp, ziel)
return bild.length
} catch (err) {
console.error(`[webp] ${path}: ${err instanceof Error ? err.message : err}`)
return null
}
}
/** Einen ganzen Ordner an einen anderen Platz legen.
*
* NICHT dasselbe wie moveFile mit einem Ordner darin, und das ist ein
* Fallstrick, der beim Schreiben fast durchgerutscht waere: `thumbPath` haengt
* ".webp" an. Fuer eine Datei ist das richtig (vehicles/adder.png ->
* thumbs/vehicles/adder.png.webp), fuer einen ORDNER waere es Unsinn -- der
* Vorschauordner heisst thumbs/vehicles und nicht thumbs/vehicles.webp.
*
* Deshalb hier die Ordnerfassung: dieselben drei Baeume, aber ohne Endung. */
export async function moveFolder(von: string, nach: string): Promise<void> {
await mkdir(dirname(join(config.filesDir, nach)), { recursive: true })
await rename(join(config.filesDir, von), join(config.filesDir, nach))
for (const ordner of ['thumbs', 'webp']) {
try {
await mkdir(dirname(join(config.dataDir, ordner, nach)), { recursive: true })
await rename(
join(config.dataDir, ordner, von),
join(config.dataDir, ordner, nach),
)
} catch {
// Gibt es nicht — kein Fehler. Beides ist abgeleitet und laesst
// sich mit einem Knopf neu rechnen.
}
}
}
export async function deleteFile(path: string): Promise<void> {
await rm(absolutePath(path), { force: true })
// Vorschau und sparsame Fassung gehen mit. Sonst bleiben sie liegen, und
// beim naechsten Bild unter demselben Pfad sieht man das alte.
await rm(thumbPath(path), { force: true })
await rm(webpPfad(path), { force: true })
}
// ------------------------------------------------------------- Papierkorb
//
// Verschieben statt Loeschen. Der Ablagename traegt eine laufende Nummer, weil
// unter demselben Pfad nacheinander verschiedene Dateien gelegen haben koennen
// -- und beide sollen zurueckholbar sein.
export const papierkorbPfad = (ablage: string) => join(config.dataDir, 'papierkorb', ablage)
/** Die Datei in den Papierkorb legen. Gibt den Ablagenamen zurueck.
*
* Die VORSCHAU wandert NICHT mit: sie laesst sich aus dem Bild neu rechnen,
* und ein Papierkorb, der Abgeleitetes mitschleppt, ist doppelt so gross wie
* noetig. */
export async function inDenPapierkorb(path: string, id: number): Promise<string> {
const ablage = `${id}-${path.replace(/[\\/]/g, '_')}`
const ziel = papierkorbPfad(ablage)
await mkdir(dirname(ziel), { recursive: true })
await rename(absolutePath(path), ziel)
await rm(thumbPath(path), { force: true })
await rm(webpPfad(path), { force: true })
return ablage
}
/** Zurueck an ihren Platz. */
export async function ausDemPapierkorb(ablage: string, path: string): Promise<void> {
const ziel = absolutePath(path)
await mkdir(dirname(ziel), { recursive: true })
await rename(papierkorbPfad(ablage), ziel)
}
export async function fileExists(path: string): Promise<boolean> {
try {
await stat(absolutePath(path))
return true
} catch {
return false
}
}
/** Die oeffentliche Adresse einer Datei.
*
* filePrefix ist bei zwei Namen leer und bei einem "/f" — so kommt hier
* dieselbe Adresse heraus, unter der die Datei auch tatsaechlich liegt. */
export const publicUrlFor = (path: string) =>
`${config.publicUrl}${config.filePrefix}/${path}`