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>
665 lines
28 KiB
TypeScript
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}`
|