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