diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 54ca8b3..a82b939 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -570,17 +570,61 @@ dashRoutes.post('/upload', async (c) => { return c.json({ error: 'Erwartet wird multipart/form-data' }, 400) } - // { all: true } ist noetig: ohne das liefert Hono bei mehreren Feldern - // gleichen Namens nur das letzte, und aus fuenf Dateien wuerde eine. - const form = await c.req.parseBody({ all: true }) + // Das Formular lesen -- und dabei eine Strenge ausbuegeln, die uns fast + // eine Anbindung gekostet haette. + // + // Manche Aufrufer setzen die Grenzmarkierung in ANFUEHRUNGSZEICHEN: + // + // multipart/form-data; boundary="3c5a305b-cb7d-..." + // + // Nodes Parser lehnt das ab, obwohl RFC 2046 es ausdruecklich erlaubt. + // Also nehmen wir sie weg und lesen selbst. + // + // ACHTUNG, EINE HALBE WAHRHEIT WAERE HIER TEURER ALS KEINE: das allein + // rettet PowerShells `Invoke-RestMethod -Form` NICHT. Mitgeschnitten hat + // dessen Rumpf naemlich noch eine zweite Abweichung: + // + // Content-Disposition: form-data; name=file; filename=a.png + // + // RFC 7578 verlangt name="file" MIT Anfuehrungszeichen, .NET laesst sie + // weg. Das steht im RUMPF, nicht im Kopf -- dafuer braeuchte es einen + // eigenen Multipart-Parser, und der waere fuer diesen Dienst + // unverhaeltnismaessig. Werkzeuge auf dieser Maschine nehmen deshalb + // `curl.exe -F` (siehe tools/uebernehmen.ps1); curl setzt beides korrekt. + let form: FormData + try { + const typ = c.req.header('content-type') ?? '' + if (/boundary="/.test(typ)) { + const gerade = typ.replace(/boundary="([^"]+)"/, 'boundary=$1') + const rumpf = await c.req.arrayBuffer() + form = await new Request('http://formular.invalid/', { + method: 'POST', + headers: { 'content-type': gerade }, + body: rumpf, + }).formData() + } else { + form = await c.req.raw.formData() + } + } catch (err) { + // Ein kaputter Rumpf ergab bisher einen 500, aufgefangen nur von der + // Auffanglinie. Wer ein Formular falsch zusammenbaut, soll 400 samt + // Grund bekommen und nicht "Serverfehler". + return c.json( + { + error: `Der Formular-Rumpf liess sich nicht lesen: ${err instanceof Error ? err.message : 'unbekannt'}`, + }, + 400, + ) + } - const roheDateien = form['file'] - const dateien = (Array.isArray(roheDateien) ? roheDateien : [roheDateien]).filter( - (f): f is File => f instanceof File, - ) + // getAll und nicht get: sonst kaeme von fuenf Dateien nur die letzte an. + // Ohne DOM-Typen kennt TypeScript hier nur FormDataEntryValue; die + // Einengung auf File muss deshalb ausgesprochen werden. Der Test bleibt + // derselbe -- was kein Text ist, ist eine Datei. + const dateien = form.getAll('file').filter((f) => typeof f !== 'string') as unknown as File[] if (dateien.length === 0) return c.json({ error: 'Keine Datei dabei' }, 400) - const ordnerRoh = typeof form['folder'] === 'string' ? form['folder'].trim() : '' + const ordnerRoh = (form.get('folder') as string | null)?.trim() ?? '' let ordner = '' if (ordnerRoh) { try { @@ -593,7 +637,7 @@ dashRoutes.post('/upload', async (c) => { } } - const ueberschreiben = form['overwrite'] !== 'false' + const ueberschreiben = form.get('overwrite') !== 'false' const ergebnisse: { name: string ok: boolean diff --git a/tools/sichern.ps1 b/tools/sichern.ps1 index 55d4be7..80ad498 100644 --- a/tools/sichern.ps1 +++ b/tools/sichern.ps1 @@ -98,7 +98,10 @@ $archiv = Join-Path $Ziel $name # -- 1. Datenbank stimmig herausholen ------------------------------------------ Schritt 'Datenbank sichern (SQLite-Sicherungsschnittstelle im Container)' -$ausgabe = docker exec $Container node dist/backup.js /data/.sicherung/media.db 2>&1 +# 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 @@ -285,7 +288,7 @@ if (-not ($z['basis'] -and $z['benutzer'] -and $z['passwort'])) { # 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'])" + $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) { diff --git a/tools/uebernehmen.ps1 b/tools/uebernehmen.ps1 new file mode 100644 index 0000000..c08ea0f --- /dev/null +++ b/tools/uebernehmen.ps1 @@ -0,0 +1,207 @@ +# 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>(.*?)')) { + $block = $m.Groups[1].Value + if ($block -match '<[dD]:collection\s*/>') { continue } # Ordner selbst + if ($block -notmatch '<[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 +}