From 74cf56ce815dc2b7a2ba7baa5dd828811bceb5fd Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Tue, 11 Aug 2026 19:27:09 +0200 Subject: [PATCH] feat: eine Startseite, und die Sicherung geht in die Nextcloud STARTSEITE. Wer das Dashboard oeffnet, landete bisher in der Galerie -- mit 228 Kacheln und ohne Antwort auf die erste Frage, die man beim Oeffnen hat: laeuft alles? Jetzt zuerst eine Uebersicht: Zahlen, was heute dazukam, ein Bilderstreifen der letzten Uploads, die letzten acht Schritte aus dem Verlauf, die groessten Ordner, die Zugaenge -- und die ADRESSVORLAGE zum Kopieren, weil das die haeufigste Frage ueberhaupt ist. Die Galerie zieht auf /galerie um. Alles in EINER Antwort (/api/dash/uebersicht, gemessen 14 ms). Vier Einzelabfragen waeren vier Runden ueber den Proxy und koennten auseinander- laufen: Dateizahl aus der einen und letzte Ereignisse aus der naechsten Antwort sind zwei verschiedene Zeitpunkte. Der Router zog dabei nach src/router.ts um. Der Grund ist mehr als Geschmack: die Startseite braucht eine Navigationsfunktion, die anderen Seiten nicht -- und sobald EINE Seite eine Eigenschaft mehr hat, passt die gemeinsame Liste in App.tsx nicht mehr. Genau daran ist der erste Versuch gescheitert (TypeScript hat es gefangen). Als Modulfunktion hat wieder jede Seite dieselbe Signatur. SICHERUNG IN DIE NEXTCLOUD. Bisher lagen die Archive auf derselben Platte wie die Daten -- gegen einen Plattenausfall half das gar nicht. Jetzt gehen sie per WebDAV hinauf, mit curl statt rclone: Nextcloud spricht WebDAV, curl spricht WebDAV, und curl ist ohnehin da. Auch dort wird NACHGEZAEHLT: die Kopie wird zurueckgeholt und die Pruefsumme verglichen. Ein PUT, der 201 sagt, hat nichts bewiesen -- abgeschnittene Uebertragungen sehen genauso aus. Und ausgeduennt wird ebenfalls, sonst waechst die Nextcloud still voll. Die Zugangsdaten liegen NEBEN den Sicherungen (C:\backup\d4rk_media\ nextcloud.txt), nicht im Repo: sie gehoeren zur Maschine, nicht zum Code. Fehlt die Datei, sagt das Skript das einmal und sichert lokal weiter -- eine Sicherung, die wegen eines fehlenden Passworts GAR NICHT stattfindet, waere der schlechtere Tausch. Dabei einmal reingefallen: die Pruefung brach mit "sh: set: Illegal option -" ab, nachdem ein Werkzeug die Datei mit CRLF gespeichert hatte -- die Shell im Container liest dann "set -e\r". Sieht aus wie ein Tippfehler und ist keiner. Das Skript entfernt die Wagenruecklaeufe jetzt selbst. Co-Authored-By: Claude Opus 5 --- server/src/routes/dash.ts | 62 ++++++++ server/ui/src/App.tsx | 44 ++---- server/ui/src/api.ts | 15 ++ server/ui/src/pages/Start.tsx | 274 ++++++++++++++++++++++++++++++++++ server/ui/src/router.ts | 34 +++++ tools/sichern.ps1 | 121 ++++++++++++++- 6 files changed, 516 insertions(+), 34 deletions(-) create mode 100644 server/ui/src/pages/Start.tsx create mode 100644 server/ui/src/router.ts diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 125331e..6d07ecc 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -805,6 +805,68 @@ dashRoutes.get('/stand', (c) => { return c.json({ medien: m.n, neuest: m.neuest, ereignis: e.letzte }) }) +/** Alles fuer die Startseite in EINER Antwort. + * + * Vier Einzelabfragen beim Oeffnen waeren vier Runden ueber den Proxy, nur um + * eine Uebersicht zu zeigen. Und sie koennten auseinanderlaufen: die Zahl der + * Dateien aus der einen und die letzten Ereignisse aus der naechsten Antwort + * sind zwei verschiedene Zeitpunkte. */ +dashRoutes.get('/uebersicht', (c) => { + const m = db + .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') + .get() as { files: number; bytes: number } + + const heute = now() - 86_400_000 + const woche = now() - 7 * 86_400_000 + + const neu = db.prepare('SELECT COUNT(*) AS n FROM events WHERE kind = ? AND at > ?') + const heuteHoch = (neu.get('upload', heute) as { n: number }).n + const wocheHoch = (neu.get('upload', woche) as { n: number }).n + + const letzte = db + .prepare('SELECT * FROM events ORDER BY at DESC, id DESC LIMIT 8') + .all() as MediaEvent[] + + // Die letzten Bilder fuer eine Streifenvorschau. Nur Bilder: ein PDF als + // graue Kachel in einer Uebersicht ist Fuellmaterial. + const bilder = ( + db + .prepare( + `SELECT * FROM media WHERE mime LIKE 'image/%' + ORDER BY updated_at DESC LIMIT 12`, + ) + .all() as Media[] + ).map((row) => ({ ...row, url: publicUrlFor(row.path) })) + + const ordner = db + .prepare( + `SELECT CASE WHEN instr(path, '/') > 0 + THEN substr(path, 1, instr(path, '/') - 1) + ELSE '(Wurzel)' END AS folder, + COUNT(*) AS files, SUM(size) AS bytes + FROM media GROUP BY folder ORDER BY bytes DESC LIMIT 5`, + ) + .all() as { folder: string; files: number; bytes: number }[] + + const tokens = db + .prepare('SELECT id, name, prefix, can_delete, last_used_at FROM tokens ORDER BY last_used_at DESC') + .all() as Pick[] + + return c.json({ + files: m.files, + bytes: m.bytes, + heuteHoch, + wocheHoch, + publicUrl: config.publicUrl, + filePrefix: config.filePrefix, + letzte, + bilder, + ordner, + tokens, + discord: discordConfig().bereit, + }) +}) + dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index c0bf0ad..f93ce39 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -1,47 +1,35 @@ -import { useEffect, useState } from 'react' -import { HardDrive, History, Images, KeyRound, LogOut, PieChart, UserCog } from 'lucide-react' +import { useEffect } from 'react' +import { + Gauge, + HardDrive, + History, + Images, + KeyRound, + LogOut, + PieChart, + UserCog, +} from 'lucide-react' import { run, useStore } from './store' import { Anmelden } from './pages/Anmelden' +import { Start } from './pages/Start' import { Galerie } from './pages/Galerie' import { Speicher } from './pages/Speicher' import { Tokens } from './pages/Tokens' import { Konto } from './pages/Konto' import { Verlauf } from './pages/Verlauf' import { Meldungen } from './components/Meldungen' +import { gehZu, usePfad } from './router' import { Avatar } from './components/Avatar' -// Ein winziger Router über die History-API statt einer Bibliothek. -// -// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut -// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein -// Neuladen auf /tokens im Router landet und nicht im 404. - const seiten = [ - { pfad: '/', name: 'Galerie', kind: Images, Inhalt: Galerie }, + { pfad: '/', name: 'Start', kind: Gauge, Inhalt: Start }, + { pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie }, { pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf }, { pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher }, { pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens }, { pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto }, ] as const -function usePfad(): [string, (p: string) => void] { - const [pfad, setPfad] = useState(window.location.pathname) - - useEffect(() => { - const zurueck = () => setPfad(window.location.pathname) - window.addEventListener('popstate', zurueck) - return () => window.removeEventListener('popstate', zurueck) - }, []) - - return [ - pfad, - (p: string) => { - window.history.pushState(null, '', p) - setPfad(p) - }, - ] -} - export function App() { // Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne // Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das @@ -50,7 +38,7 @@ export function App() { const checking = useStore((s) => s.checking) const checkSession = useStore((s) => s.checkSession) const logout = useStore((s) => s.logout) - const [pfad, gehZu] = usePfad() + const pfad = usePfad() useEffect(() => { void checkSession() diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 1efb550..253f8bb 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -159,6 +159,20 @@ export type Folders = { /** Der billige Fuehler fuer die automatische Auffrischung. */ export type Stand = { medien: number; neuest: number; ereignis: number } +export type Uebersicht = { + files: number + bytes: number + heuteHoch: number + wocheHoch: number + publicUrl: string + filePrefix: string + letzte: MediaEvent[] + bilder: MediaItem[] + ordner: { folder: string; files: number; bytes: number }[] + tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[] + discord: boolean +} + export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' export type Richtung = 'auf' | 'ab' @@ -290,6 +304,7 @@ export const api = { einstellungenSpeichern: (discord: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }), + uebersicht: () => get('/api/dash/uebersicht'), stand: () => get('/api/dash/stand'), stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), diff --git a/server/ui/src/pages/Start.tsx b/server/ui/src/pages/Start.tsx new file mode 100644 index 0000000..c616066 --- /dev/null +++ b/server/ui/src/pages/Start.tsx @@ -0,0 +1,274 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Check, + Copy, + Folder, + HardDrive, + Images, + KeyRound, + Loader2, + RefreshCw, + ShieldCheck, + Trash2, + TrendingUp, + Upload, +} from 'lucide-react' +import { api, vorschauUrl, type EventKind, type Uebersicht } from '../api' +import { useStore } from '../store' +import { bytes, zeit } from '../format' +import { Karte, Leer, useKopieren } from '../components/Bausteine' +import { Avatar } from '../components/Avatar' +import { useAuffrischen } from '../auffrischen' +import { gehZu } from '../router' + +const arten: Record = { + upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload }, + replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw }, + delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 }, +} + +/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich. + * + * Bisher landete man in der Galerie — mit 228 Kacheln und ohne Antwort auf + * die erste Frage, die man beim Öffnen hat: läuft alles? Diese Seite + * beantwortet sie in einem Blick und verlinkt den Rest. */ +export function Start() { + const note = useStore((s) => s.note) + const user = useStore((s) => s.user) + const [daten, setDaten] = useState(null) + const [laedt, setLaedt] = useState(true) + const { kopiert, kopieren } = useKopieren() + + const laden = useCallback(async () => { + try { + setDaten(await api.uebersicht()) + } catch (err) { + note('fehler', err instanceof Error ? err.message : 'Unbekannt') + } finally { + setLaedt(false) + } + }, [note]) + + useEffect(() => { + void laden() + }, [laden]) + + // Hier zieht nichts weg: die Seite hat keine Auswahl und kein offenes + // Fenster, sie darf sich jederzeit erneuern. + useAuffrischen({ laden: () => void laden(), blockiert: false }) + + if (laedt) { + return ( +
+ +
+ ) + } + if (!daten) return + + const vorlage = `${daten.publicUrl}${daten.filePrefix}/vehicles/{model}.webp` + + return ( +
+
+ +
+

+ Hallo {user?.discordName ?? user?.username ?? ''} +

+

+ {daten.files} Dateien · {bytes(daten.bytes)} belegt +

+
+
+ +
+ } + was="Dateien" + wert={String(daten.files)} + onClick={() => gehZu('/galerie')} + /> + } was="Belegt" wert={bytes(daten.bytes)} /> + } + was="Neu heute" + wert={String(daten.heuteHoch)} + unten={`${daten.wocheHoch} in sieben Tagen`} + onClick={() => gehZu('/verlauf')} + /> + } + was="Token" + wert={String(daten.tokens.length)} + onClick={() => gehZu('/tokens')} + /> +
+ + {/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */} + +
+ + {vorlage} + + +
+
+ +
+ + {daten.bilder.length === 0 ? ( + + ) : ( +
+ {daten.bilder.map((b) => ( + + ))} +
+ )} +
+ + + {daten.letzte.length === 0 ? ( + + ) : ( +
    + {daten.letzte.map((e) => { + const a = arten[e.kind] + const Kind = a.kind + return ( +
  • + + + {e.path} + + + {zeit(e.at)} + +
  • + ) + })} +
+ )} +
+
+ +
+ }> + {daten.ordner.length === 0 ? ( + + ) : ( +
    + {daten.ordner.map((o) => ( +
  • + + {o.folder} + + + {o.files} Dateien + + + {bytes(o.bytes)} + +
  • + ))} +
+ )} +
+ + +
    + {daten.tokens.map((t) => ( +
  • + + {t.name} + + {t.last_used_at ? `zuletzt ${zeit(t.last_used_at)}` : 'nie benutzt'} + +
  • + ))} +
  • + + Discord-Anmeldung {daten.discord ? 'eingerichtet' : 'nicht eingerichtet'} +
  • +
+
+
+
+ ) +} + +function Kachel({ + kind, + was, + wert, + unten, + onClick, +}: { + kind: React.ReactNode + was: string + wert: string + unten?: string + onClick?: () => void +}) { + const inhalt = ( + <> +
+ {kind} + {was} +
+

{wert}

+ {unten &&

{unten}

} + + ) + + // Nur was wirklich irgendwohin fuehrt, wird ein Knopf. Eine Kachel, die + // aussieht wie ein Knopf und nichts tut, ist der Anfang des Misstrauens. + return onClick ? ( + + ) : ( +
{inhalt}
+ ) +} diff --git a/server/ui/src/router.ts b/server/ui/src/router.ts new file mode 100644 index 0000000..a63c950 --- /dev/null +++ b/server/ui/src/router.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' + +// Ein winziger Router über die History-API statt einer Bibliothek. +// +// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut +// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein +// Neuladen auf /tokens im Router landet und nicht im 404. +// +// `gehZu` steht hier als MODULFUNKTION und nicht als Eigenschaft der Seiten. +// Der Unterschied ist mehr als Geschmack: sobald eine einzige Seite eine +// Navigationsfunktion durchgereicht bekäme, hätten alle Seiten verschiedene +// Signaturen — und die gemeinsame Liste in App.tsx ginge nicht mehr. Genau +// daran ist der erste Versuch gescheitert. + +/** Woanders hin, ohne die Seite neu zu laden. */ +export function gehZu(pfad: string) { + if (window.location.pathname === pfad) return + window.history.pushState(null, '', pfad) + // pushState löst KEIN popstate aus — das tut nur der Zurück-Knopf. Ohne + // dieses Ereignis bliebe die Ansicht stehen, obwohl die Adresse wechselt. + window.dispatchEvent(new PopStateEvent('popstate')) +} + +export function usePfad(): string { + const [pfad, setPfad] = useState(window.location.pathname) + + useEffect(() => { + const zurueck = () => setPfad(window.location.pathname) + window.addEventListener('popstate', zurueck) + return () => window.removeEventListener('popstate', zurueck) + }, []) + + return pfad +} diff --git a/tools/sichern.ps1 b/tools/sichern.ps1 index 11d112e..aefaf36 100644 --- a/tools/sichern.ps1 +++ b/tools/sichern.ps1 @@ -16,7 +16,16 @@ # 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. Alte Archive fliegen raus, sobald mehr als -Behalten da sind. +# 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. +# 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 → @@ -88,9 +97,12 @@ Gut "$name — $([math]::Round($groesse/1MB, 1)) MB" # -- 3. Nachzaehlen ------------------------------------------------------------ Schritt 'Sicherung pruefen (auspacken, Datenbank oeffnen, zaehlen)' -$pruefung = docker run --rm ` - -v "${Ziel}:/in:ro" ` - --entrypoint sh $Abbild -c @" + +# 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' @@ -105,7 +117,9 @@ 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() }) @@ -125,7 +139,102 @@ if ($dateien -ne $zahlen.media) { } Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.' -# -- 4. Alte Sicherungen ausduennen -------------------------------------------- +# -- 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. + +$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 = @{} + 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 + } + 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')) { + Schlecht "MKCOL $pfadBisher -> HTTP $code" + if ($code -eq '401') { Schlecht 'Benutzer oder App-Passwort stimmt nicht.' } + exit 1 + } + } + + $ziel = "$wurzel/$($z['ordner'])/$name" + $code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $ziel + if ($code -notin @('201', '204')) { + Schlecht "Hochladen fehlgeschlagen -> 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. + $zurueck = Join-Path $env:TEMP "pruefe-$name" + curl.exe -s -u $auth -o $zurueck $ziel + $hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash + $dortHash = (Get-FileHash $zurueck -Algorithm SHA256).Hash + Remove-Item $zurueck -Force -ErrorAction SilentlyContinue + + if ($hierHash -ne $dortHash) { + Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.' + exit 1 + } + Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)" + + # 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') | + ForEach-Object { $_.Value } | Sort-Object -Unique -Descending) + if ($fern.Count -gt $Behalten) { + foreach ($alt in $fern | 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) {