diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 125331e..6d07ecc 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -805,6 +805,68 @@ dashRoutes.get('/stand', (c) => { return c.json({ medien: m.n, neuest: m.neuest, ereignis: e.letzte }) }) +/** Alles fuer die Startseite in EINER Antwort. + * + * Vier Einzelabfragen beim Oeffnen waeren vier Runden ueber den Proxy, nur um + * eine Uebersicht zu zeigen. Und sie koennten auseinanderlaufen: die Zahl der + * Dateien aus der einen und die letzten Ereignisse aus der naechsten Antwort + * sind zwei verschiedene Zeitpunkte. */ +dashRoutes.get('/uebersicht', (c) => { + const m = db + .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') + .get() as { files: number; bytes: number } + + const heute = now() - 86_400_000 + const woche = now() - 7 * 86_400_000 + + const neu = db.prepare('SELECT COUNT(*) AS n FROM events WHERE kind = ? AND at > ?') + const heuteHoch = (neu.get('upload', heute) as { n: number }).n + const wocheHoch = (neu.get('upload', woche) as { n: number }).n + + const letzte = db + .prepare('SELECT * FROM events ORDER BY at DESC, id DESC LIMIT 8') + .all() as MediaEvent[] + + // Die letzten Bilder fuer eine Streifenvorschau. Nur Bilder: ein PDF als + // graue Kachel in einer Uebersicht ist Fuellmaterial. + const bilder = ( + db + .prepare( + `SELECT * FROM media WHERE mime LIKE 'image/%' + ORDER BY updated_at DESC LIMIT 12`, + ) + .all() as Media[] + ).map((row) => ({ ...row, url: publicUrlFor(row.path) })) + + const ordner = db + .prepare( + `SELECT CASE WHEN instr(path, '/') > 0 + THEN substr(path, 1, instr(path, '/') - 1) + ELSE '(Wurzel)' END AS folder, + COUNT(*) AS files, SUM(size) AS bytes + FROM media GROUP BY folder ORDER BY bytes DESC LIMIT 5`, + ) + .all() as { folder: string; files: number; bytes: number }[] + + const tokens = db + .prepare('SELECT id, name, prefix, can_delete, last_used_at FROM tokens ORDER BY last_used_at DESC') + .all() as Pick[] + + return c.json({ + files: m.files, + bytes: m.bytes, + heuteHoch, + wocheHoch, + publicUrl: config.publicUrl, + filePrefix: config.filePrefix, + letzte, + bilder, + ordner, + tokens, + discord: discordConfig().bereit, + }) +}) + dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index c0bf0ad..f93ce39 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -1,47 +1,35 @@ -import { useEffect, useState } from 'react' -import { HardDrive, History, Images, KeyRound, LogOut, PieChart, UserCog } from 'lucide-react' +import { useEffect } from 'react' +import { + Gauge, + HardDrive, + History, + Images, + KeyRound, + LogOut, + PieChart, + UserCog, +} from 'lucide-react' import { run, useStore } from './store' import { Anmelden } from './pages/Anmelden' +import { Start } from './pages/Start' import { Galerie } from './pages/Galerie' import { Speicher } from './pages/Speicher' import { Tokens } from './pages/Tokens' import { Konto } from './pages/Konto' import { Verlauf } from './pages/Verlauf' import { Meldungen } from './components/Meldungen' +import { gehZu, usePfad } from './router' import { Avatar } from './components/Avatar' -// Ein winziger Router über die History-API statt einer Bibliothek. -// -// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut -// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein -// Neuladen auf /tokens im Router landet und nicht im 404. - const seiten = [ - { pfad: '/', name: 'Galerie', kind: Images, Inhalt: Galerie }, + { pfad: '/', name: 'Start', kind: Gauge, Inhalt: Start }, + { pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie }, { pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf }, { pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher }, { pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens }, { pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto }, ] as const -function usePfad(): [string, (p: string) => void] { - const [pfad, setPfad] = useState(window.location.pathname) - - useEffect(() => { - const zurueck = () => setPfad(window.location.pathname) - window.addEventListener('popstate', zurueck) - return () => window.removeEventListener('popstate', zurueck) - }, []) - - return [ - pfad, - (p: string) => { - window.history.pushState(null, '', p) - setPfad(p) - }, - ] -} - export function App() { // Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne // Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das @@ -50,7 +38,7 @@ export function App() { const checking = useStore((s) => s.checking) const checkSession = useStore((s) => s.checkSession) const logout = useStore((s) => s.logout) - const [pfad, gehZu] = usePfad() + const pfad = usePfad() useEffect(() => { void checkSession() diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 1efb550..253f8bb 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -159,6 +159,20 @@ export type Folders = { /** Der billige Fuehler fuer die automatische Auffrischung. */ export type Stand = { medien: number; neuest: number; ereignis: number } +export type Uebersicht = { + files: number + bytes: number + heuteHoch: number + wocheHoch: number + publicUrl: string + filePrefix: string + letzte: MediaEvent[] + bilder: MediaItem[] + ordner: { folder: string; files: number; bytes: number }[] + tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[] + discord: boolean +} + export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' export type Richtung = 'auf' | 'ab' @@ -290,6 +304,7 @@ export const api = { einstellungenSpeichern: (discord: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }), + uebersicht: () => get('/api/dash/uebersicht'), stand: () => get('/api/dash/stand'), stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), diff --git a/server/ui/src/pages/Start.tsx b/server/ui/src/pages/Start.tsx new file mode 100644 index 0000000..c616066 --- /dev/null +++ b/server/ui/src/pages/Start.tsx @@ -0,0 +1,274 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Check, + Copy, + Folder, + HardDrive, + Images, + KeyRound, + Loader2, + RefreshCw, + ShieldCheck, + Trash2, + TrendingUp, + Upload, +} from 'lucide-react' +import { api, vorschauUrl, type EventKind, type Uebersicht } from '../api' +import { useStore } from '../store' +import { bytes, zeit } from '../format' +import { Karte, Leer, useKopieren } from '../components/Bausteine' +import { Avatar } from '../components/Avatar' +import { useAuffrischen } from '../auffrischen' +import { gehZu } from '../router' + +const arten: Record = { + upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload }, + replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw }, + delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 }, +} + +/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich. + * + * Bisher landete man in der Galerie — mit 228 Kacheln und ohne Antwort auf + * die erste Frage, die man beim Öffnen hat: läuft alles? Diese Seite + * beantwortet sie in einem Blick und verlinkt den Rest. */ +export function Start() { + const note = useStore((s) => s.note) + const user = useStore((s) => s.user) + const [daten, setDaten] = useState(null) + const [laedt, setLaedt] = useState(true) + const { kopiert, kopieren } = useKopieren() + + const laden = useCallback(async () => { + try { + setDaten(await api.uebersicht()) + } catch (err) { + note('fehler', err instanceof Error ? err.message : 'Unbekannt') + } finally { + setLaedt(false) + } + }, [note]) + + useEffect(() => { + void laden() + }, [laden]) + + // Hier zieht nichts weg: die Seite hat keine Auswahl und kein offenes + // Fenster, sie darf sich jederzeit erneuern. + useAuffrischen({ laden: () => void laden(), blockiert: false }) + + if (laedt) { + return ( +
+ +
+ ) + } + if (!daten) return + + const vorlage = `${daten.publicUrl}${daten.filePrefix}/vehicles/{model}.webp` + + return ( +
+
+ +
+

+ Hallo {user?.discordName ?? user?.username ?? ''} +

+

+ {daten.files} Dateien · {bytes(daten.bytes)} belegt +

+
+
+ +
+ } + was="Dateien" + wert={String(daten.files)} + onClick={() => gehZu('/galerie')} + /> + } was="Belegt" wert={bytes(daten.bytes)} /> + } + was="Neu heute" + wert={String(daten.heuteHoch)} + unten={`${daten.wocheHoch} in sieben Tagen`} + onClick={() => gehZu('/verlauf')} + /> + } + was="Token" + wert={String(daten.tokens.length)} + onClick={() => gehZu('/tokens')} + /> +
+ + {/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */} + +
+ + {vorlage} + + +
+
+ +
+ + {daten.bilder.length === 0 ? ( + + ) : ( +
+ {daten.bilder.map((b) => ( + + ))} +
+ )} +
+ + + {daten.letzte.length === 0 ? ( + + ) : ( +
    + {daten.letzte.map((e) => { + const a = arten[e.kind] + const Kind = a.kind + return ( +
  • + + + {e.path} + + + {zeit(e.at)} + +
  • + ) + })} +
+ )} +
+
+ +
+ }> + {daten.ordner.length === 0 ? ( + + ) : ( +
    + {daten.ordner.map((o) => ( +
  • + + {o.folder} + + + {o.files} Dateien + + + {bytes(o.bytes)} + +
  • + ))} +
+ )} +
+ + +
    + {daten.tokens.map((t) => ( +
  • + + {t.name} + + {t.last_used_at ? `zuletzt ${zeit(t.last_used_at)}` : 'nie benutzt'} + +
  • + ))} +
  • + + Discord-Anmeldung {daten.discord ? 'eingerichtet' : 'nicht eingerichtet'} +
  • +
+
+
+
+ ) +} + +function Kachel({ + kind, + was, + wert, + unten, + onClick, +}: { + kind: React.ReactNode + was: string + wert: string + unten?: string + onClick?: () => void +}) { + const inhalt = ( + <> +
+ {kind} + {was} +
+

{wert}

+ {unten &&

{unten}

} + + ) + + // Nur was wirklich irgendwohin fuehrt, wird ein Knopf. Eine Kachel, die + // aussieht wie ein Knopf und nichts tut, ist der Anfang des Misstrauens. + return onClick ? ( + + ) : ( +
{inhalt}
+ ) +} diff --git a/server/ui/src/router.ts b/server/ui/src/router.ts new file mode 100644 index 0000000..a63c950 --- /dev/null +++ b/server/ui/src/router.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' + +// Ein winziger Router über die History-API statt einer Bibliothek. +// +// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut +// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein +// Neuladen auf /tokens im Router landet und nicht im 404. +// +// `gehZu` steht hier als MODULFUNKTION und nicht als Eigenschaft der Seiten. +// Der Unterschied ist mehr als Geschmack: sobald eine einzige Seite eine +// Navigationsfunktion durchgereicht bekäme, hätten alle Seiten verschiedene +// Signaturen — und die gemeinsame Liste in App.tsx ginge nicht mehr. Genau +// daran ist der erste Versuch gescheitert. + +/** Woanders hin, ohne die Seite neu zu laden. */ +export function gehZu(pfad: string) { + if (window.location.pathname === pfad) return + window.history.pushState(null, '', pfad) + // pushState löst KEIN popstate aus — das tut nur der Zurück-Knopf. Ohne + // dieses Ereignis bliebe die Ansicht stehen, obwohl die Adresse wechselt. + window.dispatchEvent(new PopStateEvent('popstate')) +} + +export function usePfad(): string { + const [pfad, setPfad] = useState(window.location.pathname) + + useEffect(() => { + const zurueck = () => setPfad(window.location.pathname) + window.addEventListener('popstate', zurueck) + return () => window.removeEventListener('popstate', zurueck) + }, []) + + return pfad +} diff --git a/tools/sichern.ps1 b/tools/sichern.ps1 index 11d112e..aefaf36 100644 --- a/tools/sichern.ps1 +++ b/tools/sichern.ps1 @@ -16,7 +16,16 @@ # 3. Das Archiv wird GEPRUEFT: ausgepackt, die Datenbank geoeffnet, die # Eintraege gezaehlt und mit dem laufenden Dienst verglichen. Eine # Sicherung, die niemand nachzaehlt, ist eine Vermutung. -# 4. Alte Archive fliegen raus, sobald mehr als -Behalten da sind. +# 4. Das Archiv geht in die NEXTCLOUD, wenn Zugangsdaten hinterlegt sind. +# Bis dahin lag die Sicherung auf derselben Platte wie die Daten — gegen +# einen Plattenausfall half sie damit gar nicht. +# 5. Alte Archive fliegen raus, hier wie dort, sobald mehr als -Behalten da +# sind. +# +# WARUM CURL UND NICHT RCLONE. Nextcloud spricht WebDAV, curl spricht WebDAV, +# und curl ist ohnehin da. rclone waere ein weiteres Programm, das installiert, +# konfiguriert und aktuell gehalten werden will — fuer PUT, PROPFIND und DELETE +# lohnt das nicht. # # NICHT MIT DRIN sind die Vorschaubilder unter /data/thumbs, und das ist # Absicht: sie lassen sich aus den Bildern jederzeit neu rechnen (Dashboard → @@ -88,9 +97,12 @@ Gut "$name — $([math]::Round($groesse/1MB, 1)) MB" # -- 3. Nachzaehlen ------------------------------------------------------------ Schritt 'Sicherung pruefen (auspacken, Datenbank oeffnen, zaehlen)' -$pruefung = docker run --rm ` - -v "${Ziel}:/in:ro" ` - --entrypoint sh $Abbild -c @" + +# Die Wagenruecklaeufe muessen raus. Diese Datei liegt unter Windows und wird +# je nach Werkzeug mit CRLF gespeichert; die Shell im Container liest dann +# "set -e\r" und antwortet mit "Illegal option -". Der Fehler sieht aus wie ein +# Tippfehler im Skript und ist keiner — genau einmal reingefallen. +$skript = (@" set -e mkdir -p /tmp/p && cd /tmp/p tar xzf '/in/$name' @@ -105,7 +117,9 @@ const u = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; console.log(JSON.stringify({ media: m, tokens: t, users: u })); " find /tmp/p/files -type f | wc -l -"@ +"@) -replace "`r", '' + +$pruefung = docker run --rm -v "${Ziel}:/in:ro" --entrypoint sh $Abbild -c $skript if ($LASTEXITCODE -ne 0) { Schlecht "Pruefung fehlgeschlagen: $pruefung"; exit 1 } $zeilen = @($pruefung -split "`n" | Where-Object { $_.Trim() }) @@ -125,7 +139,102 @@ if ($dateien -ne $zahlen.media) { } Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.' -# -- 4. Alte Sicherungen ausduennen -------------------------------------------- +# -- 4. In die Nextcloud ------------------------------------------------------- +# +# Die Zugangsdaten liegen NEBEN den Sicherungen und nicht im Repo: sie gehoeren +# zu dieser Maschine, nicht zum Code. Fehlt die Datei, wird das einmal gesagt +# und lokal weitergesichert — eine Sicherung, die wegen eines fehlenden +# Passworts GAR NICHT stattfindet, waere der schlechtere Tausch. +# +# Aufbau der Datei (eine Zuweisung je Zeile, # ist ein Kommentar): +# +# basis = https://cdn.d4rkst3r.de +# benutzer = darkster +# passwort = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx <- APP-PASSWORT, nicht das echte +# ordner = Sicherungen/d4rk_media +# +# Das App-Passwort steht in Nextcloud unter Einstellungen → Sicherheit → +# "Neues App-Passwort erstellen". Es laesst sich einzeln zurueckziehen, ohne +# dass das eigene Passwort geaendert werden muss — genau dafuer gibt es sie. + +$zugangDatei = Join-Path $Ziel 'nextcloud.txt' + +if (-not (Test-Path $zugangDatei)) { + Write-Host " Keine Nextcloud hinterlegt ($zugangDatei) — die Sicherung bleibt lokal." -ForegroundColor Yellow +} else { + $z = @{} + foreach ($zeile in Get-Content $zugangDatei) { + if ($zeile -match '^\s*#') { continue } + if ($zeile -match '^\s*([a-z]+)\s*=\s*(.+?)\s*$') { $z[$Matches[1]] = $Matches[2] } + } + + $fehlend = @('basis', 'benutzer', 'passwort') | Where-Object { -not $z[$_] } + if ($fehlend) { + Schlecht "In $zugangDatei fehlt: $($fehlend -join ', ')" + exit 1 + } + if (-not $z['ordner']) { $z['ordner'] = 'Sicherungen/d4rk_media' } + + $basis = $z['basis'].TrimEnd('/') + $wurzel = "$basis/remote.php/dav/files/$($z['benutzer'])" + $auth = "$($z['benutzer']):$($z['passwort'])" + + Schritt "In die Nextcloud: $($z['ordner'])" + + # Die Ordner Ebene fuer Ebene anlegen. MKCOL legt NUR EINE an; auf einen + # fehlenden Zwischenschritt antwortet es mit 409, und dann sucht man den + # Fehler beim Passwort. + $pfadBisher = '' + foreach ($teil in $z['ordner'].Split('/')) { + if (-not $teil) { continue } + $pfadBisher = if ($pfadBisher) { "$pfadBisher/$teil" } else { $teil } + # 201 = angelegt, 405 = gab es schon. Beides ist in Ordnung. + $code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X MKCOL "$wurzel/$pfadBisher" + if ($code -notin @('201', '405')) { + Schlecht "MKCOL $pfadBisher -> HTTP $code" + if ($code -eq '401') { Schlecht 'Benutzer oder App-Passwort stimmt nicht.' } + exit 1 + } + } + + $ziel = "$wurzel/$($z['ordner'])/$name" + $code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $ziel + if ($code -notin @('201', '204')) { + Schlecht "Hochladen fehlgeschlagen -> HTTP $code" + exit 1 + } + Gut "hochgeladen ($code)" + + # NACHZAEHLEN, auch hier: zurueckholen und die Pruefsumme vergleichen. Ein + # PUT, der 201 sagt, hat noch nichts bewiesen — abgeschnittene Uebertragungen + # sehen genauso aus. Bei ein paar Megabyte taeglich ist das billig. + $zurueck = Join-Path $env:TEMP "pruefe-$name" + curl.exe -s -u $auth -o $zurueck $ziel + $hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash + $dortHash = (Get-FileHash $zurueck -Algorithm SHA256).Hash + Remove-Item $zurueck -Force -ErrorAction SilentlyContinue + + if ($hierHash -ne $dortHash) { + Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.' + exit 1 + } + Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)" + + # Auch draussen ausduennen. Sonst waechst die Nextcloud still voll, und das + # faellt erst auf, wenn sie voll ist. + $liste = curl.exe -s -u $auth -X PROPFIND -H 'Depth: 1' "$wurzel/$($z['ordner'])" + $fern = @([regex]::Matches($liste, 'd4rk_media-[\d-]+\.tar\.gz') | + ForEach-Object { $_.Value } | Sort-Object -Unique -Descending) + if ($fern.Count -gt $Behalten) { + foreach ($alt in $fern | Select-Object -Skip $Behalten) { + $c = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X DELETE "$wurzel/$($z['ordner'])/$alt" + if ($c -in @('204', '200')) { Gut "in der Nextcloud entfernt: $alt" } + else { Write-Host " konnte $alt nicht entfernen (HTTP $c)" -ForegroundColor Yellow } + } + } +} + +# -- 5. Alte Sicherungen ausduennen (lokal) ------------------------------------ $alle = Get-ChildItem $Ziel -Filter 'd4rk_media-*.tar.gz' | Sort-Object Name -Descending if ($alle.Count -gt $Behalten) {