diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 6d07ecc..a4ae8c0 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -33,7 +33,15 @@ import { } from '../auth.js' import { readdir, readFile, rm, stat } from 'node:fs/promises' import { join } from 'node:path' -import { DISCORD_KEYS, ausDatenbank, discordConfig, setSetting } from '../settings.js' +import { + DISCORD_KEYS, + SICHERUNG_KEYS, + ausDatenbank, + discordConfig, + getSetting, + setSetting, + sicherungConfig, +} from '../settings.js' import { discordRoutes, redirectUri } from './discord.js' import { absolutePath, @@ -701,6 +709,19 @@ dashRoutes.get('/settings', (c) => { !ausDatenbank(DISCORD_KEYS.roleIdAlt), }, }, + sicherung: (() => { + const sc = sicherungConfig() + let letzte: unknown = null + try { + const roh = getSetting(SICHERUNG_KEYS.letzte) + letzte = roh ? JSON.parse(roh) : null + } catch { + // Ein kaputter Eintrag ist kein Grund, die ganze Seite + // scheitern zu lassen -- dann steht dort eben "unbekannt". + letzte = null + } + return { ...sc, letzte } + })(), }) }) @@ -712,9 +733,61 @@ dashRoutes.post('/settings', async (c) => { guildId?: string roleIds?: string | string[] } + sicherung?: { + basis?: string + benutzer?: string + passwort?: string + ordner?: string + behalten?: number | string + } }>(c) if (!body) return c.json(KEIN_JSON, 400) + // -- Sicherung ---------------------------------------------------------- + if (body.sicherung) { + const sic = body.sicherung + if (sic.basis !== undefined) { + const v = sic.basis.trim().replace(/\/+$/, '') + if (v && !/^https?:\/\//.test(v)) { + return c.json({ error: 'Adresse muss mit http:// oder https:// anfangen' }, 400) + } + setSetting(SICHERUNG_KEYS.basis, v) + } + if (sic.benutzer !== undefined) setSetting(SICHERUNG_KEYS.benutzer, sic.benutzer.trim()) + if (sic.ordner !== undefined) { + // Kein fuehrender Schraegstrich: der Pfad haengt hinter der + // WebDAV-Wurzel, und "//Sicherungen" ergibt dort einen Ordner + // ohne Namen. + setSetting(SICHERUNG_KEYS.ordner, sic.ordner.trim().replace(/^\/+|\/+$/g, '')) + } + if (sic.behalten !== undefined) { + const n = Number(sic.behalten) + if (!Number.isFinite(n) || n < 1 || n > 365) { + return c.json({ error: 'Behalten: zwischen 1 und 365' }, 400) + } + setSetting(SICHERUNG_KEYS.behalten, String(Math.round(n))) + } + // Wie beim Discord-Geheimnis: leer heisst unveraendert, "-" entfernt. + if (sic.passwort !== undefined) { + const v = sic.passwort.trim() + if (v === '-') setSetting(SICHERUNG_KEYS.passwort, '') + else if (v) setSetting(SICHERUNG_KEYS.passwort, v) + } + + if (!body.discord) { + const jetzt = sicherungConfig() + return c.json({ + ok: true, + bereit: jetzt.bereit, + fehlt: [ + !jetzt.basis && 'Adresse', + !jetzt.benutzer && 'Benutzername', + !jetzt.hatPasswort && 'App-Passwort', + ].filter(Boolean), + }) + } + } + const d = body.discord if (!d) return c.json({ error: 'nichts zu speichern' }, 400) diff --git a/server/src/settings.ts b/server/src/settings.ts index 66a9184..3b2a9f9 100644 --- a/server/src/settings.ts +++ b/server/src/settings.ts @@ -44,6 +44,54 @@ export function ausDatenbank(key: string): boolean { return Boolean(zeile && zeile.value !== '') } +// ----------------------------------------------------------------- Sicherung +// +// Die Werte fuer die Sicherung stehen hier, obwohl das Skript sie braucht und +// nicht der Dienst. Der Grund ist die Rueckmeldung: eine Textdatei auf der +// Platte sagt niemandem, ob die Sicherung heute Nacht gelaufen ist. Ein +// Eintrag in der Datenbank kann das Panel anzeigen. +// +// tools/sichern.ps1 liest sie ueber `docker exec` DIREKT aus media.db und +// nicht ueber die API: so verlaesst das Passwort den Rechner nie -- ausser +// beim Eintragen im Browser, und das geht ueber TLS. + +export const SICHERUNG_KEYS = { + basis: 'sicherung.basis', + benutzer: 'sicherung.benutzer', + passwort: 'sicherung.passwort', + ordner: 'sicherung.ordner', + behalten: 'sicherung.behalten', + /** Schreibt das Skript nach jedem Lauf. JSON, damit das Panel es anzeigen + * kann, ohne dass hier eine weitere Tabelle noetig waere. */ + letzte: 'sicherung.letzte', +} as const + +export type SicherungConfig = { + basis: string + benutzer: string + ordner: string + behalten: number + hatPasswort: boolean + bereit: boolean +} + +export function sicherungConfig(): SicherungConfig { + const basis = getSetting(SICHERUNG_KEYS.basis) + const benutzer = getSetting(SICHERUNG_KEYS.benutzer) + const passwort = getSetting(SICHERUNG_KEYS.passwort) + const ordner = getSetting(SICHERUNG_KEYS.ordner) || 'Sicherungen/d4rk_media' + const behalten = Number(getSetting(SICHERUNG_KEYS.behalten)) || 14 + + return { + basis, + benutzer, + ordner, + behalten, + hatPasswort: Boolean(passwort), + bereit: Boolean(basis && benutzer && passwort), + } +} + // ------------------------------------------------------------------- Discord export const DISCORD_KEYS = { diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index f93ce39..97b0e59 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -7,6 +7,7 @@ import { KeyRound, LogOut, PieChart, + Settings, UserCog, } from 'lucide-react' import { run, useStore } from './store' @@ -16,6 +17,7 @@ import { Galerie } from './pages/Galerie' import { Speicher } from './pages/Speicher' import { Tokens } from './pages/Tokens' import { Konto } from './pages/Konto' +import { Einstellungen } from './pages/Einstellungen' import { Verlauf } from './pages/Verlauf' import { Meldungen } from './components/Meldungen' import { gehZu, usePfad } from './router' @@ -28,6 +30,7 @@ const seiten = [ { pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher }, { pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens }, { pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto }, + { pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen }, ] as const export function App() { diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 253f8bb..27b3144 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -182,7 +182,27 @@ export type Verwaiste = { vorschauOhneBild: { anzahl: number; beispiele: string[] } } +/** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */ +export type LetzteSicherung = { + at: number + ok: boolean + dateien?: number + groesse?: number + /** true = in der Nextcloud, false = nur lokal, undefined = unbekannt. */ + hoch?: boolean + fehler?: string +} | null + export type Einstellungen = { + sicherung: { + basis: string + benutzer: string + ordner: string + behalten: number + hatPasswort: boolean + bereit: boolean + letzte: LetzteSicherung + } discord: { clientId: string guildId: string @@ -301,6 +321,8 @@ export const api = { ), einstellungen: () => get('/api/dash/settings'), + sicherungSpeichern: (sicherung: Record) => + post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }), einstellungenSpeichern: (discord: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }), diff --git a/server/ui/src/pages/Einstellungen.tsx b/server/ui/src/pages/Einstellungen.tsx new file mode 100644 index 0000000..2c097e9 --- /dev/null +++ b/server/ui/src/pages/Einstellungen.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from 'react' +import { AlertTriangle, CheckCircle2, CloudUpload, Loader2, ShieldCheck, ShieldX } from 'lucide-react' +import { api, type Einstellungen as EinstellungenDaten } from '../api' +import { run, useStore } from '../store' +import { bytes, zeit } from '../format' +import { Feld, Karte, Knopf } from '../components/Bausteine' +import { DiscordKarte } from '../components/DiscordKarte' + +/** Alles, was sich einstellen lässt, an einer Stelle. + * + * Bisher lag die Discord-Karte beim Konto und die Zugangsdaten der Sicherung + * in einer Textdatei auf der Platte. Beides ist jetzt hier — und das ist mehr + * als Aufräumen: eine Textdatei sagt niemandem, ob die Sicherung heute Nacht + * gelaufen ist. Diese Seite tut es. */ +export function Einstellungen() { + return ( +
+ + +
+ ) +} + +function SicherungKarte() { + const note = useStore((s) => s.note) + const [daten, setDaten] = useState(null) + const [laedt, setLaedt] = useState(true) + const [speichert, setSpeichert] = useState(false) + + const [basis, setBasis] = useState('') + const [benutzer, setBenutzer] = useState('') + const [passwort, setPasswort] = useState('') + const [ordner, setOrdner] = useState('') + const [behalten, setBehalten] = useState('14') + + async function laden() { + setLaedt(true) + try { + const e = await api.einstellungen() + setDaten(e) + setBasis(e.sicherung.basis) + setBenutzer(e.sicherung.benutzer) + setOrdner(e.sicherung.ordner) + setBehalten(String(e.sicherung.behalten)) + } catch (err) { + note('fehler', err instanceof Error ? err.message : 'Unbekannt') + } finally { + setLaedt(false) + } + } + + useEffect(() => { + void laden() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + async function speichern() { + setSpeichert(true) + const ergebnis = await run( + () => + api.sicherungSpeichern({ + basis, + benutzer, + ...(passwort ? { passwort } : {}), + ordner, + behalten: Number(behalten), + }), + (r) => + r.bereit + ? 'Gespeichert. Die nächste Sicherung geht in die Nextcloud.' + : `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`, + ) + setSpeichert(false) + if (ergebnis) { + setPasswort('') + void laden() + } + } + + if (laedt) { + return ( + +
+ +
+
+ ) + } + if (!daten) return null + + const s = daten.sicherung + const l = s.letzte + + return ( + + eingerichtet + + ) : ( + + nur lokal + + ) + } + > +
+ {/* Das Wichtigste zuerst: LIEF sie? Eine Sicherung, die still + aufhoert zu laufen, ist der eigentliche Schaden. */} +
+ {!l ? ( + + ) : l.ok ? ( + + ) : ( + + )} + + {!l ? ( + 'Noch keine Sicherung gemeldet. Sie läuft täglich um 04:30 als geplante Aufgabe.' + ) : ( + <> + Zuletzt {zeit(l.at)} — {l.ok ? 'erfolgreich' : 'FEHLGESCHLAGEN'} + {l.dateien != null && `, ${l.dateien} Dateien`} + {l.groesse != null && `, ${bytes(l.groesse)}`} + {l.hoch === true && ', in der Nextcloud'} + {l.hoch === false && ', nur lokal'} + {l.fehler && ( + {l.fehler} + )} + + )} + +
+ +
+ setBasis(e.target.value)} + /> + setBenutzer(e.target.value)} + /> + setPasswort(e.target.value)} + /> + setOrdner(e.target.value)} + /> + setBehalten(e.target.value)} + /> +
+ +

+ + + Nimm ein App-Passwort, nicht dein echtes: Nextcloud → + Einstellungen → Sicherheit → „Neues App-Passwort erstellen". Es lässt + sich einzeln zurückziehen. Das Skript liest die Werte direkt aus der + Datenbank des Dienstes — über HTTP geht das Passwort nur hier beim + Eintragen, und das über TLS. + +

+ +
+ +
+
+
+ ) +} diff --git a/server/ui/src/pages/Konto.tsx b/server/ui/src/pages/Konto.tsx index 1ea0ff0..691460a 100644 --- a/server/ui/src/pages/Konto.tsx +++ b/server/ui/src/pages/Konto.tsx @@ -2,7 +2,6 @@ import { useState, type FormEvent } from 'react' import { api } from '../api' import { run, useStore } from '../store' import { Feld, Karte, Knopf } from '../components/Bausteine' -import { DiscordKarte } from '../components/DiscordKarte' import { Avatar } from '../components/Avatar' export function Konto() { @@ -105,8 +104,6 @@ export function Konto() { - - ) } diff --git a/tools/sichern.ps1 b/tools/sichern.ps1 index aefaf36..55d4be7 100644 --- a/tools/sichern.ps1 +++ b/tools/sichern.ps1 @@ -16,9 +16,12 @@ # 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. 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. +# 4. Das Archiv geht in die NEXTCLOUD, wenn im Panel Zugangsdaten stehen +# (Einstellungen → Sicherung). Bis dahin lag die Sicherung auf derselben +# Platte wie die Daten — gegen einen Plattenausfall half sie gar nicht. +# 4b. Und der Lauf MELDET SICH ZURUECK: das Ergebnis landet in der Datenbank +# und steht danach im Panel. Eine Sicherung, die still aufhoert zu laufen, +# ist der eigentliche Schaden — nicht die eine Nacht, in der sie fehlt. # 5. Alte Archive fliegen raus, hier wie dort, sobald mehr als -Behalten da # sind. # @@ -49,6 +52,36 @@ function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan } function Gut($text) { Write-Host " $text" -ForegroundColor Green } function Schlecht($text) { Write-Host " $text" -ForegroundColor Red } +# Das Ergebnis in die Datenbank des Dienstes schreiben, damit es im Panel +# steht. Schlaegt DAS fehl, ist es kein Grund, die Sicherung als gescheitert zu +# behandeln — das Archiv liegt dann trotzdem richtig. +$inDerWolke = $false + +function Melde($ok, $dateien, $groesse, $hoch, $fehler) { + $eintrag = @{ + at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + ok = [bool]$ok + dateien = $dateien + groesse = $groesse + hoch = [bool]$hoch + } + if ($fehler) { $eintrag['fehler'] = "$fehler" } + $json = ($eintrag | ConvertTo-Json -Compress) + + # Ueber die Standardeingabe und nicht als Argument: der JSON-Text enthaelt + # Anfuehrungszeichen, und die ueberleben den Weg durch zwei Shells nicht. + $skript = @" +let roh = ''; +process.stdin.on('data', (d) => (roh += d)).on('end', () => { + const db = require('better-sqlite3')('/data/media.db'); + db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' + + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at') + .run('sicherung.letzte', roh.trim(), Date.now()); +}); +"@ -replace "`r", '' + $json | docker exec -i $Container node -e $skript 2>&1 | Out-Null +} + # -- Vorbedingungen ------------------------------------------------------------ $laeuft = docker ps --filter "name=^/$Container$" --format '{{.Names}}' @@ -157,22 +190,47 @@ Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.' # "Neues App-Passwort erstellen". Es laesst sich einzeln zurueckziehen, ohne # dass das eigene Passwort geaendert werden muss — genau dafuer gibt es sie. +# Die Werte kommen aus der Datenbank des Dienstes, eingetragen im Panel unter +# Einstellungen → Sicherung. Gelesen wird DIREKT ueber `docker exec` und nicht +# ueber die API: so verlaesst das Passwort den Rechner nie. +# +# nextcloud.txt neben den Sicherungen gilt weiterhin und hat VORRANG — wer sie +# angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird. + +$z = @{} $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 = @{} +if (Test-Path $zugangDatei) { 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 + Gut "Zugang aus $zugangDatei" +} else { + $roh = docker exec $Container node -e @" +const db = require('better-sqlite3')('/data/media.db', { readonly: true }); +const hol = (k) => { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(k); return r ? r.value : ''; }; +console.log(JSON.stringify({ + basis: hol('sicherung.basis'), + benutzer: hol('sicherung.benutzer'), + passwort: hol('sicherung.passwort'), + ordner: hol('sicherung.ordner'), + behalten: hol('sicherung.behalten'), +})); +"@ + if ($LASTEXITCODE -eq 0 -and $roh) { + $j = $roh | ConvertFrom-Json + foreach ($k in 'basis', 'benutzer', 'passwort', 'ordner', 'behalten') { + if ($j.$k) { $z[$k] = $j.$k } + } } +} + +if ($z['behalten']) { $Behalten = [int]$z['behalten'] } + +if (-not ($z['basis'] -and $z['benutzer'] -and $z['passwort'])) { + Write-Host ' Keine Nextcloud eingerichtet (Panel → Einstellungen → Sicherung) — die Sicherung bleibt lokal.' -ForegroundColor Yellow +} else { if (-not $z['ordner']) { $z['ordner'] = 'Sicherungen/d4rk_media' } $basis = $z['basis'].TrimEnd('/') @@ -191,42 +249,47 @@ if (-not (Test-Path $zugangDatei)) { # 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.' } + $fehlertext = "MKCOL $pfadBisher -> HTTP $code" + if ($code -eq '401') { $fehlertext += ' (Benutzer oder App-Passwort stimmt nicht)' } + Schlecht $fehlertext + Melde $false $zahlen.media $groesse $false $fehlertext exit 1 } } - $ziel = "$wurzel/$($z['ordner'])/$name" - $code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $ziel + $fern = "$wurzel/$($z['ordner'])/$name" + $code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $fern if ($code -notin @('201', '204')) { Schlecht "Hochladen fehlgeschlagen -> HTTP $code" + Melde $false $zahlen.media $groesse $false "Hochladen -> 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. + # PUT, der 201 sagt, hat noch nichts bewiesen — abgeschnittene + # Uebertragungen sehen genauso aus. Bei ein paar Megabyte ist das billig. $zurueck = Join-Path $env:TEMP "pruefe-$name" - curl.exe -s -u $auth -o $zurueck $ziel + curl.exe -s -u $auth -o $zurueck $fern $hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash - $dortHash = (Get-FileHash $zurueck -Algorithm SHA256).Hash + $dortHash = if (Test-Path $zurueck) { (Get-FileHash $zurueck -Algorithm SHA256).Hash } else { '' } Remove-Item $zurueck -Force -ErrorAction SilentlyContinue if ($hierHash -ne $dortHash) { Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.' + Melde $false $zahlen.media $groesse $false 'Pruefsumme der Kopie weicht ab' exit 1 } Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)" + $inDerWolke = $true # 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') | + $fernListe = @([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) { + if ($fernListe.Count -gt $Behalten) { + foreach ($alt in $fernListe | 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 } @@ -245,6 +308,8 @@ if ($alle.Count -gt $Behalten) { } } +Melde $true $zahlen.media $groesse $inDerWolke $null + Write-Host '' Gut "Fertig: $archiv" Write-Host " Zurueckspielen: tar xzf , dann media.db und files/ ins Volume legen," -ForegroundColor DarkGray