// 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 = { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const ziel = absolutePath(path) await mkdir(dirname(ziel), { recursive: true }) await rename(papierkorbPfad(ablage), ziel) } export async function fileExists(path: string): Promise { 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}`