BEIM UEBERNEHMEN VON 3670 DATEIEN STARB DER DIENST. Nicht einmal, sondern in
Schleife: acht Neustarts, nach aussen 502 vom Proxy.
TypeError [ERR_INVALID_STATE]: ReadableStream is already closed
at ReadableByteStreamController.close (node:internal/webstreams/...)
DIE URSACHE WAR MEINE EIGENE AENDERUNG von vor einer Stunde. Um PowerShells
Formular-Rumpf zu vertragen, hatte ich `c.req.parseBody()` durch
`c.req.raw.formData()` ersetzt -- und damit Hono den Rumpf weggenommen, den es
selbst verwaltet. Beim Aufraeumen der Antwort schliesst dann jemand einen
Strom, der schon zu ist. Die Ausnahme faellt in einem Microtask an, also
AUSSERHALB jedes try/catch, und Node beendet den Prozess.
Beim Test mit 57 Dateien fiel das nicht auf. Bei 3670 schon.
Jetzt geht der Normalfall wieder ueber Hono; der eigene Weg gilt nur noch fuer
die Antwort mit Anfuehrungszeichen an der Grenzmarkierung, fuer die er gedacht
war.
UND EIN NETZ DARUNTER: process.on('uncaughtException') faengt, was ausserhalb
jedes try/catch anfaellt, und laesst den Dienst weiterlaufen. Die Abwaegung
steht im Code: das ist NICHT in jedem Fall richtig -- der Prozess kann danach
kaputt sein. Hier ueberwiegt das Weiterlaufen, weil dieser Dienst keinen
Zustand im Speicher haelt (alles in SQLite und auf der Platte) und ein Dienst,
der wegen EINER Anfrage fuer alle weg ist, der schlechtere Tausch waere. Laut
wird es trotzdem.
DER SCHADEN WAR REPARIERBAR, und zwar mit dem Werkzeug von heute Nachmittag:
206 Dateien waren durch, 2 lagen auf der Platte OHNE Datensatz (geschrieben,
dann starb der Prozess vor dem Eintrag), dazu eine verwaiste Vorschau. Der
Verwaisten-Finder hat sie gefunden, "aufnehmen" hat sie eingetragen -- keine
Datei verloren.
Und die Fehlermeldung im Uebernahmeskript verschwieg die Ursache: sie meldete
"Conversion from JSON failed ... Unexpected character <", obwohl die Wahrheit
ein 502 war. Jetzt wird der HTTP-Code mitgelesen, bei 502 sauber abgebrochen
und gesagt, dass ein neuer Lauf einfach weitermacht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
10 KiB
PowerShell
230 lines
10 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)") }
|
|
|
|
# Den HTTP-Code MIT abfragen. Vorher stand hier nur ConvertFrom-Json,
|
|
# und als der Dienst wegbrach, meldete das Skript
|
|
# "Conversion from JSON failed ... Unexpected character <" -- also
|
|
# einen Parser-Fehler, obwohl die Wahrheit ein 502 vom Proxy war. Eine
|
|
# Fehlermeldung, die die Ursache verschweigt, kostet genau die Zeit,
|
|
# die sie sparen soll.
|
|
$argumente += @('-w', "`n%{http_code}")
|
|
$antwort = (& curl.exe @argumente) -join "`n"
|
|
$code = ($antwort -split "`n")[-1]
|
|
$rumpf = ($antwort -split "`n")[0..(($antwort -split "`n").Count - 2)] -join "`n"
|
|
|
|
if ($code -ne '200') {
|
|
$fehler += $dateien.Count
|
|
Schlecht "Stapel fehlgeschlagen: HTTP $code — $($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))"
|
|
if ($code -eq '502') {
|
|
Schlecht 'Der Dienst antwortet nicht. Laeuft der Container? (docker ps)'
|
|
Schlecht 'Der Lauf laesst sich danach einfach wiederholen — was da ist, wird uebersprungen.'
|
|
break
|
|
}
|
|
} else {
|
|
try {
|
|
$res = $rumpf | ConvertFrom-Json
|
|
foreach ($r in $res.ergebnisse) {
|
|
if ($r.ok) { $hoch++ }
|
|
elseif ($r.error -like '*belegt*') { $schon++ }
|
|
else { $fehler++; Schlecht "$($r.name): $($r.error)" }
|
|
}
|
|
Gut "$hoch uebernommen, $schon schon da, $fehler Fehler"
|
|
} catch {
|
|
$fehler += $dateien.Count
|
|
Schlecht "Antwort unlesbar: $($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))"
|
|
}
|
|
}
|
|
|
|
$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
|
|
}
|