diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 4c944ea..125331e 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -31,7 +31,8 @@ import { userForSession, verifyPassword, } from '../auth.js' -import { readFile, stat } from 'node:fs/promises' +import { readdir, readFile, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' import { DISCORD_KEYS, ausDatenbank, discordConfig, setSetting } from '../settings.js' import { discordRoutes, redirectUri } from './discord.js' import { @@ -304,12 +305,28 @@ dashRoutes.get('/media', (c) => { const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' + // Die Sortierung kommt vom Aufrufer und geht in SQL -- also NIEMALS + // durchgereicht, sondern gegen eine feste Liste getauscht. Ein Feldname + // aus einer Adresszeile ist die aelteste Einladung ueberhaupt. + const SPALTEN: Record = { + geaendert: 'updated_at', + angelegt: 'created_at', + pfad: 'path', + groesse: 'size', + } + const sortSpalte = SPALTEN[c.req.query('sort') ?? ''] ?? 'updated_at' + const richtung = c.req.query('dir') === 'auf' ? 'ASC' : 'DESC' + // Nach dem Pfad wird ohne Ruecksicht auf Gross- und Kleinschreibung + // sortiert: sonst stuende "Adder" vor "adder" und dazwischen nichts. + const nachSpalte = sortSpalte === 'path' ? 'path COLLATE NOCASE' : sortSpalte + const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as { n: number } const rows = db .prepare( - `SELECT * FROM media ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`, + `SELECT * FROM media ${where} ORDER BY ${nachSpalte} ${richtung}, id DESC + LIMIT ? OFFSET ?`, ) .all(...params, limit, offset) as Media[] @@ -369,6 +386,43 @@ dashRoutes.get('/folders', (c) => { }) }) +/** Nur die IDs zu einer Auswahl — fuer "alle Treffer waehlen". + * + * Ohne das muesste jemand fuenfzehn Seiten durchblaettern und auf jeder + * "Seite waehlen" druecken, um einen Ordner mit 900 Bildern zu leeren. + * + * Gedeckelt auf 5000: das ist mehr, als hier je auf einmal ausgewaehlt wird, + * und verhindert, dass eine Antwort in die Megabyte laeuft. Wer daran stoesst, + * bekommt es GESAGT (gekappt: true) statt still eine halbe Auswahl. + */ +dashRoutes.get('/media/ids', (c) => { + const query = c.req.query('query')?.trim() ?? '' + const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' + const maskiert = (t: string) => t.replace(/[\\%_]/g, (z) => `\\${z}`) + + const bedingungen: string[] = [] + const params: unknown[] = [] + if (query) { + bedingungen.push(`path LIKE ? ESCAPE '\\'`) + params.push(`%${maskiert(query)}%`) + } + if (folder) { + bedingungen.push(`path LIKE ? ESCAPE '\\'`) + params.push(`${maskiert(folder)}/%`) + } + const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' + + const DECKEL = 5000 + const rows = db + .prepare(`SELECT id FROM media ${where} ORDER BY updated_at DESC LIMIT ?`) + .all(...params, DECKEL + 1) as { id: number }[] + + return c.json({ + ids: rows.slice(0, DECKEL).map((r) => r.id), + gekappt: rows.length > DECKEL, + }) +}) + dashRoutes.delete('/media/:id', async (c) => { const id = Number(c.req.param('id')) const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined @@ -835,6 +889,146 @@ dashRoutes.post('/maintenance/prune-sessions', (c) => { return c.json({ ok: true }) }) +/** Was nicht zusammenpasst: Dateien ohne Datensatz, Datensaetze ohne Datei, + * Vorschauen ohne Bild. + * + * ERST ZAEHLEN, DANN FRAGEN. Diese Route veraendert NICHTS -- sie sagt nur, + * was sie sieht, mit Beispielen. Ein Knopf, der ungefragt aufraeumt, ist bei + * einer Ablage ohne Papierkorb der falsche Knopf. + * + * Wie es dazu kommt: jemand loescht im Volume von Hand, ein Upload bricht + * zwischen Datei und Datensatz ab, oder ein Bild wird ausserhalb des Dienstes + * hineinkopiert. Das Sicherungsskript zaehlt beides schon gegeneinander und + * meldet die Abweichung -- reparieren konnte es sie bisher nicht. */ +async function verwaisteSuchen() { + const rows = db.prepare('SELECT id, path, size FROM media').all() as { + id: number + path: string + size: number + }[] + const bekannt = new Map(rows.map((r) => [r.path, r])) + + // Alles, was wirklich auf der Platte liegt. + const aufPlatte: string[] = [] + async function durchlaufen(rel: string) { + let eintraege + try { + eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true }) + } catch { + return + } + for (const e of eintraege) { + const p = rel ? `${rel}/${e.name}` : e.name + if (e.isDirectory()) await durchlaufen(p) + else if (e.isFile()) aufPlatte.push(p) + } + } + await durchlaufen('') + + const dateiOhneDatensatz = aufPlatte.filter((p) => !bekannt.has(p)) + const datensatzOhneDatei: typeof rows = [] + for (const r of rows) { + try { + await stat(absolutePath(r.path)) + } catch { + datensatzOhneDatei.push(r) + } + } + + // 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) { + let eintraege + try { + eintraege = await readdir(join(config.dataDir, 'thumbs', rel), { + withFileTypes: true, + }) + } catch { + return + } + 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) + } + } + await thumbsDurchlaufen('') + const vorschauOhneBild = vorschauen.filter((p) => !bekannt.has(p.replace(/\.webp$/, ''))) + + return { rows, dateiOhneDatensatz, datensatzOhneDatei, vorschauOhneBild } +} + +dashRoutes.get('/maintenance/orphans', async (c) => { + const f = await verwaisteSuchen() + return c.json({ + dateiOhneDatensatz: { + anzahl: f.dateiOhneDatensatz.length, + beispiele: f.dateiOhneDatensatz.slice(0, 20), + }, + datensatzOhneDatei: { + 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), + }, + }) +}) + +/** Aufraeumen -- in zwei Richtungen, und die schonende ist die Voreinstellung. + * + * '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. */ +dashRoutes.post('/maintenance/orphans', async (c) => { + const body = await jsonBody<{ aktion?: string }>(c) + const aktion = body?.aktion ?? 'aufnehmen' + const f = await verwaisteSuchen() + const wer = c.get('user').username + + if (aktion === 'aufnehmen') { + let aufgenommen = 0 + for (const pfad of f.dateiOhneDatensatz) { + try { + const daten = await readFile(absolutePath(pfad)) + const zeit = now() + db.prepare( + `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) + VALUES (?, ?, ?, ?, NULL, ?, ?)`, + ).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit) + if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten) + logEvent('upload', pfad, daten.length, 'user', wer) + aufgenommen++ + } catch (err) { + console.error('[verwaiste] ' + pfad, err) + } + } + return c.json({ aktion, aufgenommen }) + } + + if (aktion === 'aufraeumen') { + let datensaetze = 0 + for (const r of f.datensatzOhneDatei) { + db.prepare('DELETE FROM media WHERE id = ?').run(r.id) + logEvent('delete', r.path, r.size, 'user', wer) + datensaetze++ + } + let vorschauen = 0 + for (const p of f.vorschauOhneBild) { + await rm(join(config.dataDir, 'thumbs', p), { force: true }) + vorschauen++ + } + return c.json({ aktion, datensaetze, vorschauen }) + } + + return c.json({ error: 'unbekannte Aktion — "aufnehmen" oder "aufraeumen"' }, 400) +}) + /** Fehlende Vorschaubilder nachziehen. * * Braucht es, weil die Vorschauen erst spaeter dazukamen: was vorher diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 8cbc18e..1efb550 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -159,6 +159,15 @@ export type Folders = { /** Der billige Fuehler fuer die automatische Auffrischung. */ export type Stand = { medien: number; neuest: number; ereignis: number } +export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' +export type Richtung = 'auf' | 'ab' + +export type Verwaiste = { + dateiOhneDatensatz: { anzahl: number; beispiele: string[] } + datensatzOhneDatei: { anzahl: number; beispiele: string[] } + vorschauOhneBild: { anzahl: number; beispiele: string[] } +} + export type Einstellungen = { discord: { clientId: string @@ -241,10 +250,23 @@ export const api = { changePassword: (current: string, next: string) => post<{ ok: true }>('/api/dash/auth/password', { current, next }), - media: (query: string, folder: string, limit: number, offset: number) => + media: ( + query: string, + folder: string, + limit: number, + offset: number, + sort: Sortierung = 'geaendert', + dir: Richtung = 'ab', + ) => get( `/api/dash/media?query=${encodeURIComponent(query)}` + - `&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}`, + `&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}` + + `&sort=${sort}&dir=${dir}`, + ), + /** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern. */ + medienIds: (query: string, folder: string) => + get<{ ids: number[]; gekappt: boolean }>( + `/api/dash/media/ids?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`, ), folders: () => get('/api/dash/folders'), deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`), @@ -271,6 +293,12 @@ export const api = { stand: () => get('/api/dash/stand'), stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), + verwaiste: () => get('/api/dash/maintenance/orphans'), + verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') => + post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>( + '/api/dash/maintenance/orphans', + { aktion }, + ), tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'), createToken: (name: string, prefix: string, canDelete: boolean) => diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx index dce3c2e..ee69337 100644 --- a/server/ui/src/pages/Galerie.tsx +++ b/server/ui/src/pages/Galerie.tsx @@ -6,6 +6,7 @@ import { Copy, ExternalLink, FileQuestion, + ArrowDownUp, ImageOff, Loader2, RefreshCw, @@ -13,7 +14,7 @@ import { Trash2, X, } from 'lucide-react' -import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage } from '../api' +import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage, type Richtung, type Sortierung } from '../api' import { run, useStore } from '../store' import { bytes, istBild, zeit } from '../format' import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' @@ -30,6 +31,8 @@ export function Galerie() { const [ordner, setOrdner] = useState('') const [ordnerDaten, setOrdnerDaten] = useState(null) const [seite, setSeite] = useState(0) + const [sortierung, setSortierung] = useState('geaendert') + const [richtung, setRichtung] = useState('ab') const [daten, setDaten] = useState(null) const [laedt, setLaedt] = useState(true) const [gewaehlt, setGewaehlt] = useState>(new Set()) @@ -61,7 +64,14 @@ export function Galerie() { const meine = ++lauf.current setLaedt(true) try { - const seiteDaten = await api.media(sucheAktiv, ordner, PRO_SEITE, seite * PRO_SEITE) + const seiteDaten = await api.media( + sucheAktiv, + ordner, + PRO_SEITE, + seite * PRO_SEITE, + sortierung, + richtung, + ) if (meine !== lauf.current) return setDaten(seiteDaten) } catch (err) { @@ -70,7 +80,7 @@ export function Galerie() { } finally { if (meine === lauf.current) setLaedt(false) } - }, [sucheAktiv, ordner, seite, note]) + }, [sucheAktiv, ordner, seite, sortierung, richtung, note]) useEffect(() => { void laden() @@ -119,6 +129,17 @@ export function Galerie() { const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id)) + async function alleTrefferWaehlen() { + const antwort = await run( + () => api.medienIds(sucheAktiv, ordner), + (r) => + r.gekappt + ? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.` + : `${r.ids.length} gewählt.`, + ) + if (antwort) setGewaehlt(new Set(antwort.ids)) + } + function alleUmschalten() { setGewaehlt((alt) => { const neu = new Set(alt) @@ -240,6 +261,35 @@ export function Galerie() { automatisch + + {items.length > 0 && ( )} + {/* Erst anbieten, wenn es ueberhaupt mehr als eine Seite gibt -- + sonst steht dort ein Knopf, der dasselbe tut wie der davor. */} + {gesamt > items.length && ( + void alleTrefferWaehlen()} + kind={`Alle ${gesamt} wählen`} + /> + )} + {gewaehlt.size > 0 && ( s.note) const [stats, setStats] = useState(null) const [laedt, setLaedt] = useState(true) const [zieht, setZieht] = useState(false) + const [verwaiste, setVerwaiste] = useState(null) + const [sucht, setSucht] = useState(false) + const [handelt, setHandelt] = useState(false) + const [fragt, setFragt] = useState<'aufnehmen' | 'aufraeumen' | null>(null) const { kopiert, kopieren } = useKopieren() useEffect(() => { @@ -99,8 +113,119 @@ export function Speicher() { hochgeladen wurde, hat noch keine.

+ +
+
+ { + setSucht(true) + const v = await run( + () => api.verwaiste(), + (r) => + r.dateiOhneDatensatz.anzahl + + r.datensatzOhneDatei.anzahl + + r.vorschauOhneBild.anzahl === + 0 + ? 'Alles passt zusammen.' + : 'Abweichungen gefunden — siehe unten.', + ) + setSucht(false) + if (v) setVerwaiste(v) + }} + kind={ + <> + + Verwaiste suchen + + } + /> +

+ Dateien ohne Datensatz, Datensätze ohne Datei, Vorschauen ohne + Bild. Sucht nur — verändert nichts. +

+
+ + {verwaiste && ( +
+ 0 ? ( + setFragt('aufnehmen')} + kind="In die Liste aufnehmen" + /> + ) : null + } + /> + + + {(verwaiste.datensatzOhneDatei.anzahl > 0 || + verwaiste.vorschauOhneBild.anzahl > 0) && ( +
+ setFragt('aufraeumen')} + kind="Leere Datensätze und Vorschauen entfernen" + /> +
+ )} +
+ )} +
+ setFragt(null)} + onJa={async () => { + if (!fragt) return + setHandelt(true) + await run( + () => api.verwaisteBehandeln(fragt), + (r) => + fragt === 'aufnehmen' + ? `${r.aufgenommen} Dateien in die Liste aufgenommen.` + : `${r.datensaetze} Datensätze und ${r.vorschauen} Vorschauen entfernt.`, + ) + setHandelt(false) + setFragt(null) + const v = await run(() => api.verwaiste(), 'Neu gezählt.') + if (v) setVerwaiste(v) + }} + text={ + fragt === 'aufnehmen' ? ( +

+ Die gefundenen Dateien bekommen einen Datensatz und erscheinen in der + Galerie. Es wird nichts gelöscht und nichts überschrieben. +

+ ) : ( +

+ Datensätze, deren Datei fehlt, werden entfernt — die Dateien sind + ohnehin weg. Verwaiste Vorschauen werden gelöscht; sie lassen sich + jederzeit neu rechnen. +

+ ) + } + /> + {stats.folders.length === 0 ? ( @@ -142,6 +267,41 @@ export function Speicher() { ) } +function Befund({ + titel, + daten, + hinweis, + knopf, +}: { + titel: string + daten: { anzahl: number; beispiele: string[] } + hinweis: string + knopf?: React.ReactNode +}) { + return ( +
+
+ 0 ? 'font-medium text-warn' : 'text-schrift-leise'}> + {daten.anzahl} + + {titel} + {hinweis} + {knopf} +
+ {daten.beispiele.length > 0 && ( +
    + {daten.beispiele.map((b) => ( +
  • {b}
  • + ))} + {daten.anzahl > daten.beispiele.length && ( +
  • … und {daten.anzahl - daten.beispiele.length} weitere
  • + )} +
+ )} +
+ ) +} + function Zahl({ kind, was, wert }: { kind: React.ReactNode; was: string; wert: string }) { return (