diff --git a/server/src/db.ts b/server/src/db.ts index 4343689..e92078b 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -205,6 +205,13 @@ CREATE TABLE IF NOT EXISTS settings ( // laengst, aber es galt je Upload. Ein Token mit "hoechstens 2 MB je Bild" // konnte trotzdem die Platte fuellen, es brauchte nur genug Bilder. const kSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[] + // Groesste erlaubte Kantenlaenge fuer Bilder ueber diesen Token. Null = + // unveraendert ablegen. + if (!kSpalten.some((s) => s.name === 'max_kante')) { + db.exec('ALTER TABLE tokens ADD COLUMN max_kante INTEGER') + console.log('[db] Spalte tokens.max_kante angelegt') + } + if (!kSpalten.some((s) => s.name === 'quota_bytes')) { db.exec('ALTER TABLE tokens ADD COLUMN quota_bytes INTEGER') console.log('[db] Spalte tokens.quota_bytes angelegt') @@ -339,6 +346,8 @@ export type Token = { max_bytes: number | null /** Wieviel dieser Token INSGESAMT halten darf. Null = so viel er will. */ quota_bytes: number | null + /** Groesste Kantenlaenge fuer Bilder. Null = ablegen, wie es kommt. */ + max_kante: number | null /** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */ arten: string /** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */ diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index d31fee2..a4caedd 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -1811,6 +1811,397 @@ dashRoutes.delete('/folders/:pfad{.+}', async (c) => { * * Ohne Deckel: wer 900 Zeilen exportiert, will 900 Zeilen. Bei 228 Eintraegen * sind das 40 KB. */ +// -------------------------------------------------- Einstellungen mitnehmen +// +// WOZU. Discord-Zugang, Sicherungsziel, Adressvorlagen, Meldungsanlaesse, +// Marke -- alles steht in `settings` und waere beim Neuaufsetzen von Hand +// nachzutragen. Eine Stunde Klickerei, bei der man genau die eine Zeile +// vergisst, die man erst drei Wochen spaeter braucht. +// +// GEHEIMNISSE GEHEN NICHT MIT, und zwar standardmaessig nicht. Unter den +// Schluesseln liegen das Discord-Client-Geheimnis und das Nextcloud-Passwort. +// Eine Datei, die man "mal eben" exportiert und in einen Chat zieht, ist genau +// der Weg, auf dem so etwas abhandenkommt. +// +// Wer sie WIRKLICH braucht, sagt es ausdruecklich -- und bekommt sie mit einem +// Hinweis, der in der Datei steht und nicht nur auf dem Bildschirm stand. + +/** Was ein Geheimnis ist. Beim Ergaenzen einer Einstellung hier mitdenken. */ +const GEHEIM = new Set([DISCORD_KEYS.clientSecret, SICHERUNG_KEYS.passwort]) + +/** Was gar nicht erst mitgenommen wird -- Betriebszustand, keine Einstellung. + * + * `sicherung.letzte` etwa: das ist der Bericht des letzten Laufs. In einer + * frischen Anlage einzuspielen hiesse, dem Wachhund eine Sicherung zu + * behaupten, die es dort nie gab. */ +const NICHT_MITNEHMEN = [/^sicherung\.letzte$/, /^discord\.zuletzt/] + +dashRoutes.get('/einstellungen/export', (c) => { + const mitGeheim = c.req.query('geheim') === '1' + const alle = db.prepare('SELECT key, value FROM settings ORDER BY key').all() as { + key: string + value: string + }[] + + const werte: Record = {} + const ausgelassen: string[] = [] + for (const z of alle) { + if (NICHT_MITNEHMEN.some((r) => r.test(z.key))) continue + if (GEHEIM.has(z.key) && !mitGeheim) { + if (z.value) ausgelassen.push(z.key) + continue + } + werte[z.key] = z.value + } + + if (mitGeheim) { + logVerwaltung(c.get('user').username, 'einstellung', 'geaendert', 'Export MIT Geheimnissen') + } + + return c.json( + { + dienst: 'd4rk_media', + stand: new Date(now()).toISOString(), + // Der Hinweis steht IN der Datei und nicht nur auf dem Bildschirm, + // von dem er beim Weiterreichen abfaellt. + hinweis: mitGeheim + ? 'ENTHAELT GEHEIMNISSE (Discord-Client-Geheimnis, Nextcloud-Passwort). Nicht weitergeben, nicht in ein Repository legen.' + : 'Ohne Geheimnisse. Discord-Client-Geheimnis und Nextcloud-Passwort fehlen und sind nach dem Einspielen von Hand nachzutragen.', + ausgelassen, + werte, + }, + 200, + { + 'Content-Disposition': `attachment; filename="d4rk_media-einstellungen-${new Date(now()).toISOString().slice(0, 10)}.json"`, + 'Cache-Control': 'no-store', + }, + ) +}) + +/** Wieder einspielen. + * + * LEERE WERTE UEBERSCHREIBEN NICHTS. Eine Datei ohne Geheimnisse -- der + * Normalfall -- traegt sie als leere Zeichenkette oder gar nicht; beides darf + * ein vorhandenes Passwort nicht loeschen. Wer wirklich leeren will, tut das + * im Formular, wo er sieht, was verschwindet. + */ +dashRoutes.post('/einstellungen/import', async (c) => { + const body = await jsonBody<{ werte?: Record; ueberschreiben?: boolean }>(c) + if (!body?.werte || typeof body.werte !== 'object') { + return c.json({ error: 'Keine Werte dabei — erwartet wird das Feld "werte".' }, 400) + } + + const gesetzt: string[] = [] + const uebersprungen: string[] = [] + + for (const [key, wert] of Object.entries(body.werte)) { + if (typeof wert !== 'string') continue + if (NICHT_MITNEHMEN.some((r) => r.test(key))) { + uebersprungen.push(key) + continue + } + if (!wert.trim()) { + uebersprungen.push(key) + continue + } + // Ohne `ueberschreiben` bleibt stehen, was schon dasteht: ein Import in + // eine laufende Anlage soll ergaenzen und nicht ueberraschen. + if (!body.ueberschreiben && getSetting(key)) { + uebersprungen.push(key) + continue + } + setSetting(key, wert) + gesetzt.push(key) + } + + logVerwaltung( + c.get('user').username, + 'einstellung', + 'geaendert', + 'Import', + `${gesetzt.length} gesetzt, ${uebersprungen.length} uebersprungen`, + ) + + return c.json({ gesetzt, uebersprungen }) +}) + +/** Suchen und Ersetzen ueber viele Pfade -- mit Vorschau. + * + * Bei 3578 Items ist eine Umbenennung von Hand keine. Der Anlass waere etwa + * "items/" nach "inventar/", oder eine geaenderte Namenskonvention. + * + * DIE VORSCHAU IST DER EIGENTLICHE TEIL, nicht die Bequemlichkeit dahinter. + * Was hier passiert, ist dieselbe Warnung wie beim Umbenennen eines Ordners -- + * jede alte Adresse gibt danach 404 --, nur mal tausend. Wer das ohne + * Gegenlesen ausloest, hat keine Gelegenheit mehr, es zu bemerken. + * + * Deshalb ist `ausfuehren` ausdruecklich zu setzen. Ohne das Feld wird + * gerechnet und gezeigt, nicht angefasst. + * + * GEPRUEFT WIRD VOR DEM ERSTEN SCHRITT, nicht waehrenddessen: Kollisionen + * untereinander, belegte Ziele, unzulaessige Pfade. Ein Lauf, der bei Nummer + * 700 abbricht, hinterlaesst 699 umbenannte und 2878 nicht -- und niemand + * weiss mehr, wo die Grenze lag. + */ +dashRoutes.post('/media/ersetzen', async (c) => { + const body = await jsonBody<{ + suchen?: string + ersetzen?: string + ordner?: string + ausfuehren?: boolean + }>(c) + if (!body) return c.json(KEIN_JSON, 400) + if (!body.suchen) return c.json({ error: 'Nichts zu suchen' }, 400) + + const suchen = body.suchen + const ersetzen = body.ersetzen ?? '' + + let ordner = '' + if (body.ordner?.trim()) { + try { + ordner = checkPath(body.ordner.trim().replace(/^\/+|\/+$/g, '')) + } catch (err) { + if (err instanceof PathError) return c.json({ error: `Ordner: ${err.message}` }, 400) + throw err + } + } + + const maskiert = (t: string) => t.replace(/[\\%_]/g, (z) => `\\${z}`) + const zeilen = ( + ordner + ? (db + .prepare( + `SELECT id, path, size, abrufe FROM media WHERE path LIKE ? ESCAPE '\\' ORDER BY path`, + ) + .all(`${maskiert(ordner)}/%`) as Media[]) + : (db.prepare('SELECT id, path, size, abrufe FROM media ORDER BY path').all() as Media[]) + ).filter((r) => r.path.includes(suchen)) + + if (zeilen.length === 0) { + return c.json({ error: `Kein Pfad enthaelt "${suchen}".` }, 404) + } + if (zeilen.length > 5000) { + return c.json({ error: `${zeilen.length} Treffer — hoechstens 5000 auf einmal.` }, 400) + } + + // -- Planen und pruefen, alles vor dem ersten Schritt --------------------- + const plan: { id: number; alt: string; neu: string; abrufe: number }[] = [] + const probleme: { alt: string; grund: string }[] = [] + // Die Ziele untereinander: zwei Dateien, die auf denselben neuen Pfad + // fallen, wuerden sonst die eine die andere ueberschreiben -- und der + // Verlauf zeigte zwei Umbenennungen, von denen eine eine Loeschung war. + const zieleGesehen = new Map() + + for (const r of zeilen) { + const neuerPfad = r.path.split(suchen).join(ersetzen) + + if (neuerPfad === r.path) continue + if (!neuerPfad.trim()) { + probleme.push({ alt: r.path, grund: 'ergaebe einen leeren Pfad' }) + continue + } + + let gueltig: string + try { + gueltig = checkPath(neuerPfad) + } catch (err) { + probleme.push({ + alt: r.path, + grund: err instanceof PathError ? err.message : 'unbrauchbar', + }) + continue + } + + const schon = zieleGesehen.get(gueltig) + if (schon) { + probleme.push({ alt: r.path, grund: `faellt mit ${schon} auf denselben Pfad` }) + continue + } + + // Belegt von einer Datei, die NICHT mit umbenannt wird. + const belegt = db.prepare('SELECT id FROM media WHERE path = ?').get(gueltig) as + | { id: number } + | undefined + if (belegt && !zeilen.some((z) => z.id === belegt.id)) { + probleme.push({ alt: r.path, grund: `${gueltig} ist belegt` }) + continue + } + + zieleGesehen.set(gueltig, r.path) + plan.push({ id: r.id, alt: r.path, neu: gueltig, abrufe: r.abrufe }) + } + + const geholt = plan.reduce((s, p) => s + p.abrufe, 0) + + // -- Nur zeigen ----------------------------------------------------------- + if (!body.ausfuehren) { + return c.json({ + vorschau: true, + treffer: zeilen.length, + plan: plan.slice(0, 200), + planGesamt: plan.length, + probleme, + // Die Zahl, die die Entscheidung traegt: wie oft die betroffenen + // Dateien insgesamt geholt wurden. Null heisst, es schaut niemand + // zu; viertausend heisst, es schaut jemand zu. + abrufeGesamt: geholt, + }) + } + + // -- Ein Problem verhindert ALLES ---------------------------------------- + // + // Nicht "das Machbare machen": eine halbe Umbenennung ist der Zustand, aus + // dem niemand mehr herausfindet, weil die Grenze nirgends steht. + if (probleme.length > 0) { + return c.json( + { + error: `${probleme.length} von ${zeilen.length} gehen nicht — deshalb wurde nichts angefasst.`, + probleme, + }, + 409, + ) + } + if (plan.length === 0) return c.json({ error: 'Nichts zu tun' }, 400) + + const wer = c.get('user').username + const zeit = now() + const fertig: { alt: string; neu: string }[] = [] + const gescheitert: { alt: string; grund: string }[] = [] + + for (const p of plan) { + try { + await moveFile(p.alt, p.neu) + db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run( + p.neu, + zeit, + p.id, + ) + fertig.push({ alt: p.alt, neu: p.neu }) + } catch (err) { + gescheitert.push({ + alt: p.alt, + grund: err instanceof Error ? err.message : 'unbekannt', + }) + } + } + + // EIN Eintrag und nicht dreitausend. Wer nachliest, will "items -> inventar, + // 3578 Dateien" lesen und nicht dreitausend Zeilen davon. + logEvent('rename', ersetzen || '(entfernt)', 0, 'user', wer, suchen) + logVerwaltung( + wer, + 'ordner', + 'geaendert', + ordner || '(alles)', + `"${suchen}" -> "${ersetzen}", ${fertig.length} Datei(en)`, + ) + + return c.json({ umbenannt: fertig.length, gescheitert, beispiele: fertig.slice(0, 20) }) +}) + +/** Wozu fehlt ein Bild? + * + * DIE FRAGE, DIE BISHER NIEMAND BEANTWORTET HAT. Das Fotostudio weiss, was es + * aufgenommen hat. Dieser Dienst weiss, was liegt. Die Differenz rechnet + * keiner aus -- und deshalb faellt "das Handy zeigt bei manchen Autos kein + * Bild" erst im Spiel auf. + * + * Hereingereicht wird eine Liste von Namen und ein Muster mit genau EINEM + * Platzhalter -- dasselbe Muster, das unter Adressvorlagen steht. Heraus kommt, + * was fehlt, und was umgekehrt daliegt, ohne in der Liste zu stehen. + * + * BEIDE RICHTUNGEN, weil beide etwas anderes bedeuten: fehlende Bilder sind + * Arbeit fuer das Fotostudio, ueberzaehlige sind Fahrzeuge, die es im Spiel + * nicht mehr gibt -- und damit Platz, den man zurueckbekommt. + */ +dashRoutes.post('/fehlende', async (c) => { + const body = await jsonBody<{ muster?: string; namen?: string[] | string }>(c) + if (!body?.muster?.trim()) return c.json({ error: 'Kein Muster angegeben' }, 400) + + const muster = body.muster.trim().replace(/^\/+/, '') + const platzhalter = muster.match(/\{[a-zA-Z]+\}/g) ?? [] + if (platzhalter.length !== 1) { + return c.json( + { + error: + platzhalter.length === 0 + ? 'Im Muster fehlt der Platzhalter, z. B. vehicles/{model}.webp' + : `Genau ein Platzhalter, nicht ${platzhalter.length}. Sonst ist nicht klar, wofuer die Namen stehen.`, + }, + 400, + ) + } + + // Die Namen kommen als Liste oder als Textblock aus einem Textfeld -- wer + // eine Spalte aus einer Tabelle kopiert, hat Zeilenumbrueche, wer aus einem + // Skript kommt, hat ein Array. + const roh = Array.isArray(body.namen) + ? body.namen + : String(body.namen ?? '').split(/[\r\n,;]+/) + const namen = [...new Set(roh.map((n) => String(n).trim()).filter(Boolean))] + if (namen.length === 0) return c.json({ error: 'Keine Namen dabei' }, 400) + if (namen.length > 5000) return c.json({ error: 'Hoechstens 5000 Namen auf einmal' }, 400) + + const wo = platzhalter[0] as string + // Der Ordner ist der Teil vor dem Platzhalter -- daran erkennen wir, was + // "ueberzaehlig" ueberhaupt heisst. Ohne ihn waere jede Datei im Dienst + // ueberzaehlig, und die Antwort waere unbrauchbar. + const bisPlatzhalter = muster.slice(0, muster.indexOf(wo)) + const ordner = bisPlatzhalter.includes('/') + ? bisPlatzhalter.slice(0, bisPlatzhalter.lastIndexOf('/')) + : '' + + const vorhanden = db.prepare('SELECT 1 FROM media WHERE path = ?') + const fehlt: { name: string; pfad: string }[] = [] + const da: string[] = [] + const unbrauchbar: { name: string; grund: string }[] = [] + + for (const name of namen) { + // KLEINGESCHRIEBEN gesucht, weil so abgelegt wird. Wer "WEAPON_SMG" + // hereinreicht, meint weapon_smg.png -- und bekaeme sonst ein "fehlt", + // das gelogen waere. + const pfad = muster.replace(wo, name.toLowerCase()) + try { + const gueltig = checkPath(pfad) + if (vorhanden.get(gueltig)) da.push(gueltig) + else fehlt.push({ name, pfad: gueltig }) + } catch (err) { + unbrauchbar.push({ + name, + grund: err instanceof PathError ? err.message : 'unbrauchbar', + }) + } + } + + // Die Gegenrichtung: was liegt in diesem Ordner, ohne in der Liste zu + // stehen? Nur Dateien, die auf das Muster passen -- eine README im selben + // Ordner ist nicht ueberzaehlig, sie gehoert nur nicht dazu. + const endung = muster.slice(muster.indexOf(wo) + wo.length) + const gesucht = new Set(da) + const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`) + const imOrdner = ordner + ? (db + .prepare(`SELECT path FROM media WHERE path LIKE ? ESCAPE '\\'`) + .all(`${maskiert}/%`) as { path: string }[]) + : [] + const ueberzaehlig = imOrdner + .map((r) => r.path) + .filter((pfad) => pfad.endsWith(endung) && !gesucht.has(pfad)) + + return c.json({ + muster, + geprueft: namen.length, + da: da.length, + fehlt, + // Gekappt, damit die Antwort nicht zur Datenhalde wird -- die Zahl + // steht daneben, damit die Kappung nicht als Vollstaendigkeit + // durchgeht. + ueberzaehlig: ueberzaehlig.slice(0, 500), + ueberzaehligGesamt: ueberzaehlig.length, + unbrauchbar, + }) +}) + dashRoutes.get('/export', (c) => { const form = c.req.query('format') ?? 'json' const ordner = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' @@ -2282,7 +2673,7 @@ dashRoutes.get('/tokens', (c) => { const rows = db .prepare( `SELECT id, name, prefix, can_delete, created_at, last_used_at, - max_bytes, quota_bytes, arten, expires_at + max_bytes, quota_bytes, max_kante, arten, expires_at FROM tokens ORDER BY created_at DESC`, ) .all() as Omit[] @@ -2319,6 +2710,7 @@ dashRoutes.post('/tokens', async (c) => { canDelete?: boolean maxMb?: number | string quotaMb?: number | string + maxKante?: number | string arten?: string[] gueltigTage?: number | string }>(c) @@ -2359,6 +2751,17 @@ dashRoutes.post('/tokens', async (c) => { quotaBytes = Math.round(mb * 1024 * 1024) } + // Die groesste Kantenlaenge fuer Bilder. Kein Schutz, sondern Platz und + // Ladezeit: ein Bildschirmfoto ist 3840 breit und wird auf 400 gezeigt. + let maxKante: number | null = null + if (body.maxKante !== undefined && String(body.maxKante).trim() !== '') { + const px = Number(body.maxKante) + if (!Number.isFinite(px) || px < 16) { + return c.json({ error: 'Kantenlaenge: eine Zahl ab 16' }, 400) + } + maxKante = Math.round(px) + } + const ERLAUBTE_ARTEN = ['bild', 'video', 'ton', 'dokument', 'andere'] let arten = '' if (Array.isArray(body.arten) && body.arten.length > 0) { @@ -2394,8 +2797,8 @@ dashRoutes.post('/tokens', async (c) => { const info = db .prepare( `INSERT INTO tokens (name, hash, prefix, can_delete, created_at, - max_bytes, quota_bytes, arten, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + max_bytes, quota_bytes, max_kante, arten, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( name.trim(), @@ -2405,6 +2808,7 @@ dashRoutes.post('/tokens', async (c) => { now(), maxBytes, quotaBytes, + maxKante, arten, expiresAt, ) @@ -2451,6 +2855,7 @@ dashRoutes.patch('/tokens/:id', async (c) => { canDelete?: boolean maxMb?: number | string | null quotaMb?: number | string | null + maxKante?: number | string | null arten?: string[] | null gueltigTage?: number | string | null }>(c) @@ -2498,6 +2903,15 @@ dashRoutes.patch('/tokens/:id', async (c) => { feld.max_bytes = zahl(body.maxMb, 'Groessengrenze', config.maxUploadBytes) } if (body.quotaMb !== undefined) feld.quota_bytes = zahl(body.quotaMb, 'Kontingent') + if (body.maxKante !== undefined) { + if (body.maxKante === null || String(body.maxKante).trim() === '') { + feld.max_kante = null + } else { + const px = Number(body.maxKante) + if (!Number.isFinite(px) || px < 16) throw new Error('Kantenlaenge: eine Zahl ab 16') + feld.max_kante = Math.round(px) + } + } } catch (err) { return c.json({ error: err instanceof Error ? err.message : 'ungueltige Zahl' }, 400) } @@ -2537,7 +2951,7 @@ dashRoutes.patch('/tokens/:id', async (c) => { const neu = db .prepare( `SELECT id, name, prefix, can_delete, created_at, last_used_at, - max_bytes, quota_bytes, arten, expires_at + max_bytes, quota_bytes, max_kante, arten, expires_at FROM tokens WHERE id = ?`, ) .get(id) as Omit diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index f8a4282..b38a1e9 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -31,6 +31,7 @@ import { extensionFor, fileExists, kannBewegtVorschau, + vielleichtKleiner, kannVorschau, lohntWebp, mimeFor, @@ -266,8 +267,27 @@ uploadRoutes.post('/upload', async (c) => { } } + // VOR dem Schreiben verkleinern, wenn der Token es verlangt. Danach waere + // es zu spaet: dann laege das grosse Bild schon auf der Platte, und der + // Hash im Datensatz gehoerte zu einer anderen Datei als der abgelegten. + let daten = data + let verkleinert: false | { von: string; auf: string } = false + if (token.max_kante) { + const r = await vielleichtKleiner(data, mime, token.max_kante) + daten = r.data + verkleinert = r.verkleinert + } + + // DER HASH GEHOERT ZU DEM, WAS WIRKLICH LIEGT. Weiter oben wird er aus dem + // Original gerechnet -- der braucht ihn fuer den Ersatzpfad, wenn kein + // X-Path kam. Wird das Bild verkleinert, ist das eine ANDERE Datei, und ein + // Datensatz mit dem Hash des Originals waere schlicht falsch: der + // Verwaisten-Sucher und jede spaetere Gegenprobe verglichen dann gegen + // etwas, das nirgends liegt. + const digestAbgelegt = verkleinert ? sha256(daten) : digest + try { - await writeFileAtomic(path, data) + await writeFileAtomic(path, daten) } catch (err) { console.error('[upload] Schreiben fehlgeschlagen:', err) return c.json({ error: 'Schreiben fehlgeschlagen' }, 500) @@ -276,8 +296,8 @@ uploadRoutes.post('/upload', async (c) => { // Die Vorschau gleich mit. Bewusst NACH dem eigentlichen Schreiben und mit // 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) + if (kannVorschau(mime)) await writeThumb(path, daten) + if (lohntWebp(mime)) await writeWebp(path, daten) // Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und // deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge faellt @@ -292,23 +312,26 @@ uploadRoutes.post('/upload', async (c) => { db.prepare( `UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ? WHERE id = ?`, - ).run(data.length, digest, mime, dauer, token.id, timestamp, existing.id) + ).run(daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, existing.id) } else { db.prepare( `INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(path, data.length, digest, mime, dauer, token.id, timestamp, timestamp) + ).run(path, daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, timestamp) } - logEvent(existing ? 'replace' : 'upload', path, data.length, 'token', token.name) + logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name) return c.json({ url: publicUrlFor(path), path, - size: data.length, - sha256: digest, + size: daten.length, + sha256: digestAbgelegt, mime, replaced: Boolean(existing), + // Nur dann, wenn wirklich gerechnet wurde -- ein Feld, das immer + // dasteht, sagt nichts. + ...(verkleinert ? { verkleinert } : {}), }) }) diff --git a/server/src/storage.ts b/server/src/storage.ts index 6147b0d..63fc069 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -170,6 +170,57 @@ export const sha256 = (data: Buffer) => createHash('sha256').update(data).digest 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