Files
d4rk_media/tools/sichern.ps1
T
D4rkst3randClaude Opus 5 5d35381952 feat: Dateien aus der Nextcloud uebernehmen
tools/uebernehmen.ps1 holt einen Nextcloud-Ordner in den Dienst. Unterordner
bleiben erhalten, -Probe zeigt erst nur, was passieren wuerde.

UEBER DAS DASHBOARD UND NICHT UEBER DIE TOKEN-API. Nextcloud-Dateien heissen
"Brand Logo (final).PNG" -- checkPath lehnt das mit Recht ab. Der
Dashboard-Weg biegt den Namen bereits vorhersagbar zurecht. Dieselbe Regel an
einer Stelle ist besser als dieselbe Regel an zweien, von denen eine irgendwann
abweicht.

WIEDERHOLBAR ueber overwrite=false: was schon da ist, meldet der Dienst je
Datei als "Pfad ist belegt". Nachgemessen mit 57 Marken -- zweiter Lauf: 0
uebernommen, 57 waren schon da.

DREI FEHLER BEIM BAUEN, alle durch Messen gefunden:

1. Ein kaputter Formular-Rumpf ergab einen NACKTEN 500, aufgefangen nur von der
   Auffanglinie. Jetzt 400 mit Grund.

2. PowerShells `Invoke-RestMethod -Form` scheitert am Dienst. Ich hatte die
   Ursache zuerst falsch: nicht (nur) die Grenzmarkierung in
   Anfuehrungszeichen, die RFC 2046 erlaubt und die wir jetzt vertragen --
   sondern der Rumpf selbst. Mitgeschnitten:

       Content-Disposition: form-data; name=file; filename=a.png

   RFC 7578 verlangt name="file" MIT Anfuehrungszeichen, .NET laesst sie weg,
   und Nodes Parser besteht darauf. Das steht im RUMPF; dafuer braeuchte es
   einen eigenen Multipart-Parser, und der waere unverhaeltnismaessig. Die
   Werkzeuge nehmen deshalb curl.exe -F.

3. PowerShell-Falle: curl.exe gibt zeilenweise ein ARRAY zurueck, und
   -match/-notmatch darauf FILTERT, statt zu pruefen. Ein nicht-leeres Ergebnis
   gilt als wahr -- damit meldete das Skript "Kein Verzeichnis gefunden" bei
   einer tadellosen Antwort mit HTTP 207. Jetzt wird vorher zusammengefuegt,
   auch in sichern.ps1, wo dieselbe Falle nur deshalb nicht zuschlug, weil die
   Ausgabe eine Zeile hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:59:59 +02:00

320 lines
14 KiB
PowerShell

# Sicherung von d4rk_media: Datenbank und Bilder in EIN Archiv.
#
# Aufruf:
# pwsh -File tools\sichern.ps1
# pwsh -File tools\sichern.ps1 -Ziel D:\woanders -Behalten 30
#
# Was passiert, und warum in dieser Reihenfolge:
#
# 1. Die Datenbank wird IM laufenden Container ueber SQLites eigene
# Sicherungsschnittstelle als eine in sich stimmige Datei abgelegt
# (dist/backup.js). Ein blosses Kopieren waere wegen des WAL still
# unvollstaendig.
# 2. Ein zweiter, kurzlebiger Container packt Bilder und diese Aufnahme in
# ein tar.gz. Er haengt das Volume NUR LESEND ein — eine Sicherung, die
# schreiben kann, kann auch kaputtmachen.
# 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 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.
#
# 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 →
# Speicher → "Fehlende Vorschaubilder erzeugen"). Sie mitzusichern hiesse, jede
# Nacht Abgeleitetes mitzuschleppen. Wer zurueckspielt, drueckt den Knopf.
#
# Der Dienst laeuft die ganze Zeit weiter. Nichts hier haelt ihn an.
[CmdletBinding()]
param(
[string]$Ziel = 'C:\backup\d4rk_media',
[string]$Container = 'd4rk-media',
[string]$Volume = 'd4rk_media_media-data',
[string]$Abbild = 'd4rk-media:latest',
[int]$Behalten = 14
)
$ErrorActionPreference = 'Stop'
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}}'
if ($laeuft -ne $Container) {
Schlecht "Der Container '$Container' laeuft nicht. Ohne ihn gibt es keine stimmige Aufnahme der Datenbank."
exit 1
}
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
$stempel = Get-Date -Format 'yyyy-MM-dd-HHmm'
$name = "d4rk_media-$stempel.tar.gz"
$archiv = Join-Path $Ziel $name
# -- 1. Datenbank stimmig herausholen ------------------------------------------
Schritt 'Datenbank sichern (SQLite-Sicherungsschnittstelle im Container)'
# Auch hier zusammenfuegen: die Ausgabe kaeme sonst als Array, und -match
# darauf filtert, statt zu pruefen (siehe uebernehmen.ps1). Solange es eine
# Zeile ist, faellt das nicht auf -- bei zweien schon.
$ausgabe = (docker exec $Container node dist/backup.js /data/.sicherung/media.db 2>&1) -join "`n"
if ($LASTEXITCODE -ne 0) {
Schlecht "Fehlgeschlagen: $ausgabe"
exit 1
}
Gut $ausgabe
# Die erwartete Zeilenzahl steht in der Meldung -- sie ist der Massstab fuer
# Schritt 3.
$erwartet = if ($ausgabe -match '(\d+) Medieneintraege') { [int]$Matches[1] } else { -1 }
# -- 2. Alles in ein Archiv ----------------------------------------------------
Schritt 'Bilder und Datenbank packen'
# Zwei -C: die Bilder kommen als files/... in das Archiv, die Aufnahme als
# media.db daneben. Bewusst das eigene Abbild statt alpine -- dort ist GNU tar
# drin, und das kann mehrere -C.
docker run --rm `
-v "${Volume}:/data:ro" `
-v "${Ziel}:/out" `
--entrypoint sh $Abbild -c "tar czf '/out/$name' -C /data files -C /data/.sicherung media.db"
if ($LASTEXITCODE -ne 0) { Schlecht 'Packen fehlgeschlagen.'; exit 1 }
# Die Zwischenaufnahme im Volume wieder weg: sie gehoert nicht zum Bestand und
# wuerde bei der naechsten Sicherung mitgezaehlt.
docker exec $Container rm -rf /data/.sicherung | Out-Null
$groesse = (Get-Item $archiv).Length
Gut "$name$([math]::Round($groesse/1MB, 1)) MB"
# -- 3. Nachzaehlen ------------------------------------------------------------
Schritt 'Sicherung pruefen (auspacken, Datenbank oeffnen, zaehlen)'
# 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'
# Aus /app heraus starten und nicht aus /tmp/p: Node sucht node_modules vom
# Arbeitsverzeichnis aufwaerts, und ueber /tmp liegt kein better-sqlite3.
cd /app
node -e "
const db = require('better-sqlite3')('/tmp/p/media.db', { readonly: true, fileMustExist: true });
const m = db.prepare('SELECT COUNT(*) AS n FROM media').get().n;
const t = db.prepare('SELECT COUNT(*) AS n FROM tokens').get().n;
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() })
$zahlen = $zeilen[0] | ConvertFrom-Json
$dateien = [int]$zeilen[1].Trim()
Gut "in der Sicherung: $($zahlen.media) Medieneintraege, $dateien Dateien, $($zahlen.tokens) Token, $($zahlen.users) Benutzer"
if ($erwartet -ge 0 -and $zahlen.media -ne $erwartet) {
Schlecht "PASST NICHT: erwartet waren $erwartet Eintraege."
exit 1
}
# Eine Datei mehr oder weniger als Eintraege ist ein echter Befund und kein
# Rundungsfehler: dann liegt eine Datei ohne Datensatz herum oder umgekehrt.
if ($dateien -ne $zahlen.media) {
Write-Host " Hinweis: $dateien Dateien zu $($zahlen.media) Eintraegen — das sollte gleich sein." -ForegroundColor Yellow
}
Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.'
# -- 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.
# 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 (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] }
}
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('/')
$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')) {
$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
}
}
$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 ist das billig.
$zurueck = Join-Path $env:TEMP "pruefe-$name"
curl.exe -s -u $auth -o $zurueck $fern
$hierHash = (Get-FileHash $archiv -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'])") -join "`n"
$fernListe = @([regex]::Matches($liste, 'd4rk_media-[\d-]+\.tar\.gz') |
ForEach-Object { $_.Value } | Sort-Object -Unique -Descending)
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 }
}
}
}
# -- 5. Alte Sicherungen ausduennen (lokal) ------------------------------------
$alle = Get-ChildItem $Ziel -Filter 'd4rk_media-*.tar.gz' | Sort-Object Name -Descending
if ($alle.Count -gt $Behalten) {
Schritt "Alte Sicherungen entfernen (mehr als $Behalten)"
$alle | Select-Object -Skip $Behalten | ForEach-Object {
Remove-Item $_.FullName -Force
Gut "entfernt: $($_.Name)"
}
}
Melde $true $zahlen.media $groesse $inDerWolke $null
Write-Host ''
Gut "Fertig: $archiv"
Write-Host " Zurueckspielen: tar xzf <archiv>, dann media.db und files/ ins Volume legen," -ForegroundColor DarkGray
Write-Host " waehrend der Container steht. Siehe ROADMAP." -ForegroundColor DarkGray