feat: ein Einstellungen-Tab -- und die Sicherung meldet sich zurueck
Die Zugangsdaten der Sicherung standen in einer Textdatei auf der Platte. Jetzt
stehen sie im Panel, zusammen mit der Discord-Einrichtung, die vom Konto
dorthin umgezogen ist.
DER EIGENTLICHE GEWINN IST NICHT DIE BEQUEMLICHKEIT. Eine Textdatei sagt
niemandem, ob die Sicherung heute Nacht gelaufen ist -- und eine Sicherung, die
still aufhoert zu laufen, ist der eigentliche Schaden, nicht die eine Nacht, in
der sie fehlt. Das Skript schreibt sein Ergebnis deshalb nach jedem Lauf in die
Datenbank, und die Karte zeigt es als Erstes: wann, ob erfolgreich, wie viele
Dateien, wie gross, und ob es in der Nextcloud gelandet ist oder nur lokal.
Nachgemessen: {"dateien":228,"groesse":12063419,"hoch":false,"ok":true}.
DAS PASSWORT GEHT NICHT UEBER DIE API. Das Skript laeuft auf dem Wirt, nicht im
Dienst -- es liest die Werte per `docker exec` DIREKT aus media.db. Ueber HTTP
geht das Passwort nur beim Eintragen im Browser, und das ueber TLS. Zurueck gibt
die API nur "hatPasswort: true", wie beim Discord-Geheimnis.
nextcloud.txt neben den Sicherungen gilt weiter und hat VORRANG: wer sie
angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird.
Zwei Fussangeln beim Melden, beide beim Bauen aufgefallen:
- Der JSON-Text geht ueber die STANDARDEINGABE in den Container und nicht als
Argument -- Anfuehrungszeichen ueberleben den Weg durch zwei Shells nicht.
- Auch dieses Hier-Dokument braucht das -replace "`r": dieselbe CRLF-Falle wie
bei der Pruefung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,15 @@ import {
|
|||||||
} from '../auth.js'
|
} from '../auth.js'
|
||||||
import { readdir, readFile, rm, stat } from 'node:fs/promises'
|
import { readdir, readFile, rm, stat } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { DISCORD_KEYS, ausDatenbank, discordConfig, setSetting } from '../settings.js'
|
import {
|
||||||
|
DISCORD_KEYS,
|
||||||
|
SICHERUNG_KEYS,
|
||||||
|
ausDatenbank,
|
||||||
|
discordConfig,
|
||||||
|
getSetting,
|
||||||
|
setSetting,
|
||||||
|
sicherungConfig,
|
||||||
|
} from '../settings.js'
|
||||||
import { discordRoutes, redirectUri } from './discord.js'
|
import { discordRoutes, redirectUri } from './discord.js'
|
||||||
import {
|
import {
|
||||||
absolutePath,
|
absolutePath,
|
||||||
@@ -701,6 +709,19 @@ dashRoutes.get('/settings', (c) => {
|
|||||||
!ausDatenbank(DISCORD_KEYS.roleIdAlt),
|
!ausDatenbank(DISCORD_KEYS.roleIdAlt),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sicherung: (() => {
|
||||||
|
const sc = sicherungConfig()
|
||||||
|
let letzte: unknown = null
|
||||||
|
try {
|
||||||
|
const roh = getSetting(SICHERUNG_KEYS.letzte)
|
||||||
|
letzte = roh ? JSON.parse(roh) : null
|
||||||
|
} catch {
|
||||||
|
// Ein kaputter Eintrag ist kein Grund, die ganze Seite
|
||||||
|
// scheitern zu lassen -- dann steht dort eben "unbekannt".
|
||||||
|
letzte = null
|
||||||
|
}
|
||||||
|
return { ...sc, letzte }
|
||||||
|
})(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -712,9 +733,61 @@ dashRoutes.post('/settings', async (c) => {
|
|||||||
guildId?: string
|
guildId?: string
|
||||||
roleIds?: string | string[]
|
roleIds?: string | string[]
|
||||||
}
|
}
|
||||||
|
sicherung?: {
|
||||||
|
basis?: string
|
||||||
|
benutzer?: string
|
||||||
|
passwort?: string
|
||||||
|
ordner?: string
|
||||||
|
behalten?: number | string
|
||||||
|
}
|
||||||
}>(c)
|
}>(c)
|
||||||
if (!body) return c.json(KEIN_JSON, 400)
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
|
|
||||||
|
// -- Sicherung ----------------------------------------------------------
|
||||||
|
if (body.sicherung) {
|
||||||
|
const sic = body.sicherung
|
||||||
|
if (sic.basis !== undefined) {
|
||||||
|
const v = sic.basis.trim().replace(/\/+$/, '')
|
||||||
|
if (v && !/^https?:\/\//.test(v)) {
|
||||||
|
return c.json({ error: 'Adresse muss mit http:// oder https:// anfangen' }, 400)
|
||||||
|
}
|
||||||
|
setSetting(SICHERUNG_KEYS.basis, v)
|
||||||
|
}
|
||||||
|
if (sic.benutzer !== undefined) setSetting(SICHERUNG_KEYS.benutzer, sic.benutzer.trim())
|
||||||
|
if (sic.ordner !== undefined) {
|
||||||
|
// Kein fuehrender Schraegstrich: der Pfad haengt hinter der
|
||||||
|
// WebDAV-Wurzel, und "//Sicherungen" ergibt dort einen Ordner
|
||||||
|
// ohne Namen.
|
||||||
|
setSetting(SICHERUNG_KEYS.ordner, sic.ordner.trim().replace(/^\/+|\/+$/g, ''))
|
||||||
|
}
|
||||||
|
if (sic.behalten !== undefined) {
|
||||||
|
const n = Number(sic.behalten)
|
||||||
|
if (!Number.isFinite(n) || n < 1 || n > 365) {
|
||||||
|
return c.json({ error: 'Behalten: zwischen 1 und 365' }, 400)
|
||||||
|
}
|
||||||
|
setSetting(SICHERUNG_KEYS.behalten, String(Math.round(n)))
|
||||||
|
}
|
||||||
|
// Wie beim Discord-Geheimnis: leer heisst unveraendert, "-" entfernt.
|
||||||
|
if (sic.passwort !== undefined) {
|
||||||
|
const v = sic.passwort.trim()
|
||||||
|
if (v === '-') setSetting(SICHERUNG_KEYS.passwort, '')
|
||||||
|
else if (v) setSetting(SICHERUNG_KEYS.passwort, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.discord) {
|
||||||
|
const jetzt = sicherungConfig()
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
bereit: jetzt.bereit,
|
||||||
|
fehlt: [
|
||||||
|
!jetzt.basis && 'Adresse',
|
||||||
|
!jetzt.benutzer && 'Benutzername',
|
||||||
|
!jetzt.hatPasswort && 'App-Passwort',
|
||||||
|
].filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const d = body.discord
|
const d = body.discord
|
||||||
if (!d) return c.json({ error: 'nichts zu speichern' }, 400)
|
if (!d) return c.json({ error: 'nichts zu speichern' }, 400)
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,54 @@ export function ausDatenbank(key: string): boolean {
|
|||||||
return Boolean(zeile && zeile.value !== '')
|
return Boolean(zeile && zeile.value !== '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- Sicherung
|
||||||
|
//
|
||||||
|
// Die Werte fuer die Sicherung stehen hier, obwohl das Skript sie braucht und
|
||||||
|
// nicht der Dienst. Der Grund ist die Rueckmeldung: eine Textdatei auf der
|
||||||
|
// Platte sagt niemandem, ob die Sicherung heute Nacht gelaufen ist. Ein
|
||||||
|
// Eintrag in der Datenbank kann das Panel anzeigen.
|
||||||
|
//
|
||||||
|
// tools/sichern.ps1 liest sie ueber `docker exec` DIREKT aus media.db und
|
||||||
|
// nicht ueber die API: so verlaesst das Passwort den Rechner nie -- ausser
|
||||||
|
// beim Eintragen im Browser, und das geht ueber TLS.
|
||||||
|
|
||||||
|
export const SICHERUNG_KEYS = {
|
||||||
|
basis: 'sicherung.basis',
|
||||||
|
benutzer: 'sicherung.benutzer',
|
||||||
|
passwort: 'sicherung.passwort',
|
||||||
|
ordner: 'sicherung.ordner',
|
||||||
|
behalten: 'sicherung.behalten',
|
||||||
|
/** Schreibt das Skript nach jedem Lauf. JSON, damit das Panel es anzeigen
|
||||||
|
* kann, ohne dass hier eine weitere Tabelle noetig waere. */
|
||||||
|
letzte: 'sicherung.letzte',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SicherungConfig = {
|
||||||
|
basis: string
|
||||||
|
benutzer: string
|
||||||
|
ordner: string
|
||||||
|
behalten: number
|
||||||
|
hatPasswort: boolean
|
||||||
|
bereit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sicherungConfig(): SicherungConfig {
|
||||||
|
const basis = getSetting(SICHERUNG_KEYS.basis)
|
||||||
|
const benutzer = getSetting(SICHERUNG_KEYS.benutzer)
|
||||||
|
const passwort = getSetting(SICHERUNG_KEYS.passwort)
|
||||||
|
const ordner = getSetting(SICHERUNG_KEYS.ordner) || 'Sicherungen/d4rk_media'
|
||||||
|
const behalten = Number(getSetting(SICHERUNG_KEYS.behalten)) || 14
|
||||||
|
|
||||||
|
return {
|
||||||
|
basis,
|
||||||
|
benutzer,
|
||||||
|
ordner,
|
||||||
|
behalten,
|
||||||
|
hatPasswort: Boolean(passwort),
|
||||||
|
bereit: Boolean(basis && benutzer && passwort),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- Discord
|
// ------------------------------------------------------------------- Discord
|
||||||
|
|
||||||
export const DISCORD_KEYS = {
|
export const DISCORD_KEYS = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
LogOut,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
Settings,
|
||||||
UserCog,
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { run, useStore } from './store'
|
import { run, useStore } from './store'
|
||||||
@@ -16,6 +17,7 @@ import { Galerie } from './pages/Galerie'
|
|||||||
import { Speicher } from './pages/Speicher'
|
import { Speicher } from './pages/Speicher'
|
||||||
import { Tokens } from './pages/Tokens'
|
import { Tokens } from './pages/Tokens'
|
||||||
import { Konto } from './pages/Konto'
|
import { Konto } from './pages/Konto'
|
||||||
|
import { Einstellungen } from './pages/Einstellungen'
|
||||||
import { Verlauf } from './pages/Verlauf'
|
import { Verlauf } from './pages/Verlauf'
|
||||||
import { Meldungen } from './components/Meldungen'
|
import { Meldungen } from './components/Meldungen'
|
||||||
import { gehZu, usePfad } from './router'
|
import { gehZu, usePfad } from './router'
|
||||||
@@ -28,6 +30,7 @@ const seiten = [
|
|||||||
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
||||||
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
||||||
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
|
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
|
||||||
|
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
|||||||
@@ -182,7 +182,27 @@ export type Verwaiste = {
|
|||||||
vorschauOhneBild: { anzahl: number; beispiele: string[] }
|
vorschauOhneBild: { anzahl: number; beispiele: string[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */
|
||||||
|
export type LetzteSicherung = {
|
||||||
|
at: number
|
||||||
|
ok: boolean
|
||||||
|
dateien?: number
|
||||||
|
groesse?: number
|
||||||
|
/** true = in der Nextcloud, false = nur lokal, undefined = unbekannt. */
|
||||||
|
hoch?: boolean
|
||||||
|
fehler?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
export type Einstellungen = {
|
export type Einstellungen = {
|
||||||
|
sicherung: {
|
||||||
|
basis: string
|
||||||
|
benutzer: string
|
||||||
|
ordner: string
|
||||||
|
behalten: number
|
||||||
|
hatPasswort: boolean
|
||||||
|
bereit: boolean
|
||||||
|
letzte: LetzteSicherung
|
||||||
|
}
|
||||||
discord: {
|
discord: {
|
||||||
clientId: string
|
clientId: string
|
||||||
guildId: string
|
guildId: string
|
||||||
@@ -301,6 +321,8 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
|
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
|
||||||
|
sicherungSpeichern: (sicherung: Record<string, string | number>) =>
|
||||||
|
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }),
|
||||||
einstellungenSpeichern: (discord: Record<string, string>) =>
|
einstellungenSpeichern: (discord: Record<string, string>) =>
|
||||||
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
|
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { AlertTriangle, CheckCircle2, CloudUpload, Loader2, ShieldCheck, ShieldX } from 'lucide-react'
|
||||||
|
import { api, type Einstellungen as EinstellungenDaten } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||||
|
import { DiscordKarte } from '../components/DiscordKarte'
|
||||||
|
|
||||||
|
/** Alles, was sich einstellen lässt, an einer Stelle.
|
||||||
|
*
|
||||||
|
* Bisher lag die Discord-Karte beim Konto und die Zugangsdaten der Sicherung
|
||||||
|
* in einer Textdatei auf der Platte. Beides ist jetzt hier — und das ist mehr
|
||||||
|
* als Aufräumen: eine Textdatei sagt niemandem, ob die Sicherung heute Nacht
|
||||||
|
* gelaufen ist. Diese Seite tut es. */
|
||||||
|
export function Einstellungen() {
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-2xl flex-col gap-4">
|
||||||
|
<SicherungKarte />
|
||||||
|
<DiscordKarte />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SicherungKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<EinstellungenDaten | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const [speichert, setSpeichert] = useState(false)
|
||||||
|
|
||||||
|
const [basis, setBasis] = useState('')
|
||||||
|
const [benutzer, setBenutzer] = useState('')
|
||||||
|
const [passwort, setPasswort] = useState('')
|
||||||
|
const [ordner, setOrdner] = useState('')
|
||||||
|
const [behalten, setBehalten] = useState('14')
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
setLaedt(true)
|
||||||
|
try {
|
||||||
|
const e = await api.einstellungen()
|
||||||
|
setDaten(e)
|
||||||
|
setBasis(e.sicherung.basis)
|
||||||
|
setBenutzer(e.sicherung.benutzer)
|
||||||
|
setOrdner(e.sicherung.ordner)
|
||||||
|
setBehalten(String(e.sicherung.behalten))
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function speichern() {
|
||||||
|
setSpeichert(true)
|
||||||
|
const ergebnis = await run(
|
||||||
|
() =>
|
||||||
|
api.sicherungSpeichern({
|
||||||
|
basis,
|
||||||
|
benutzer,
|
||||||
|
...(passwort ? { passwort } : {}),
|
||||||
|
ordner,
|
||||||
|
behalten: Number(behalten),
|
||||||
|
}),
|
||||||
|
(r) =>
|
||||||
|
r.bereit
|
||||||
|
? 'Gespeichert. Die nächste Sicherung geht in die Nextcloud.'
|
||||||
|
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
|
||||||
|
)
|
||||||
|
setSpeichert(false)
|
||||||
|
if (ergebnis) {
|
||||||
|
setPasswort('')
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (laedt) {
|
||||||
|
return (
|
||||||
|
<Karte titel="Sicherung">
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!daten) return null
|
||||||
|
|
||||||
|
const s = daten.sicherung
|
||||||
|
const l = s.letzte
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Sicherung in die Nextcloud"
|
||||||
|
rechts={
|
||||||
|
s.bereit ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gut">
|
||||||
|
<ShieldCheck size={14} /> eingerichtet
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||||
|
<ShieldX size={14} /> nur lokal
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Das Wichtigste zuerst: LIEF sie? Eine Sicherung, die still
|
||||||
|
aufhoert zu laufen, ist der eigentliche Schaden. */}
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${
|
||||||
|
!l
|
||||||
|
? 'border-rand bg-grund text-schrift-leise'
|
||||||
|
: l.ok
|
||||||
|
? 'border-gut/40 bg-gut/10'
|
||||||
|
: 'border-schlecht/40 bg-schlecht/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!l ? (
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
) : l.ok ? (
|
||||||
|
<CheckCircle2 size={16} className="mt-0.5 shrink-0 text-gut" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
|
||||||
|
)}
|
||||||
|
<span className="text-schrift">
|
||||||
|
{!l ? (
|
||||||
|
'Noch keine Sicherung gemeldet. Sie läuft täglich um 04:30 als geplante Aufgabe.'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Zuletzt {zeit(l.at)} — {l.ok ? 'erfolgreich' : 'FEHLGESCHLAGEN'}
|
||||||
|
{l.dateien != null && `, ${l.dateien} Dateien`}
|
||||||
|
{l.groesse != null && `, ${bytes(l.groesse)}`}
|
||||||
|
{l.hoch === true && ', in der Nextcloud'}
|
||||||
|
{l.hoch === false && ', nur lokal'}
|
||||||
|
{l.fehler && (
|
||||||
|
<span className="block text-xs text-schlecht">{l.fehler}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
label="Nextcloud-Adresse"
|
||||||
|
value={basis}
|
||||||
|
placeholder="https://cdn.d4rkst3r.de"
|
||||||
|
onChange={(e) => setBasis(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Benutzername"
|
||||||
|
value={benutzer}
|
||||||
|
placeholder="wie beim Anmelden in der Nextcloud"
|
||||||
|
onChange={(e) => setBenutzer(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="App-Passwort"
|
||||||
|
type="password"
|
||||||
|
value={passwort}
|
||||||
|
placeholder={s.hatPasswort ? '•••••••• (hinterlegt)' : 'noch keines'}
|
||||||
|
hinweis='leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
|
||||||
|
onChange={(e) => setPasswort(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Ordner"
|
||||||
|
value={ordner}
|
||||||
|
placeholder="Sicherungen/d4rk_media"
|
||||||
|
onChange={(e) => setOrdner(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Wie viele behalten"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={behalten}
|
||||||
|
hinweis="gilt hier wie in der Nextcloud"
|
||||||
|
onChange={(e) => setBehalten(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="flex items-start gap-2 text-xs text-schrift-leise">
|
||||||
|
<CloudUpload size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Nimm ein <strong>App-Passwort</strong>, nicht dein echtes: Nextcloud →
|
||||||
|
Einstellungen → Sicherheit → „Neues App-Passwort erstellen". Es lässt
|
||||||
|
sich einzeln zurückziehen. Das Skript liest die Werte direkt aus der
|
||||||
|
Datenbank des Dienstes — über HTTP geht das Passwort nur hier beim
|
||||||
|
Eintragen, und das über TLS.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { useState, type FormEvent } from 'react'
|
|||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { run, useStore } from '../store'
|
import { run, useStore } from '../store'
|
||||||
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||||
import { DiscordKarte } from '../components/DiscordKarte'
|
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
export function Konto() {
|
export function Konto() {
|
||||||
@@ -105,8 +104,6 @@ export function Konto() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
<DiscordKarte />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-23
@@ -16,9 +16,12 @@
|
|||||||
# 3. Das Archiv wird GEPRUEFT: ausgepackt, die Datenbank geoeffnet, die
|
# 3. Das Archiv wird GEPRUEFT: ausgepackt, die Datenbank geoeffnet, die
|
||||||
# Eintraege gezaehlt und mit dem laufenden Dienst verglichen. Eine
|
# Eintraege gezaehlt und mit dem laufenden Dienst verglichen. Eine
|
||||||
# Sicherung, die niemand nachzaehlt, ist eine Vermutung.
|
# Sicherung, die niemand nachzaehlt, ist eine Vermutung.
|
||||||
# 4. Das Archiv geht in die NEXTCLOUD, wenn Zugangsdaten hinterlegt sind.
|
# 4. Das Archiv geht in die NEXTCLOUD, wenn im Panel Zugangsdaten stehen
|
||||||
# Bis dahin lag die Sicherung auf derselben Platte wie die Daten — gegen
|
# (Einstellungen → Sicherung). Bis dahin lag die Sicherung auf derselben
|
||||||
# einen Plattenausfall half sie damit gar nicht.
|
# Platte wie die Daten — gegen einen Plattenausfall half sie gar nicht.
|
||||||
|
# 4b. Und der Lauf MELDET SICH ZURUECK: das Ergebnis landet in der Datenbank
|
||||||
|
# und steht danach im Panel. Eine Sicherung, die still aufhoert zu laufen,
|
||||||
|
# ist der eigentliche Schaden — nicht die eine Nacht, in der sie fehlt.
|
||||||
# 5. Alte Archive fliegen raus, hier wie dort, sobald mehr als -Behalten da
|
# 5. Alte Archive fliegen raus, hier wie dort, sobald mehr als -Behalten da
|
||||||
# sind.
|
# sind.
|
||||||
#
|
#
|
||||||
@@ -49,6 +52,36 @@ function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan }
|
|||||||
function Gut($text) { Write-Host " $text" -ForegroundColor Green }
|
function Gut($text) { Write-Host " $text" -ForegroundColor Green }
|
||||||
function Schlecht($text) { Write-Host " $text" -ForegroundColor Red }
|
function Schlecht($text) { Write-Host " $text" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# Das Ergebnis in die Datenbank des Dienstes schreiben, damit es im Panel
|
||||||
|
# steht. Schlaegt DAS fehl, ist es kein Grund, die Sicherung als gescheitert zu
|
||||||
|
# behandeln — das Archiv liegt dann trotzdem richtig.
|
||||||
|
$inDerWolke = $false
|
||||||
|
|
||||||
|
function Melde($ok, $dateien, $groesse, $hoch, $fehler) {
|
||||||
|
$eintrag = @{
|
||||||
|
at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
|
||||||
|
ok = [bool]$ok
|
||||||
|
dateien = $dateien
|
||||||
|
groesse = $groesse
|
||||||
|
hoch = [bool]$hoch
|
||||||
|
}
|
||||||
|
if ($fehler) { $eintrag['fehler'] = "$fehler" }
|
||||||
|
$json = ($eintrag | ConvertTo-Json -Compress)
|
||||||
|
|
||||||
|
# Ueber die Standardeingabe und nicht als Argument: der JSON-Text enthaelt
|
||||||
|
# Anfuehrungszeichen, und die ueberleben den Weg durch zwei Shells nicht.
|
||||||
|
$skript = @"
|
||||||
|
let roh = '';
|
||||||
|
process.stdin.on('data', (d) => (roh += d)).on('end', () => {
|
||||||
|
const db = require('better-sqlite3')('/data/media.db');
|
||||||
|
db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
|
||||||
|
.run('sicherung.letzte', roh.trim(), Date.now());
|
||||||
|
});
|
||||||
|
"@ -replace "`r", ''
|
||||||
|
$json | docker exec -i $Container node -e $skript 2>&1 | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
# -- Vorbedingungen ------------------------------------------------------------
|
# -- Vorbedingungen ------------------------------------------------------------
|
||||||
|
|
||||||
$laeuft = docker ps --filter "name=^/$Container$" --format '{{.Names}}'
|
$laeuft = docker ps --filter "name=^/$Container$" --format '{{.Names}}'
|
||||||
@@ -157,22 +190,47 @@ Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.'
|
|||||||
# "Neues App-Passwort erstellen". Es laesst sich einzeln zurueckziehen, ohne
|
# "Neues App-Passwort erstellen". Es laesst sich einzeln zurueckziehen, ohne
|
||||||
# dass das eigene Passwort geaendert werden muss — genau dafuer gibt es sie.
|
# dass das eigene Passwort geaendert werden muss — genau dafuer gibt es sie.
|
||||||
|
|
||||||
|
# Die Werte kommen aus der Datenbank des Dienstes, eingetragen im Panel unter
|
||||||
|
# Einstellungen → Sicherung. Gelesen wird DIREKT ueber `docker exec` und nicht
|
||||||
|
# ueber die API: so verlaesst das Passwort den Rechner nie.
|
||||||
|
#
|
||||||
|
# nextcloud.txt neben den Sicherungen gilt weiterhin und hat VORRANG — wer sie
|
||||||
|
# angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird.
|
||||||
|
|
||||||
|
$z = @{}
|
||||||
$zugangDatei = Join-Path $Ziel 'nextcloud.txt'
|
$zugangDatei = Join-Path $Ziel 'nextcloud.txt'
|
||||||
|
|
||||||
if (-not (Test-Path $zugangDatei)) {
|
if (Test-Path $zugangDatei) {
|
||||||
Write-Host " Keine Nextcloud hinterlegt ($zugangDatei) — die Sicherung bleibt lokal." -ForegroundColor Yellow
|
|
||||||
} else {
|
|
||||||
$z = @{}
|
|
||||||
foreach ($zeile in Get-Content $zugangDatei) {
|
foreach ($zeile in Get-Content $zugangDatei) {
|
||||||
if ($zeile -match '^\s*#') { continue }
|
if ($zeile -match '^\s*#') { continue }
|
||||||
if ($zeile -match '^\s*([a-z]+)\s*=\s*(.+?)\s*$') { $z[$Matches[1]] = $Matches[2] }
|
if ($zeile -match '^\s*([a-z]+)\s*=\s*(.+?)\s*$') { $z[$Matches[1]] = $Matches[2] }
|
||||||
}
|
}
|
||||||
|
Gut "Zugang aus $zugangDatei"
|
||||||
$fehlend = @('basis', 'benutzer', 'passwort') | Where-Object { -not $z[$_] }
|
} else {
|
||||||
if ($fehlend) {
|
$roh = docker exec $Container node -e @"
|
||||||
Schlecht "In $zugangDatei fehlt: $($fehlend -join ', ')"
|
const db = require('better-sqlite3')('/data/media.db', { readonly: true });
|
||||||
exit 1
|
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'),
|
||||||
|
ordner: hol('sicherung.ordner'),
|
||||||
|
behalten: hol('sicherung.behalten'),
|
||||||
|
}));
|
||||||
|
"@
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $roh) {
|
||||||
|
$j = $roh | ConvertFrom-Json
|
||||||
|
foreach ($k in 'basis', 'benutzer', 'passwort', 'ordner', 'behalten') {
|
||||||
|
if ($j.$k) { $z[$k] = $j.$k }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($z['behalten']) { $Behalten = [int]$z['behalten'] }
|
||||||
|
|
||||||
|
if (-not ($z['basis'] -and $z['benutzer'] -and $z['passwort'])) {
|
||||||
|
Write-Host ' Keine Nextcloud eingerichtet (Panel → Einstellungen → Sicherung) — die Sicherung bleibt lokal.' -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
if (-not $z['ordner']) { $z['ordner'] = 'Sicherungen/d4rk_media' }
|
if (-not $z['ordner']) { $z['ordner'] = 'Sicherungen/d4rk_media' }
|
||||||
|
|
||||||
$basis = $z['basis'].TrimEnd('/')
|
$basis = $z['basis'].TrimEnd('/')
|
||||||
@@ -191,42 +249,47 @@ if (-not (Test-Path $zugangDatei)) {
|
|||||||
# 201 = angelegt, 405 = gab es schon. Beides ist in Ordnung.
|
# 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"
|
$code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X MKCOL "$wurzel/$pfadBisher"
|
||||||
if ($code -notin @('201', '405')) {
|
if ($code -notin @('201', '405')) {
|
||||||
Schlecht "MKCOL $pfadBisher -> HTTP $code"
|
$fehlertext = "MKCOL $pfadBisher -> HTTP $code"
|
||||||
if ($code -eq '401') { Schlecht 'Benutzer oder App-Passwort stimmt nicht.' }
|
if ($code -eq '401') { $fehlertext += ' (Benutzer oder App-Passwort stimmt nicht)' }
|
||||||
|
Schlecht $fehlertext
|
||||||
|
Melde $false $zahlen.media $groesse $false $fehlertext
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$ziel = "$wurzel/$($z['ordner'])/$name"
|
$fern = "$wurzel/$($z['ordner'])/$name"
|
||||||
$code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $ziel
|
$code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $fern
|
||||||
if ($code -notin @('201', '204')) {
|
if ($code -notin @('201', '204')) {
|
||||||
Schlecht "Hochladen fehlgeschlagen -> HTTP $code"
|
Schlecht "Hochladen fehlgeschlagen -> HTTP $code"
|
||||||
|
Melde $false $zahlen.media $groesse $false "Hochladen -> HTTP $code"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
Gut "hochgeladen ($code)"
|
Gut "hochgeladen ($code)"
|
||||||
|
|
||||||
# NACHZAEHLEN, auch hier: zurueckholen und die Pruefsumme vergleichen. Ein
|
# NACHZAEHLEN, auch hier: zurueckholen und die Pruefsumme vergleichen. Ein
|
||||||
# PUT, der 201 sagt, hat noch nichts bewiesen — abgeschnittene Uebertragungen
|
# PUT, der 201 sagt, hat noch nichts bewiesen — abgeschnittene
|
||||||
# sehen genauso aus. Bei ein paar Megabyte taeglich ist das billig.
|
# Uebertragungen sehen genauso aus. Bei ein paar Megabyte ist das billig.
|
||||||
$zurueck = Join-Path $env:TEMP "pruefe-$name"
|
$zurueck = Join-Path $env:TEMP "pruefe-$name"
|
||||||
curl.exe -s -u $auth -o $zurueck $ziel
|
curl.exe -s -u $auth -o $zurueck $fern
|
||||||
$hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash
|
$hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash
|
||||||
$dortHash = (Get-FileHash $zurueck -Algorithm SHA256).Hash
|
$dortHash = if (Test-Path $zurueck) { (Get-FileHash $zurueck -Algorithm SHA256).Hash } else { '' }
|
||||||
Remove-Item $zurueck -Force -ErrorAction SilentlyContinue
|
Remove-Item $zurueck -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
if ($hierHash -ne $dortHash) {
|
if ($hierHash -ne $dortHash) {
|
||||||
Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.'
|
Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.'
|
||||||
|
Melde $false $zahlen.media $groesse $false 'Pruefsumme der Kopie weicht ab'
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)"
|
Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)"
|
||||||
|
$inDerWolke = $true
|
||||||
|
|
||||||
# Auch draussen ausduennen. Sonst waechst die Nextcloud still voll, und das
|
# Auch draussen ausduennen. Sonst waechst die Nextcloud still voll, und das
|
||||||
# faellt erst auf, wenn sie voll ist.
|
# 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'])"
|
||||||
$fern = @([regex]::Matches($liste, 'd4rk_media-[\d-]+\.tar\.gz') |
|
$fernListe = @([regex]::Matches($liste, 'd4rk_media-[\d-]+\.tar\.gz') |
|
||||||
ForEach-Object { $_.Value } | Sort-Object -Unique -Descending)
|
ForEach-Object { $_.Value } | Sort-Object -Unique -Descending)
|
||||||
if ($fern.Count -gt $Behalten) {
|
if ($fernListe.Count -gt $Behalten) {
|
||||||
foreach ($alt in $fern | Select-Object -Skip $Behalten) {
|
foreach ($alt in $fernListe | Select-Object -Skip $Behalten) {
|
||||||
$c = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X DELETE "$wurzel/$($z['ordner'])/$alt"
|
$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" }
|
if ($c -in @('204', '200')) { Gut "in der Nextcloud entfernt: $alt" }
|
||||||
else { Write-Host " konnte $alt nicht entfernen (HTTP $c)" -ForegroundColor Yellow }
|
else { Write-Host " konnte $alt nicht entfernen (HTTP $c)" -ForegroundColor Yellow }
|
||||||
@@ -245,6 +308,8 @@ if ($alle.Count -gt $Behalten) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Melde $true $zahlen.media $groesse $inDerWolke $null
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Gut "Fertig: $archiv"
|
Gut "Fertig: $archiv"
|
||||||
Write-Host " Zurueckspielen: tar xzf <archiv>, dann media.db und files/ ins Volume legen," -ForegroundColor DarkGray
|
Write-Host " Zurueckspielen: tar xzf <archiv>, dann media.db und files/ ins Volume legen," -ForegroundColor DarkGray
|
||||||
|
|||||||
Reference in New Issue
Block a user