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:
+418
-4
@@ -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,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 } : {}),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user