Files
d4rk_media/tools/uebernehmen.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

208 lines
8.8 KiB
PowerShell

# Dateien aus der Nextcloud in d4rk_media uebernehmen.
#
# Aufruf:
# pwsh -File tools\uebernehmen.ps1 -Von 'CDN/brands' -Nach brands
# pwsh -File tools\uebernehmen.ps1 -Von 'CDN/items' -Nach items -Probe
#
# -Von Pfad IN der Nextcloud, relativ zum eigenen Ordner
# -Nach Zielordner im Dienst. Leer = der letzte Teil von -Von
# -Probe nur zeigen, nichts hochladen
#
# UNTERORDNER BLEIBEN ERHALTEN: aus CDN/brands/logos/x.png wird
# brands/logos/x.png. Wer das nicht will, sortiert vorher in der Nextcloud um.
#
# WARUM UEBER DAS DASHBOARD UND NICHT UEBER DIE TOKEN-API. Nextcloud-Dateien
# heissen "Brand Logo (final).PNG" -- checkPath lehnt das mit Recht ab, denn
# Klammern und Leerzeichen haben in einer oeffentlichen Adresse nichts zu
# suchen. Der Dashboard-Weg biegt den Namen bereits VORHERSAGBAR zurecht
# (brand-logo-final.png). Dieselbe Regel an einer Stelle ist besser als
# dieselbe Regel an zweien, von denen eine irgendwann abweicht.
#
# WIEDERHOLBAR: hochgeladen wird mit overwrite=false. Was schon da ist, meldet
# der Dienst je Datei als "Pfad ist belegt" -- der Lauf laesst sich also
# jederzeit abbrechen und noch einmal starten, ohne 500 Dateien erneut zu
# schieben.
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Von,
[string]$Nach = '',
[switch]$Probe,
[string]$Dienst = 'https://fivemanage.d4rkst3r.de',
[string]$Container = 'd4rk-media',
[int]$ProStapel = 20
)
$ErrorActionPreference = 'Stop'
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
if (-not $Nach) { $Nach = ($Von.TrimEnd('/') -split '/')[-1] }
$Nach = $Nach.Trim('/')
# -- Zugang zur Nextcloud ------------------------------------------------------
#
# Dieselbe Quelle wie bei der Sicherung: die Werte stehen im Panel unter
# Einstellungen und werden hier direkt aus der Datenbank gelesen. So gibt es
# EINEN Ort fuer diese Zugangsdaten und nicht zwei.
$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'),
}));
"@
if ($LASTEXITCODE -ne 0) { Schlecht 'Der Container antwortet nicht.'; exit 1 }
$nc = $roh | ConvertFrom-Json
if (-not ($nc.basis -and $nc.benutzer -and $nc.passwort)) {
Schlecht 'Keine Nextcloud hinterlegt. Panel -> Einstellungen -> Sicherung ausfuellen.'
exit 1
}
$wurzel = "$($nc.basis.TrimEnd('/'))/remote.php/dav/files/$($nc.benutzer)"
$auth = "$($nc.benutzer):$($nc.passwort)"
# -- Was liegt dort ------------------------------------------------------------
Schritt "In der Nextcloud nachsehen: $Von"
# Depth: infinity nennt auch alles in Unterordnern. Manche Server verweigern
# das; dann bleibt nur Ebene fuer Ebene, und das sagen wir auch.
# Das -join ist NICHT Kosmetik. curl.exe gibt seine Antwort zeilenweise als
# ARRAY zurueck, und -match/-notmatch auf einem Array FILTERT, statt wahr oder
# falsch zu liefern: ein nicht-leeres Ergebnis gilt als wahr, und damit schlug
# die Pruefung unten immer an -- bei einer tadellosen Antwort mit HTTP 207.
$xml = (curl.exe -s -u $auth -X PROPFIND -H 'Depth: infinity' "$wurzel/$Von") -join "`n"
if (-not $xml -or $xml -notmatch '<[dD]:multistatus') {
Schlecht "Kein Verzeichnis gefunden. Antwort: $($xml -replace '\s+', ' ' | Select-Object -First 1)"
Schlecht 'Stimmt der Pfad? Er ist relativ zum eigenen Nextcloud-Ordner, ohne fuehrenden Schraegstrich.'
exit 1
}
# Die Antwort ist XML mit Namensraumpraefix (d: oder D:). Statt einen Parser
# darauf anzusetzen, holen wir die beiden Felder, auf die es ankommt.
$eintraege = @()
foreach ($m in [regex]::Matches($xml, '(?s)<[dD]:response>(.*?)</[dD]:response>')) {
$block = $m.Groups[1].Value
if ($block -match '<[dD]:collection\s*/>') { continue } # Ordner selbst
if ($block -notmatch '<[dD]:href>(.*?)</[dD]:href>') { continue }
$href = [uri]::UnescapeDataString($Matches[1])
$groesse = if ($block -match '<[dD]:getcontentlength>(\d+)<') { [long]$Matches[1] } else { 0 }
# Der href traegt den vollen DAV-Pfad. Wir wollen den Teil UNTERHALB von
# -Von, damit die Unterordner erhalten bleiben.
$praefix = "/remote.php/dav/files/$($nc.benutzer)/$Von/"
if ($href -like "*$praefix*") {
$rel = $href.Substring($href.IndexOf($praefix) + $praefix.Length)
if ($rel) { $eintraege += [pscustomobject]@{ rel = $rel; href = $href; size = $groesse } }
}
}
if ($eintraege.Count -eq 0) { Schlecht 'Keine Dateien gefunden.'; exit 1 }
$summe = ($eintraege | Measure-Object -Property size -Sum).Sum
Gut "$($eintraege.Count) Dateien, $([math]::Round($summe/1MB,1)) MB"
# Nach Zielordner gruppieren: der Dashboard-Weg nimmt EINEN Ordner je Anfrage.
$gruppen = $eintraege | Group-Object {
$teile = $_.rel -split '/'
if ($teile.Count -gt 1) { "$Nach/" + ($teile[0..($teile.Count - 2)] -join '/') } else { $Nach }
}
Write-Host ''
foreach ($g in $gruppen) {
Write-Host " $($g.Name)" -NoNewline -ForegroundColor DarkGray
Write-Host " $($g.Count) Dateien"
}
Write-Host ''
if ($Probe) {
Gut 'Probelauf — nichts hochgeladen. Ohne -Probe geht es wirklich los.'
exit 0
}
# -- Anmelden am Dienst --------------------------------------------------------
$envDatei = Join-Path (Split-Path $PSScriptRoot -Parent) '.env'
if (-not (Test-Path $envDatei)) { Schlecht "Keine .env neben dem Compose gefunden."; exit 1 }
$pw = (Select-String -Path $envDatei -Pattern '^ADMIN_PASSWORD=(.+)$').Matches.Groups[1].Value
$benutzer = 'admin'
$m = Select-String -Path $envDatei -Pattern '^ADMIN_USERNAME=(.+)$'
if ($m) { $benutzer = $m.Matches.Groups[1].Value }
$keksglas = Join-Path $env:TEMP ('keks-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$anmeldung = @{ username = $benutzer; password = $pw } | ConvertTo-Json -Compress
$code = $anmeldung | curl.exe -s -o NUL -w '%{http_code}' -c $keksglas `
-X POST "$Dienst/api/dash/auth/login" -H 'content-type: application/json' -d '@-'
if ($code -ne '200') { Schlecht "Anmeldung am Dienst fehlgeschlagen (HTTP $code)."; exit 1 }
# -- Uebertragen ---------------------------------------------------------------
$tmp = Join-Path $env:TEMP ('uebernahme-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$hoch = 0; $schon = 0; $fehler = 0
foreach ($g in $gruppen) {
$zielordner = $g.Name
Schritt "$zielordner ($($g.Count) Dateien)"
foreach ($stapel in ($g.Group | ForEach-Object -Begin { $i = 0; $puffer = @() } -Process {
$puffer += $_
if ($puffer.Count -ge $ProStapel) { , $puffer; $puffer = @() }
} -End { if ($puffer.Count) { , $puffer } })) {
$dateien = @()
foreach ($e in $stapel) {
$name = ($e.rel -split '/')[-1]
$lokal = Join-Path $tmp $name
curl.exe -s -u $auth -o $lokal "$($nc.basis.TrimEnd('/'))$($e.href)"
if (Test-Path $lokal) { $dateien += Get-Item $lokal }
}
if (-not $dateien) { continue }
# HOCHGELADEN WIRD MIT CURL UND NICHT MIT Invoke-RestMethod -Form.
#
# Nicht aus Geschmack: .NET schreibt im Formular-Rumpf
# "Content-Disposition: form-data; name=file" OHNE Anfuehrungszeichen,
# RFC 7578 verlangt sie, und Nodes Parser besteht darauf. Der Dienst
# antwortet dann mit "Der Formular-Rumpf liess sich nicht lesen".
# Mitgeschnitten und nachgemessen -- curl setzt beides korrekt.
$argumente = @(
'-s', '-b', $keksglas,
'-X', 'POST', "$Dienst/api/dash/upload",
'-F', "folder=$zielordner",
'-F', 'overwrite=false'
)
foreach ($d in $dateien) { $argumente += @('-F', "file=@$($d.FullName)") }
try {
$res = (& curl.exe @argumente) | ConvertFrom-Json
foreach ($r in $res.ergebnisse) {
if ($r.ok) { $hoch++; Gut "$($r.path)" }
elseif ($r.error -like '*belegt*') { $schon++ }
else { $fehler++; Schlecht "$($r.name): $($r.error)" }
}
} catch {
$fehler += $dateien.Count
Schlecht "Stapel fehlgeschlagen: $($_.Exception.Message)"
}
$dateien | Remove-Item -Force -ErrorAction SilentlyContinue
}
}
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $keksglas -Force -ErrorAction SilentlyContinue
Write-Host ''
Gut "Fertig: $hoch uebernommen, $schon waren schon da, $fehler Fehler"
if ($schon -gt 0) {
Write-Host " (Ein zweiter Lauf laedt nichts doppelt — das ist Absicht.)" -ForegroundColor DarkGray
}