FEHLENDE BILDER. Die Frage, die bisher niemand beantwortet hat: das Fotostudio weiss, was es aufgenommen hat, der Dienst weiss, was liegt, und die Differenz rechnet keiner aus -- weshalb "das Handy zeigt bei manchen Autos kein Bild" erst im Spiel auffaellt. POST /api/dash/fehlende nimmt ein Muster mit genau EINEM Platzhalter und eine Liste (als Array oder als Textblock aus einem Textfeld) und gibt beide Richtungen zurueck: was fehlt, und was daliegt ohne in der Liste zu stehen. Beides bedeutet etwas anderes -- fehlende sind Arbeit fuers Fotostudio, ueberzaehlige sind Platz, den man zurueckbekommt. Gesucht wird KLEINGESCHRIEBEN, weil so abgelegt wird: wer "WEAPON_SMG" hereinreicht, meint weapon_smg.png und bekaeme sonst ein "fehlt", das gelogen waere. Gemessen an echten Namen: 6 geprueft, 4 da, 2 fehlen, 985 ueberzaehlig. MASSEN-UMBENENNEN mit Vorschau. Bei 3578 Items ist eine Umbenennung von Hand keine. DIE VORSCHAU IST DER EIGENTLICHE TEIL: was hier passiert, ist dieselbe Warnung wie beim Ordner-Umbenennen -- jede alte Adresse gibt danach 404 --, nur mal tausend. Geprueft wird VOR dem ersten Schritt: Kollisionen untereinander, belegte Ziele, unzulaessige Pfade. Und EIN Problem verhindert ALLES; eine halbe Umbenennung ist der Zustand, aus dem niemand mehr herausfindet, weil die Grenze nirgends steht. Gemessen: Vorschau ueber 134 Dateien fasst nichts an; eine erzwungene Kollision gibt 409 und laesst alle drei Dateien stehen; der saubere Lauf benennt zwei um, Vorschau wandert mit, ein Eintrag im Verlauf statt zwei. BILDER BEIM ABLEGEN VERKLEINERN, je Token einstellbar. NICHT als Schutz -- das waere gemessen falsch, ein 12000x12000-PNG kostet 374 ms und 47 MB, weil libvips kachelweise arbeitet. Es geht um Plattenplatz und Ladezeit: ein Bildschirmfoto ist 3840 breit und wird auf einem Handy auf 400 gezeigt. withoutEnlargement ist dabei entscheidend, sonst blaeht die Einstellung kleine Icons auf. DABEI WAERE MIR EIN ECHTER FEHLER UNTERLAUFEN: der sha256 wird weiter oben aus dem Original gerechnet (fuer den Ersatzpfad, wenn kein X-Path kam). Wird das Bild verkleinert, ist das eine ANDERE Datei -- ein Datensatz mit dem Hash des Originals waere schlicht falsch, und jede spaetere Gegenprobe verglichen gegen etwas, das nirgends liegt. Gemessen nach der Korrektur: 3000x2000 -> 512x341, 82 KB -> 3 KB, und Datenbank und Platte stimmen in Groesse und Hash ueberein. EINSTELLUNGEN MITNEHMEN. Export als JSON, Import zurueck. GEHEIMNISSE GEHEN STANDARDMAESSIG NICHT MIT: unter den Schluesseln liegen das Discord-Client-Geheimnis und das Nextcloud-Passwort, und eine Datei, die man "mal eben" exportiert und in einen Chat zieht, ist genau der Weg, auf dem so etwas abhandenkommt. Wer sie braucht, sagt ?geheim=1 -- und der Hinweis steht dann IN der Datei, nicht nur auf dem Bildschirm, von dem er beim Weiterreichen abfaellt. Der Export mit Geheimnissen steht im Verwaltungs-Verlauf. Beim Import ueberschreiben LEERE WERTE NICHTS: eine Datei ohne Geheimnisse -- der Normalfall -- traegt sie als leere Zeichenkette, und die darf ein vorhandenes Passwort nicht loeschen. Gemessen: nach einem Import mit leerem client_secret stehen die 32 Zeichen unveraendert da. Nicht mitgenommen wird sicherung.letzte -- das ist der Bericht des letzten Laufs, kein Einstellungswert. In eine frische Anlage eingespielt behauptete er dem Wachhund eine Sicherung, die es dort nie gab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
639 lines
27 KiB
TypeScript
639 lines
27 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) : ''
|
|
|
|
const zaehmen = (s: string) =>
|
|
s
|
|
.toLowerCase()
|
|
// Umlaute NICHT einfach wegwerfen: "grün" wuerde sonst zu "grn".
|
|
.replace(/ä/g, 'ae')
|
|
.replace(/ö/g, 'oe')
|
|
.replace(/ü/g, 'ue')
|
|
.replace(/ß/g, 'ss')
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/-{2,}/g, '-')
|
|
.replace(/^[^a-z0-9]+/, '')
|
|
.replace(/[-.]+$/, '')
|
|
|
|
const stamm = zaehmen(roh)
|
|
const ende = zaehmen(endung)
|
|
|
|
// 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}`
|