feat: Papierkorb, Abrufzaehler, WebP ohne Adressaenderung, ZIP hochladen

Vier Dinge, alle am laufenden Dienst gemessen.

PAPIERKORB. Loeschen war endgueltig -- bei einem Knopf "alle 3861 waehlen"
direkt neben "loeschen" ist das die falsche Haerte, und die Sicherung half nur
bis zum letzten naechtlichen Lauf. Die Datei wandert jetzt nach
/data/papierkorb, der Datensatz in eine eigene Tabelle, nach 30 Tagen raeumt
der Dienst stuendlich selbst auf. Der Ablagename traegt eine laufende Nummer:
unter demselben Pfad koennen nacheinander verschiedene Dateien gelegen haben,
und beide sollen zurueckholbar sein. Geprueft: loeschen -> oeffentlich 404,
Eintrag im Papierkorb, zurueckholen -> 200 samt neu gerechneter Vorschau.

ABRUFZAEHLER. media.abrufe und zuletzt_abgerufen. Gezaehlt wird im Speicher und
alle 30 Sekunden weggeschrieben -- ein UPDATE je Kachel waeren bei einer
Galerieseite 120 Schreibvorgaenge. Der 304 zaehlt mit (der Aufrufer WOLLTE die
Datei, er hatte sie nur schon), HEAD nicht. Was vor dem Einbau lag, steht als
"nie geholt" da, auch wenn es taeglich benutzt wurde -- die Karte im
Speicherbericht sagt das auch dazu, statt eine Zahl zu zeigen, die luegt.

WEBP OHNE ADRESSAENDERUNG. Der Pfad ist die Adresse: aus items/foo.png darf
nicht items/foo.webp werden. Die sparsame Fassung liegt deshalb DANEBEN und
wird unter DERSELBEN Adresse ausgeliefert, wenn der Aufrufer per Accept sagt,
dass er WebP versteht. Nachgemessen an einer Datei: 6448 Bytes fuer curl, 2758
fuer einen Browser. Ueber den ganzen Bestand: 3606 Fassungen, 199,3 MB ->
33,1 MB, gespart 166,2 MB. Bei 29 lohnt WebP nicht -- dort bleibt es beim
Original.

Zwei Dinge haengen daran, beide gegengeprueft: "Vary: Accept" (ohne die Zeile
legt ein Zwischenspeicher die WebP-Fassung fuer alle ab, auch fuer die, die sie
nicht lesen koennen) und getrennte ETags mit "-w" (sonst bekaeme jemand auf
seinen If-None-Match hin ein 304 fuer das falsche Bild). Gemessen: derselbe
ETag mit Accept -> 304, ohne Accept -> 200 mit dem PNG.

ZIP HOCHLADEN. Ein Archiv wird ausgepackt statt abgelegt, die Ordner darin
bleiben erhalten und haengen sich hinter den gewaehlten Zielordner. fflate und
nicht adm-zip: reines JavaScript, keine native Bibliothek -- dieses Projekt hat
schon einen halben Abend an einer ABI-Nummer verloren. Grenzen: 5000 Eintraege,
256 MB entpackt, geprueft VOR dem Entpacken (eine Zip-Bombe waere sonst schon
im Speicher). Zip-Slip gemessen: "../../../../etc/passwd" und
"..\..\windows\hosts" abgelehnt, die harmlose Datei im selben Archiv abgelegt.

UND EIN KNOPF, DER GELOGEN HAT. Der erste Nachruestlauf ueber alle Dateien
brauchte mehr als 90 Sekunden -- genau da gibt Nginx Proxy Manager auf. Der
Aufrufer sah einen 504, waehrend die Arbeit im Hintergrund weiterlief und
fertig wurde. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer als einer
ohne Rueckmeldung.

Beide Wartungsknoepfe laufen deshalb in Runden zu 300 Stueck: der Server meldet
"offen" und "fertig", die Oberflaeche ruft erneut auf und zeigt dabei "280
erzeugt, noch 129 offen ...". Gemessen: 7,8 s je Runde statt 90+ am Stueck. Der
Vorschau-Knopf hatte dieselbe Wand und wurde mitgezogen, obwohl er noch nicht
dagegengelaufen war.

Nebenbei: der Verwaisten-Sucher kennt jetzt auch /data/webp (gleiche Schleife,
Ordner im Ergebnis vorangestellt), Verschieben und Loeschen nehmen die sparsame
Fassung mit, und das Loeschen eines Ordners raeumt thumbs/ und webp/ hinterher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 21:40:55 +02:00
co-authored by Claude Opus 5
parent e4a125e1c6
commit 40c446f859
15 changed files with 1334 additions and 134 deletions
+85
View File
@@ -98,6 +98,29 @@ CREATE INDEX IF NOT EXISTS events_at ON events(at DESC);
-- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den
-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart
-- verstellbar sein soll -- derzeit die Discord-Anmeldung.
-- Der Papierkorb.
--
-- Loeschen war bis hierhin endgueltig: die Datei war von der Platte weg, und
-- die Sicherung half nur bis zum letzten naechtlichen Lauf. Bei einem Knopf
-- "alle 3670 waehlen" direkt neben "loeschen" ist das die falsche Haerte.
--
-- Die Datei wandert deshalb nach /data/papierkorb und der Datensatz hierher.
-- Was hier steht, ist alles, was zum Zurueckholen noetig ist -- der Pfad ist
-- der Schluessel, und den behalten wir.
CREATE TABLE IF NOT EXISTS papierkorb (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
mime TEXT NOT NULL,
geloescht INTEGER NOT NULL,
von TEXT NOT NULL,
-- Der Name IM Papierkorb. Nicht der Pfad: zwei Dateien koennen nacheinander
-- unter demselben Pfad gelegen haben, und beide sollen zurueckholbar sein.
ablage TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS papierkorb_zeit ON papierkorb(geloescht DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
@@ -141,6 +164,16 @@ CREATE TABLE IF NOT EXISTS settings (
console.log('[db] Spalte events.von angelegt')
}
// Abrufe. Die Frage "was braucht eigentlich noch jemand" laesst sich von
// innen nicht beantworten -- von aussen schon: wer wird geholt, wie oft,
// wann zuletzt. Das ist die Zahl, die dem Aufbewahrungsbericht fehlt.
const mSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
if (!mSpalten.some((s) => s.name === 'abrufe')) {
db.exec('ALTER TABLE media ADD COLUMN abrufe INTEGER NOT NULL DEFAULT 0')
db.exec('ALTER TABLE media ADD COLUMN zuletzt_abgerufen INTEGER')
console.log('[db] Spalten media.abrufe und zuletzt_abgerufen angelegt')
}
if (!spalten.some((s) => s.name === 'discord_avatar')) {
db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT')
db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT')
@@ -236,6 +269,20 @@ export type Media = {
token_id: number | null
created_at: number
updated_at: number
/** Wie oft die Datei oeffentlich geholt wurde. */
abrufe: number
zuletzt_abgerufen: number | null
}
export type PapierkorbEintrag = {
id: number
path: string
size: number
sha256: string
mime: string
geloescht: number
von: string
ablage: string
}
export type EventKind = 'upload' | 'replace' | 'delete' | 'move'
@@ -282,6 +329,44 @@ export function logEvent(
/** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das
* 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu
* behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */
// -- Abrufe zaehlen, ohne die Auslieferung zu bremsen -------------------------
//
// Ein UPDATE je Bildabruf waere bei einer Galerieseite mit 120 Kacheln ein
// Schreibvorgang je Kachel. Stattdessen wird im Speicher gezaehlt und
// gelegentlich weggeschrieben -- wer wissen will, ob eine Datei benutzt wird,
// braucht keine Genauigkeit auf die Sekunde.
//
// Was bei einem Absturz verlorengeht, sind hoechstens 30 Sekunden Zaehlerei.
const offeneAbrufe = new Map<string, { anzahl: number; zuletzt: number }>()
export function zaehleAbruf(path: string): void {
const bisher = offeneAbrufe.get(path)
if (bisher) {
bisher.anzahl++
bisher.zuletzt = now()
} else {
offeneAbrufe.set(path, { anzahl: 1, zuletzt: now() })
}
}
const abrufSchreiben = db.prepare(
'UPDATE media SET abrufe = abrufe + ?, zuletzt_abgerufen = ? WHERE path = ?',
)
export function schreibeAbrufe(): void {
if (offeneAbrufe.size === 0) return
const stapel = [...offeneAbrufe.entries()]
offeneAbrufe.clear()
try {
db.transaction(() => {
for (const [path, w] of stapel) abrufSchreiben.run(w.anzahl, w.zuletzt, path)
})()
} catch (err) {
console.error('[abrufe] konnten nicht geschrieben werden:', err)
}
}
export function pruneEvents(tage = 180) {
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
}
+65 -6
View File
@@ -28,11 +28,11 @@ import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { config } from './config.js'
import { ensureAdmin } from './auth.js'
import { pruneSessions } from './db.js'
import { pruneSessions, schreibeAbrufe, zaehleAbruf } from './db.js'
import { pruefeSicherung } from './meldung.js'
import { absolutePath, checkPath, mimeFor, thumbPath } from './storage.js'
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
import { uploadRoutes } from './routes/upload.js'
import { dashRoutes } from './routes/dash.js'
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
const app = new Hono()
@@ -97,7 +97,7 @@ async function serveFile(c: any, rawPath: string) {
return c.text('nicht gefunden', 404)
}
const absolute = absolutePath(path)
let absolute = absolutePath(path)
let info
try {
info = await stat(absolute)
@@ -106,10 +106,45 @@ async function serveFile(c: any, rawPath: string) {
return c.text('nicht gefunden', 404)
}
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
// DIE SPARSAME FASSUNG, unter DERSELBEN Adresse.
//
// Der Browser sagt mit `Accept`, was er versteht. Versteht er WebP und
// liegt eine Fassung daneben, bekommt er sie. Die Adresse bleibt
// items/foo.png, und kein Skript merkt etwas davon. Lua, curl und alles
// ohne diese Ansage bekommen unveraendert das Original.
//
// Der Vergleich ist absichtlich stumpf: `Accept` traegt Gewichte, und ein
// "image/webp;q=0" hiesse eigentlich "bitte nicht". Das schickt in der
// Praxis niemand -- und faellt jemand doch darauf herein, bekommt er ein
// gueltiges Bild, nur in einem anderen Format. Der Preis eines Fehlers ist
// hier klein genug, dass die einfache Pruefung die richtige ist.
let typ = mimeFor(path)
let sparsam = false
if (lohntWebp(typ) && (c.req.header('accept') ?? '').includes('image/webp')) {
try {
const wInfo = await stat(webpPfad(path))
if (wInfo.isFile()) {
absolute = webpPfad(path)
info = wInfo
typ = 'image/webp'
sparsam = true
}
} catch {
/* keine sparsame Fassung -- dann eben das Original */
}
}
// Das Kennzeichen gehoert IN den ETag: sonst traegt das Original und die
// sparsame Fassung dieselbe Marke, sobald beide zufaellig gleich gross und
// gleich alt sind, und ein Aufrufer bekaeme auf seinen 304 hin das falsche
// Bild aus seinem eigenen Zwischenspeicher.
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}${sparsam ? '-w' : ''}"`
const headers: Record<string, string> = {
'Content-Type': mimeFor(path),
'Content-Type': typ,
'Cache-Control': 'public, max-age=300',
// OHNE DIESE ZEILE legt ein Zwischenspeicher die WebP-Fassung fuer ALLE
// ab -- auch fuer die, die sie nicht lesen koennen.
Vary: 'Accept',
ETag: etag,
'Last-Modified': info.mtime.toUTCString(),
'Accept-Ranges': 'bytes',
@@ -118,6 +153,13 @@ async function serveFile(c: any, rawPath: string) {
'X-Content-Type-Options': 'nosniff',
}
// GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung.
//
// Auch der 304 zaehlt: der Aufrufer WOLLTE die Datei, er hatte sie nur
// schon. Fuer die Frage "braucht das noch jemand" ist das dasselbe.
// HEAD zaehlt nicht -- das fragt nach der Datei, nicht nach dem Inhalt.
if (c.req.method !== 'HEAD') zaehleAbruf(path)
if (c.req.header('if-none-match') === etag) return c.body(null, 304, headers)
if (c.req.method === 'HEAD') {
return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) })
@@ -328,6 +370,23 @@ setInterval(pruneSessions, 3600_000).unref()
setTimeout(() => void pruefeSicherung(), 60_000).unref()
setInterval(() => void pruefeSicherung(), 3600_000).unref()
// Die gezaehlten Abrufe wegschreiben. Alle 30 Sekunden reicht: wer wissen
// will, ob eine Datei benutzt wird, braucht keine Genauigkeit auf die Sekunde.
setInterval(schreibeAbrufe, 30_000).unref()
// Und beim Herunterfahren, damit die letzten nicht verlorengehen.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
schreibeAbrufe()
process.exit(0)
})
}
// Den Papierkorb ausleeren, was ueberfaellig ist. Beim Start mit Verzoegerung,
// danach stuendlich.
setTimeout(() => void papierkorbAusleeren(), 90_000).unref()
setInterval(() => void papierkorbAusleeren(), 3600_000).unref()
serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`[d4rk_media] Port ${info.port}`)
console.log(
+530 -91
View File
@@ -6,6 +6,7 @@
// Rueckmeldung wie ein kaputter Knopf aussieht.
import { Hono } from 'hono'
import { unzipSync } from 'fflate'
import type { Context } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { config } from '../config.js'
@@ -18,6 +19,7 @@ import {
pruneSessions,
type Media,
type MediaEvent,
type PapierkorbEintrag,
type Token,
type User,
} from '../db.js'
@@ -50,16 +52,22 @@ import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
import {
absolutePath,
checkPath,
ausDemPapierkorb,
deleteFile,
inDenPapierkorb,
kannVorschau,
lohntWebp,
mimeFor,
moveFile,
publicUrlFor,
safeFilename,
sha256,
papierkorbPfad,
thumbPath,
webpPfad,
writeFileAtomic,
writeThumb,
writeWebp,
PathError,
} from '../storage.js'
@@ -82,6 +90,10 @@ async function jsonBody<T>(c: Context<any>): Promise<T | null> {
const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
/** Wie lange Geloeschtes im Papierkorb bleibt. 30 Tage: lang genug, dass ein
* Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */
const PAPIERKORB_TAGE = 30
// ------------------------------------------------------------ Die Bremse
//
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
@@ -523,9 +535,7 @@ dashRoutes.delete('/media/:id', async (c) => {
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
await deleteFile(row.path)
db.prepare('DELETE FROM media WHERE id = ?').run(id)
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
await inPapierkorb(row, c.get('user').username)
return c.json({ deleted: row.path })
})
@@ -539,22 +549,120 @@ dashRoutes.post('/media/delete', async (c) => {
}
const select = db.prepare('SELECT * FROM media WHERE id = ?')
const remove = db.prepare('DELETE FROM media WHERE id = ?')
const deleted: string[] = []
for (const id of ids) {
const row = select.get(id) as Media | undefined
if (!row) continue
await deleteFile(row.path)
remove.run(id)
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
await inPapierkorb(row, c.get('user').username)
deleted.push(row.path)
}
return c.json({ deleted, count: deleted.length })
})
// ------------------------------------------------------------------- Archive
//
// WOZU. 3670 Item-Bilder einzeln ins Fenster zu ziehen ist keine Bedienung,
// das ist eine Strafe. Ein ZIP ist die Form, in der solche Sammlungen ohnehin
// unterwegs sind -- und es bringt die ORDNER MIT, was der wichtigere Teil ist:
// items/foo.png im Archiv landet unter items/foo.png im Dienst.
//
// fflate und nicht adm-zip: reines JavaScript, keine native Bibliothek. Dieses
// Projekt hat schon einmal einen halben Abend an einer ABI-Nummer verloren
// (better-sqlite3 gegen Node 24), und das soll sich nicht wiederholen.
/** Wie viele Eintraege ein Archiv hoechstens haben darf. */
const ZIP_MAX_EINTRAEGE = 5000
/** Und wieviel es ENTPACKT hoechstens ergeben darf.
*
* Das ist die Zip-Bombe: ein Archiv von 40 KB kann zu Gigabyte aufgehen, und
* entpackt wird in den Arbeitsspeicher. Geprueft wird deshalb VOR dem
* Entpacken -- die Groesse des Originals steht im Kopf jedes Eintrags, und
* fflate laesst uns darauf filtern. Eine Pruefung danach waere keine. */
const ZIP_MAX_GESAMT = 256 * 1024 * 1024
const istArchiv = (name: string, typ: string) =>
/\.zip$/i.test(name) || /^application\/(x-)?zip(-compressed)?$/i.test(typ.split(';')[0] ?? '')
/** Einen Pfad aus dem Archiv in einen zulaessigen Pfad verwandeln.
*
* Jedes Segment einzeln durch dieselbe Zaehmung wie ein Dateiname aus dem
* Fenster. Gibt null zurueck, wenn nichts Brauchbares uebrig bleibt.
*
* ".." FLIEGT RAUS, und zwar hier schon: ein Archiv ist eine Datei von
* draussen, und "../../etc/passwd" als Eintragsname ist ein Angriff mit
* eigenem Namen (Zip Slip). checkPath faengt es zwar auch, aber eine Abwehr,
* die nur an EINER Stelle steht, ist eine Abwehr, die beim Umbauen verschwindet. */
function archivPfad(roh: string): string | null {
const teile = roh
.replace(/\\/g, '/')
.split('/')
.filter((t) => t && t !== '.')
if (teile.some((t) => t === '..')) return null
const sauber = teile.map((t) => safeFilename(t)).filter(Boolean)
return sauber.length ? sauber.join('/') : null
}
/** Ein Archiv auspacken. Was uebersprungen wird, steht mit Grund im Ergebnis --
* ein stilles Weglassen waere hier das Schlimmste: der Aufrufer glaubt, seine
* 900 Bilder seien da, und merkt bei Bild 314, dass sie es nicht sind. */
function auspacken(
data: Buffer,
ergebnisse: { name: string; ok: boolean; error?: string }[],
): [string, Uint8Array][] {
let gesamt = 0
let anzahl = 0
const inhalt = unzipSync(data, {
filter: (eintrag) => {
// Ordnereintraege und der Beifang, den macOS in jedes Archiv legt.
if (eintrag.name.endsWith('/')) return false
if (/(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$)/i.test(eintrag.name)) return false
if (eintrag.originalSize === 0) return false
if (++anzahl > ZIP_MAX_EINTRAEGE) return false
gesamt += eintrag.originalSize
return gesamt <= ZIP_MAX_GESAMT
},
})
if (anzahl > ZIP_MAX_EINTRAEGE) {
ergebnisse.push({
name: 'Archiv',
ok: false,
error: `mehr als ${ZIP_MAX_EINTRAEGE} Eintraege — der Rest wurde nicht ausgepackt`,
})
}
if (gesamt > ZIP_MAX_GESAMT) {
ergebnisse.push({
name: 'Archiv',
ok: false,
error:
`ergibt entpackt mehr als ${Math.round(ZIP_MAX_GESAMT / 1024 / 1024)} MB ` +
'— der Rest wurde nicht ausgepackt',
})
}
const raus: [string, Uint8Array][] = []
for (const [roh, bytes] of Object.entries(inhalt)) {
const pfad = archivPfad(roh)
if (!pfad) {
ergebnisse.push({ name: roh, ok: false, error: 'unbrauchbarer Name im Archiv' })
continue
}
raus.push([pfad, bytes])
}
return raus
}
/** Hochladen aus dem Dashboard.
*
* Ein ZIP wird dabei AUSGEPACKT statt abgelegt: es ist so gut wie nie das
* Archiv, das jemand oeffentlich haben will, sondern sein Inhalt. Die Ordner
* im Archiv bleiben erhalten und haengen sich hinter den gewaehlten Zielordner.
*
* Ein eigener Weg neben /api/upload, und zwar aus einem Grund: der dortige
* haengt an einem Bearer-Token. Wer im Dashboard sitzt, hat eine SITZUNG und
@@ -662,6 +770,95 @@ dashRoutes.post('/upload', async (c) => {
error?: string
}[] = []
/** Eine Datei ablegen -- EIN Weg fuer alles.
*
* Ob der Inhalt aus einem Formularteil kam oder aus einem Archiv, macht ab
* hier keinen Unterschied mehr: gleiche Namensregeln, gleiche Pruefung auf
* Belegtsein, gleiche Vorschau, gleicher Eintrag im Verlauf. Ein zweiter
* Weg fuer ZIP-Inhalte waere ein zweiter Weg, der beim naechsten Mal
* auseinanderlaeuft.
*
* `zielpfad` ist gesetzt, wenn der Name schon ein Pfad ist -- bei
* Archiveintraegen naemlich, die ihre Ordner mitbringen. */
async function ablegen(
name: string,
data: Buffer,
gemeldeterTyp: string,
zielpfad?: string,
): Promise<void> {
if (data.length === 0) {
ergebnisse.push({ name, ok: false, error: 'leere Datei' })
return
}
if (data.length > config.maxUploadBytes) {
ergebnisse.push({
name,
ok: false,
error: `zu gross (${data.length} > ${config.maxUploadBytes} Bytes)`,
})
return
}
const digest = sha256(data)
// Der Hash als Rettungsanker fuer Namen, von denen nichts uebrig
// bleibt (kyrillisch, nur Leerzeichen). Ohne ihn hiessen zwei
// verschiedene Dateien beide "datei.txt", und die zweite
// ueberschriebe die erste still. Beim Messen genau so passiert.
const dateiname = zielpfad ?? safeFilename(name, digest.slice(0, 8))
const path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname)
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
| Media
| undefined
if (vorhanden && !ueberschreiben) {
ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' })
return
}
// Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein
// Achselzucken. Ein Formularteil ohne erkannte Art traegt
// "application/octet-stream", und das ist weniger wert als unsere
// Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und
// handbuch.pdf allesamt unter "Anderes". Aus einem Archiv kommt gar
// keine Angabe -- dort entscheidet die Endung ohnehin allein.
const gemeldet = (gemeldeterTyp || '').split(';')[0]?.trim().toLowerCase() ?? ''
const mime =
gemeldet && gemeldet !== 'application/octet-stream' ? gemeldeterTyp : mimeFor(path)
await writeFileAtomic(path, data)
if (kannVorschau(mime)) await writeThumb(path, data)
if (lohntWebp(mime)) await writeWebp(path, data)
const zeit = now()
if (vorhanden) {
db.prepare(
`UPDATE media SET size = ?, sha256 = ?, mime = ?, updated_at = ?
WHERE id = ?`,
).run(data.length, digest, mime, zeit, vorhanden.id)
} else {
db.prepare(
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
).run(path, data.length, digest, mime, zeit, zeit)
}
logEvent(
vorhanden ? 'replace' : 'upload',
path,
data.length,
'user',
c.get('user').username,
)
ergebnisse.push({
name,
ok: true,
path,
url: publicUrlFor(path),
size: data.length,
replaced: Boolean(vorhanden),
})
}
for (const datei of dateien) {
const name = datei.name || 'datei'
try {
@@ -675,69 +872,27 @@ dashRoutes.post('/upload', async (c) => {
}
const data = Buffer.from(await datei.arrayBuffer())
if (data.length === 0) {
ergebnisse.push({ name, ok: false, error: 'leere Datei' })
if (istArchiv(name, datei.type)) {
for (const [eintrag, inhalt] of auspacken(data, ergebnisse)) {
// EIGENES try JE EINTRAG. Sonst reisst ein einziger
// unbrauchbarer Name -- zu tief, zu lang -- die restlichen
// tausend mit, und der Aufrufer sieht eine Fehlermeldung
// statt neunhundertneunundneunzig Erfolgen.
try {
await ablegen(`${name}${eintrag}`, Buffer.from(inhalt), '', eintrag)
} catch (err) {
ergebnisse.push({
name: `${name}${eintrag}`,
ok: false,
error: err instanceof Error ? err.message : 'unbekannter Fehler',
})
}
}
continue
}
const digest = sha256(data)
// Der Hash als Rettungsanker fuer Namen, von denen nichts uebrig
// bleibt (kyrillisch, nur Leerzeichen). Ohne ihn hiessen zwei
// verschiedene Dateien beide "datei.txt", und die zweite
// ueberschriebe die erste still. Beim Messen genau so passiert.
const dateiname = safeFilename(name, digest.slice(0, 8))
const path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname)
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
| Media
| undefined
if (vorhanden && !ueberschreiben) {
ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' })
continue
}
// Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein
// Achselzucken. Ein Formularteil ohne erkannte Art traegt
// "application/octet-stream", und das ist weniger wert als unsere
// Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und
// handbuch.pdf allesamt unter "Anderes".
const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? ''
const mime =
gemeldet && gemeldet !== 'application/octet-stream'
? datei.type
: mimeFor(path)
await writeFileAtomic(path, data)
if (kannVorschau(mime)) await writeThumb(path, data)
const zeit = now()
if (vorhanden) {
db.prepare(
`UPDATE media SET size = ?, sha256 = ?, mime = ?, updated_at = ?
WHERE id = ?`,
).run(data.length, digest, mime, zeit, vorhanden.id)
} else {
db.prepare(
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
).run(path, data.length, digest, mime, zeit, zeit)
}
logEvent(
vorhanden ? 'replace' : 'upload',
path,
data.length,
'user',
c.get('user').username,
)
ergebnisse.push({
name,
ok: true,
path,
url: publicUrlFor(path),
size: data.length,
replaced: Boolean(vorhanden),
})
await ablegen(name, data, datei.type)
} catch (err) {
// Eine kaputte Datei bricht die anderen NICHT ab. Wer fuenf Dateien
// zieht und bei der zweiten alles verliert, zieht sie danach
@@ -1303,10 +1458,15 @@ dashRoutes.delete('/folders/:pfad{.+}', async (c) => {
400,
)
}
// Die Vorschauen desselben Ordners hinterher, falls einer da ist.
await rm(join(config.dataDir, 'thumbs', pfad), { recursive: true, force: true }).catch(
() => {},
)
// Das Abgeleitete desselben Ordners hinterher, falls es da ist. HIER mit
// recursive: anders als oben geht es nicht um eine Sicherung gegen
// versehentliches Loeschen -- der Ordner mit den Originalen ist zu diesem
// Zeitpunkt schon weg, und was hier noch liegt, ist nachrechenbar.
for (const ordner of ['thumbs', 'webp']) {
await rm(join(config.dataDir, ordner, pfad), { recursive: true, force: true }).catch(
() => {},
)
}
return c.json({ ok: true })
})
@@ -1392,6 +1552,145 @@ dashRoutes.get('/export', (c) => {
* weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route
* loescht nichts -- sie zeigt.
*/
/** Eine Datei in den Papierkorb legen: Datei verschieben, Datensatz umziehen.
*
* An EINER Stelle, weil es zwei Loeschwege gibt (einzeln und im Block) und
* die beim naechsten Mal sonst auseinanderlaufen. */
async function inPapierkorb(row: Media, wer: string): Promise<void> {
const eintrag = db
.prepare(
`INSERT INTO papierkorb (path, size, sha256, mime, geloescht, von, ablage)
VALUES (?, ?, ?, ?, ?, ?, '')`,
)
.run(row.path, row.size, row.sha256, row.mime, now(), wer)
const id = Number(eintrag.lastInsertRowid)
try {
const ablage = await inDenPapierkorb(row.path, id)
db.prepare('UPDATE papierkorb SET ablage = ? WHERE id = ?').run(ablage, id)
} catch (err) {
// Konnte die Datei nicht verschoben werden, darf der Datensatz nicht
// stehen bleiben -- sonst zeigt der Papierkorb etwas an, das sich nicht
// zurueckholen laesst.
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id)
throw err
}
db.prepare('DELETE FROM media WHERE id = ?').run(row.id)
logEvent('delete', row.path, row.size, 'user', wer)
}
/** Was zu lange im Papierkorb liegt, ist endgueltig weg.
*
* Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag
* aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */
export async function papierkorbAusleeren(): Promise<number> {
const alt = db
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
for (const e of alt) {
await rm(papierkorbPfad(e.ablage), { force: true })
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
}
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege endgueltig entfernt`)
return alt.length
}
dashRoutes.get('/papierkorb', (c) => {
const items = db
.prepare('SELECT * FROM papierkorb ORDER BY geloescht DESC LIMIT 500')
.all() as PapierkorbEintrag[]
const summe = db
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM papierkorb')
.get() as { n: number; bytes: number }
return c.json({ items, ...summe, haltbarTage: PAPIERKORB_TAGE })
})
/** Zurueck an ihren Platz.
*
* Ist der Pfad inzwischen wieder belegt, wird NICHT ueberschrieben: dann
* verschwaende eine Datei, die jemand nach dem Loeschen bewusst dort abgelegt
* hat. */
dashRoutes.post('/papierkorb/zurueck', async (c) => {
const body = await jsonBody<{ ids?: number[] }>(c)
if (!Array.isArray(body?.ids) || body.ids.length === 0) {
return c.json({ error: 'keine Auswahl' }, 400)
}
const wer = c.get('user').username
const ergebnisse: { path: string; ok: boolean; error?: string }[] = []
for (const id of body.ids) {
const e = db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id) as
| PapierkorbEintrag
| undefined
if (!e) {
ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' })
continue
}
if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(e.path)) {
ergebnisse.push({ path: e.path, ok: false, error: 'Pfad ist wieder belegt' })
continue
}
try {
await ausDemPapierkorb(e.ablage, e.path)
const zeit = now()
db.prepare(
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
).run(e.path, e.size, e.sha256, e.mime, zeit, zeit)
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id)
// Vorschau und sparsame Fassung wurden beim Loeschen weggeworfen --
// beides ist abgeleitet. Jetzt neu rechnen, sonst zeigt die Galerie
// ein Loch und die Datei kommt wieder in voller Groesse heraus.
if (kannVorschau(e.mime) || lohntWebp(e.mime)) {
try {
const daten = await readFile(absolutePath(e.path))
if (kannVorschau(e.mime)) await writeThumb(e.path, daten)
if (lohntWebp(e.mime)) await writeWebp(e.path, daten)
} catch {
/* die Galerie faellt aufs Vollbild zurueck */
}
}
logEvent('upload', e.path, e.size, 'user', wer)
ergebnisse.push({ path: e.path, ok: true })
} catch (err) {
ergebnisse.push({
path: e.path,
ok: false,
error: err instanceof Error ? err.message : 'unbekannt',
})
}
}
const gut = ergebnisse.filter((e) => e.ok).length
return c.json({ zurueck: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
})
/** Endgueltig weg. Hier gibt es kein Zurueck mehr, und die Oberflaeche sagt
* das auch. */
dashRoutes.post('/papierkorb/leeren', async (c) => {
const body = await jsonBody<{ ids?: number[] }>(c)
const eintraege = (
Array.isArray(body?.ids) && body.ids.length > 0
? body.ids
.map((id) => db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id))
.filter(Boolean)
: db.prepare('SELECT * FROM papierkorb').all()
) as PapierkorbEintrag[]
let weg = 0
for (const e of eintraege) {
await rm(papierkorbPfad(e.ablage), { force: true })
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
weg++
}
return c.json({ geleert: weg })
})
dashRoutes.get('/speicher', (c) => {
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
const z = db
@@ -1430,7 +1729,24 @@ dashRoutes.get('/speicher', (c) => {
return { tage, files: z.n, bytes: z.bytes }
})
return c.json({ nachArt, groesste, aelteste, doppelte, verschwendet, stufen })
// Die Frage, die der Bericht bisher nicht beantworten konnte.
const nieGeholt = db
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE abrufe = 0')
.get() as { n: number; bytes: number }
const beliebteste = (
db.prepare('SELECT * FROM media WHERE abrufe > 0 ORDER BY abrufe DESC LIMIT 10').all() as Media[]
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
return c.json({
nachArt,
groesste,
aelteste,
doppelte,
verschwendet,
stufen,
nieGeholt,
beliebteste,
})
})
dashRoutes.get('/stats', (c) => {
@@ -1651,13 +1967,15 @@ async function verwaisteSuchen() {
}
}
// Vorschauen ohne Bild. Der Name traegt die Endung des Originals plus
// ".webp" -- deshalb wird sie hier abgeschnitten, um zurueckzurechnen.
const vorschauen: string[] = []
async function thumbsDurchlaufen(rel: string) {
// Abgeleitetes ohne Bild -- Vorschauen UND sparsame Fassungen. Beide liegen
// im selben Baum wie das Original mit ".webp" hintendran, deshalb wird die
// Endung hier abgeschnitten, um zurueckzurechnen, und deshalb reicht EINE
// Schleife fuer beide Ordner.
const abgeleitetOhneBild: string[] = []
async function abgeleitetDurchlaufen(ordner: string, rel: string) {
let eintraege
try {
eintraege = await readdir(join(config.dataDir, 'thumbs', rel), {
eintraege = await readdir(join(config.dataDir, ordner, rel), {
withFileTypes: true,
})
} catch {
@@ -1665,14 +1983,19 @@ async function verwaisteSuchen() {
}
for (const e of eintraege) {
const p = rel ? `${rel}/${e.name}` : e.name
if (e.isDirectory()) await thumbsDurchlaufen(p)
else if (e.isFile()) vorschauen.push(p)
if (e.isDirectory()) await abgeleitetDurchlaufen(ordner, p)
else if (e.isFile() && !bekannt.has(p.replace(/\.webp$/, ''))) {
// MIT Ordner davor: der Aufraeumer muss wissen, welche der
// beiden Dateien er wegwerfen soll -- der Pfad allein ist in
// thumbs/ und webp/ derselbe.
abgeleitetOhneBild.push(`${ordner}/${p}`)
}
}
}
await thumbsDurchlaufen('')
const vorschauOhneBild = vorschauen.filter((p) => !bekannt.has(p.replace(/\.webp$/, '')))
await abgeleitetDurchlaufen('thumbs', '')
await abgeleitetDurchlaufen('webp', '')
return { rows, dateiOhneDatensatz, datensatzOhneDatei, vorschauOhneBild }
return { rows, dateiOhneDatensatz, datensatzOhneDatei, abgeleitetOhneBild }
}
dashRoutes.get('/maintenance/orphans', async (c) => {
@@ -1686,9 +2009,9 @@ dashRoutes.get('/maintenance/orphans', async (c) => {
anzahl: f.datensatzOhneDatei.length,
beispiele: f.datensatzOhneDatei.slice(0, 20).map((r) => r.path),
},
vorschauOhneBild: {
anzahl: f.vorschauOhneBild.length,
beispiele: f.vorschauOhneBild.slice(0, 20),
abgeleitetOhneBild: {
anzahl: f.abgeleitetOhneBild.length,
beispiele: f.abgeleitetOhneBild.slice(0, 20),
},
})
})
@@ -1698,9 +2021,11 @@ dashRoutes.get('/maintenance/orphans', async (c) => {
* 'aufnehmen' Dateien ohne Datensatz kommen in die Datenbank. Sie sind da,
* sie sind ausgeliefert -- sie fehlen nur in der Liste. Das ist
* fast immer das Richtige und loescht NICHTS.
* 'aufraeumen' Datensaetze ohne Datei und verwaiste Vorschauen fliegen raus.
* Hier geht etwas weg, aber nur Verweise auf Dateien, die es
* nicht mehr gibt, und abgeleitete Bilder. */
* 'aufraeumen' Datensaetze ohne Datei und verwaistes Abgeleitetes fliegen
* raus. Hier geht etwas weg, aber nur Verweise auf Dateien, die
* es nicht mehr gibt, und Bilder, die sich neu rechnen lassen --
* deshalb geht das hier AN DER PAPIERKORB VORBEI. Ein Papierkorb
* voller Vorschaubilder waere keine Sicherheit, nur Ballast. */
dashRoutes.post('/maintenance/orphans', async (c) => {
const body = await jsonBody<{ aktion?: string }>(c)
const aktion = body?.aktion ?? 'aufnehmen'
@@ -1718,6 +2043,7 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit)
if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten)
if (lohntWebp(mimeFor(pfad))) await writeWebp(pfad, daten)
logEvent('upload', pfad, daten.length, 'user', wer)
aufgenommen++
} catch (err) {
@@ -1735,8 +2061,8 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
datensaetze++
}
let vorschauen = 0
for (const p of f.vorschauOhneBild) {
await rm(join(config.dataDir, 'thumbs', p), { force: true })
for (const p of f.abgeleitetOhneBild) {
await rm(join(config.dataDir, p), { force: true })
vorschauen++
}
return c.json({ aktion, datensaetze, vorschauen })
@@ -1753,6 +2079,8 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
* Das Ergebnis nennt Zahlen und nicht nur "ok" — ein Knopf, der nach zwanzig
* Sekunden "fertig" sagt, ohne zu sagen WAS fertig ist, ist der Anfang der
* Frage, ob er ueberhaupt etwas getan hat. */
const THUMB_STAPEL = 300
dashRoutes.post('/maintenance/thumbs', async (c) => {
const rows = db.prepare('SELECT path, mime FROM media').all() as Pick<
Media,
@@ -1763,6 +2091,7 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
let vorhanden = 0
let uebersprungen = 0
let fehler = 0
let offen = 0
for (const row of rows) {
if (!kannVorschau(row.mime)) {
@@ -1777,6 +2106,14 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
/* gibt es noch nicht — gleich */
}
// Stapelweise, aus demselben Grund wie unten bei den sparsamen
// Fassungen: nach 90 Sekunden gibt der Proxy auf, und ein 504 auf einen
// Knopf, dessen Arbeit weiterlaeuft, ist eine Luege.
if (erzeugt + fehler >= THUMB_STAPEL) {
offen++
continue
}
try {
const data = await readFile(absolutePath(row.path))
if (await writeThumb(row.path, data)) erzeugt++
@@ -1788,5 +2125,107 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
}
}
return c.json({ erzeugt, vorhanden, uebersprungen, fehler, gesamt: rows.length })
return c.json({
erzeugt,
vorhanden,
uebersprungen,
fehler,
gesamt: rows.length,
offen,
fertig: offen === 0,
})
})
/** Die sparsamen Fassungen nachziehen.
*
* Dasselbe Muster wie bei den Vorschauen und aus demselben Grund: die
* uebernommenen Bilder liegen laengst da, die Umkodierung kam spaeter. Ohne
* diesen Knopf haetten nur neue Uploads eine sparsame Fassung — und genau die
* 3670 Item-Bilder, um die es geht, keine.
*
* DAS ERGEBNIS NENNT BYTES, nicht nur Stueckzahlen. "1240 erzeugt" beantwortet
* die Frage nicht, die man beim Druecken hatte; "spart 118 MB" schon. Dass
* einige NICHT lohnen, steht als eigene Zahl da und ist kein Fehler: bei
* kleinen PNG faellt WebP groesser aus, und dann bleibt es beim Original.
*
* UND ES LAEUFT IN STAPELN -- das ist nicht Vorsicht, das ist nachgemessen.
* Der erste Lauf ueber 3861 Dateien brauchte weit ueber neunzig Sekunden, und
* bei genau neunzig gibt Nginx Proxy Manager auf: `proxy_read_timeout`. Der
* Aufrufer sah einen 504, die Arbeit lief unbemerkt weiter, und der Knopf log
* damit den Anwender an. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer
* als einer ohne Rueckmeldung.
*
* Also: hoechstens WEBP_STAPEL Stueck je Aufruf, der Rest wird gezaehlt und
* als `offen` gemeldet. Die Oberflaeche ruft erneut auf, solange `fertig`
* falsch ist -- und kann dabei endlich sagen, wie weit sie ist. */
const WEBP_STAPEL = 300
dashRoutes.post('/maintenance/webp', async (c) => {
const rows = db.prepare('SELECT path, mime, size FROM media').all() as Pick<
Media,
'path' | 'mime' | 'size'
>[]
let erzeugt = 0
let vorhanden = 0
let uebersprungen = 0
let lohntNicht = 0
let fehler = 0
let vorher = 0
let nachher = 0
let offen = 0
for (const row of rows) {
if (!lohntWebp(row.mime)) {
uebersprungen++
continue
}
try {
const info = await stat(webpPfad(row.path))
vorhanden++
vorher += row.size
nachher += info.size
continue
} catch {
/* gibt es noch nicht — gleich */
}
// DER STAPEL IST VOLL: ab hier wird nur noch GEZAEHLT, nicht mehr
// gerechnet. Das kostet ein stat() je Datei und liefert dafuer eine
// ehrliche Restzahl -- besser als ein "fertig?", das raten muss.
if (erzeugt + lohntNicht + fehler >= WEBP_STAPEL) {
offen++
continue
}
try {
const data = await readFile(absolutePath(row.path))
const groesse = await writeWebp(row.path, data)
if (groesse === null) {
lohntNicht++
} else {
erzeugt++
vorher += row.size
nachher += groesse
}
} catch {
// Ein Datensatz ohne Datei — wie oben ein Zaehler, kein Absturz.
fehler++
}
}
return c.json({
erzeugt,
vorhanden,
uebersprungen,
lohntNicht,
fehler,
gesamt: rows.length,
offen,
fertig: offen === 0,
vorher,
nachher,
gespart: vorher - nachher,
})
})
+3
View File
@@ -31,11 +31,13 @@ import {
extensionFor,
fileExists,
kannVorschau,
lohntWebp,
mimeFor,
publicUrlFor,
sha256,
writeFileAtomic,
writeThumb,
writeWebp,
} from '../storage.js'
type Vars = { token: Token }
@@ -206,6 +208,7 @@ uploadRoutes.post('/upload', async (c) => {
// eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der
// Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck.
if (kannVorschau(mime)) await writeThumb(path, data)
if (lohntWebp(mime)) await writeWebp(path, data)
const timestamp = now()
if (existing) {
+108 -8
View File
@@ -235,21 +235,121 @@ export async function moveFile(von: string, nach: string): Promise<void> {
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 thumbZiel = thumbPath(nach)
await mkdir(dirname(thumbZiel), { recursive: true })
await rename(thumbPath(von), thumbZiel)
} catch {
// Keine Vorschau vorhanden — kein Fehler. Sie laesst sich jederzeit
// neu rechnen.
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
}
}
export async function deleteFile(path: string): Promise<void> {
await rm(absolutePath(path), { force: true })
// Die Vorschau geht mit. Sonst bleibt sie liegen, und beim naechsten Bild
// unter demselben Pfad sieht man das alte.
// 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> {