feat: fehlende Bilder finden, Massen-Umbenennen, Bilder verkleinern, Einstellungen mitnehmen

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>
This commit is contained in:
2026-08-12 07:53:31 +02:00
co-authored by Claude Opus 5
parent 29e516559a
commit 381feade4e
4 changed files with 509 additions and 12 deletions
+9
View File
@@ -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. */
+418 -4
View File
@@ -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<string>([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<string, string> = {}
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<string, string>; 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<string, string>()
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<Token, 'hash'>[]
@@ -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<Token, 'hash'>
+31 -8
View File
@@ -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 } : {}),
})
})
+51
View File
@@ -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