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 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 19:27:09 +02:00
co-authored by Claude Opus 5
parent 84cb3ee200
commit 74cf56ce81
6 changed files with 516 additions and 34 deletions
+62
View File
@@ -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<Token, 'id' | 'name' | 'prefix' | 'can_delete' | 'last_used_at'>[]
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')
+16 -28
View File
@@ -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()
+15
View File
@@ -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<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
stand: () => get<Stand>('/api/dash/stand'),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
+274
View File
@@ -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<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
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<Uebersicht | null>(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 (
<div className="flex justify-center py-16">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
if (!daten) return <Leer text="Die Übersicht ließ sich nicht laden." />
const vorlage = `${daten.publicUrl}${daten.filePrefix}/vehicles/{model}.webp`
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Avatar name={user?.username ?? '?'} url={user?.avatar} groesse={38} />
<div>
<h1 className="text-lg font-semibold">
Hallo {user?.discordName ?? user?.username ?? ''}
</h1>
<p className="text-xs text-schrift-leise">
{daten.files} Dateien · {bytes(daten.bytes)} belegt
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kachel
kind={<Images size={16} />}
was="Dateien"
wert={String(daten.files)}
onClick={() => gehZu('/galerie')}
/>
<Kachel kind={<HardDrive size={16} />} was="Belegt" wert={bytes(daten.bytes)} />
<Kachel
kind={<TrendingUp size={16} />}
was="Neu heute"
wert={String(daten.heuteHoch)}
unten={`${daten.wocheHoch} in sieben Tagen`}
onClick={() => gehZu('/verlauf')}
/>
<Kachel
kind={<KeyRound size={16} />}
was="Token"
wert={String(daten.tokens.length)}
onClick={() => gehZu('/tokens')}
/>
</div>
{/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */}
<Karte titel="Adressvorlage">
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
{vorlage}
</code>
<button
onClick={async () => {
if (!(await kopieren(vorlage, 'v')))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Vorlage kopieren"
>
{kopiert === 'v' ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
</div>
</Karte>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Zuletzt hochgeladen">
{daten.bilder.length === 0 ? (
<Leer text="Noch nichts da." />
) : (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{daten.bilder.map((b) => (
<button
key={b.id}
onClick={() => gehZu('/galerie')}
title={b.path}
className="aspect-square overflow-hidden rounded border border-rand bg-grund"
>
<img
src={vorschauUrl(b.path)}
onError={(e) => {
const i = e.currentTarget
if (i.dataset.gefallen) return
i.dataset.gefallen = 'ja'
i.src = b.url
}}
alt={b.path}
loading="lazy"
className="h-full w-full object-contain"
/>
</button>
))}
</div>
)}
</Karte>
<Karte titel="Zuletzt passiert">
{daten.letzte.length === 0 ? (
<Leer text="Noch nichts passiert." />
) : (
<ul className="flex flex-col gap-1.5 text-sm">
{daten.letzte.map((e) => {
const a = arten[e.kind]
const Kind = a.kind
return (
<li key={e.id} className="flex items-center gap-2">
<Kind size={13} className={`shrink-0 ${a.farbe}`} />
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
title={e.path}
>
{e.path}
</span>
<span className="shrink-0 text-[11px] text-schrift-leise">
{zeit(e.at)}
</span>
</li>
)
})}
</ul>
)}
</Karte>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Größte Ordner" rechts={<Folder size={14} className="text-schrift-leise" />}>
{daten.ordner.length === 0 ? (
<Leer text="Noch nichts abgelegt." />
) : (
<ul className="flex flex-col gap-1.5 text-sm">
{daten.ordner.map((o) => (
<li key={o.folder} className="flex items-center gap-2">
<span className="flex-1 truncate font-mono text-xs">
{o.folder}
</span>
<span className="text-xs text-schrift-leise">
{o.files} Dateien
</span>
<span className="w-16 text-right text-xs">
{bytes(o.bytes)}
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte titel="Zugänge">
<ul className="flex flex-col gap-1.5 text-sm">
{daten.tokens.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<KeyRound size={13} className="shrink-0 text-schrift-leise" />
<span className="flex-1 truncate">{t.name}</span>
<span className="text-xs text-schrift-leise">
{t.last_used_at ? `zuletzt ${zeit(t.last_used_at)}` : 'nie benutzt'}
</span>
</li>
))}
<li className="mt-1 flex items-center gap-2 border-t border-rand pt-2 text-xs">
<ShieldCheck
size={13}
className={daten.discord ? 'text-gut' : 'text-schrift-leise'}
/>
Discord-Anmeldung {daten.discord ? 'eingerichtet' : 'nicht eingerichtet'}
</li>
</ul>
</Karte>
</div>
</div>
)
}
function Kachel({
kind,
was,
wert,
unten,
onClick,
}: {
kind: React.ReactNode
was: string
wert: string
unten?: string
onClick?: () => void
}) {
const inhalt = (
<>
<div className="flex items-center gap-2 text-xs text-schrift-leise">
{kind}
{was}
</div>
<p className="mt-1 text-2xl font-semibold">{wert}</p>
{unten && <p className="text-[11px] text-schrift-leise">{unten}</p>}
</>
)
// 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 ? (
<button
onClick={onClick}
className="rounded-lg border border-rand bg-flaeche p-4 text-left transition-colors hover:border-akzent"
>
{inhalt}
</button>
) : (
<div className="rounded-lg border border-rand bg-flaeche p-4">{inhalt}</div>
)
}
+34
View File
@@ -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
}
+115 -6
View File
@@ -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) {