Der letzte offene Punkt aus docs/ideen.md. Ein ZWEITES LAUFWERK, einstellbar im
Panel unter Einstellungen -> Sicherung; sichern.ps1 legt das Archiv dort ab,
vergleicht die Pruefsumme und duennt auch dort aus.
WARUM DAS NICHT DASSELBE IST WIE DIE NEXTCLOUD. Die hilft, wenn der ganze
Rechner weg ist -- aber wer 335 MB, spaeter 100 GB, zurueckholen muss, laedt sie
ueber die Leitung. Das zweite Laufwerk ist in Minuten zurueckgespielt. Zwei
Fragen, zwei Antworten, deshalb zwei Ziele.
UND ES WARNT, WENN ES DIESELBE PLATTE IST -- das ist der eigentliche Inhalt.
Ein zweiter Ordner auf C: sieht im Panel genauso gruen aus wie eine echte zweite
Platte und hilft gegen gar nichts. Verglichen wird die PHYSISCHE Platte und
nicht der Laufwerksbuchstabe: zwei Partitionen derselben NVMe sterben zusammen.
Nachgemessen in beide Richtungen:
C:\backup gegen D:\backup -> Nr. 0 gegen Nr. 1 -> "andere Platte"
C:\backup gegen C:\Users -> Nr. 0 gegen Nr. 0 -> Warnung
Auf diesem Rechner sind das zwei getrennte NVMe zu je 954 GB.
GEMESSEN AN EINEM ECHTEN LAUF, keinem Trockentest: 4630 Eintraege, 335,9 MB,
auf beiden lokalen Zielen und in der Nextcloud dieselbe Pruefsumme
FFCE4D0A7310... -- identisch.
Geprueft wird mit der Pruefsumme und nicht mit der Dateigroesse: eine
abgebrochene Kopie auf eine volle Platte hat oft genau die richtige Laenge und
trotzdem Nullen am Ende. Schlaegt das Kopieren oder der Vergleich fehl, ist der
ganze Lauf gescheitert (exit 1, ok:false) -- ein Ziel, das still ausfaellt, ist
genau das, wogegen das hier gebaut ist.
Der Bericht traegt jetzt zweit:true. Fehlt das Feld, ist KEIN zweites Ziel
eingerichtet -- es steht absichtlich nicht als false da, denn das laese sich wie
"hat nicht geklappt".
Die Statusseite zaehlt die Orte mit: "sicherung ok an 3 Orten" statt nur "ok".
Eine Sicherung, die es nur einmal gibt, ist gruen und trotzdem eine, die ein
Plattenausfall mitnimmt -- das gehoert dorthin, wo jemand hinsieht.
Der Pfad wird beim Speichern auf seine FORM geprueft (Laufwerksbuchstabe oder
UNC-Freigabe): der Dienst laeuft im Container und kann ihn nicht nachschlagen,
aber ein relativer Pfad ist mit Sicherheit ein Tippfehler -- und ein Tippfehler
in einem Sicherungsziel faellt sonst erst auf, wenn man die Sicherung braucht.
Nachgemessen: "backup/woauchimmer" -> 400 mit Text.
Und zurueckspielen.ps1 sagt im Kopf, was zu tippen ist, wenn genau diese Platte
das Problem ist. Das ist der Fall, fuer den das Ganze da ist, und niemand soll
ihn um vier Uhr nachts erst herleiten muessen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.2 KiB
TypeScript
209 lines
8.2 KiB
TypeScript
// Einstellungen, die zur Laufzeit verstellbar sind.
|
|
//
|
|
// WAS HIER LIEGT UND WAS NICHT. In der Umgebung bleibt, was den Dienst
|
|
// ueberhaupt erreichbar macht: PUBLIC_URL, PORT, DATA_DIR, ADMIN_PASSWORD. Wer
|
|
// die per Formular verstellen koennte, koennte sich mit einem Klick
|
|
// aussperren, und die Reparatur waere ein Eingriff in die Datenbank.
|
|
//
|
|
// Hier liegt der Rest — derzeit die Discord-Anmeldung. Der Gewinn ist nicht
|
|
// nur Bequemlichkeit: das Panel kann die Redirect-URI gleich zum Kopieren
|
|
// hinlegen, und die ist die Stelle, an der ein OAuth-Anlauf am haeufigsten
|
|
// scheitert.
|
|
//
|
|
// DAS GEHEIMNIS LIEGT DAMIT IN media.db, und die steckt in jeder Sicherung
|
|
// unter C:\backup. Die .env ist dort nicht drin. Beides liegt unverschluesselt
|
|
// auf derselben Platte, der Unterschied ist die Zahl der Kopien — wer den
|
|
// Client-Schluessel zurueckziehen will, tut das im Discord-Portal, nicht hier.
|
|
|
|
import { db, now } from './db.js'
|
|
|
|
const lesen = db.prepare('SELECT value FROM settings WHERE key = ?')
|
|
const schreiben = db.prepare(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
)
|
|
|
|
/** Ein Wert aus der Datenbank — oder, wenn dort nichts steht, aus der
|
|
* Umgebung. Die Reihenfolge ist Absicht: wer schon Stack-Variablen gesetzt
|
|
* hat, behaelt sie, und wer das Panel benutzt, uebersteuert sie. */
|
|
export function getSetting(key: string, envName?: string): string {
|
|
const zeile = lesen.get(key) as { value: string } | undefined
|
|
if (zeile && zeile.value !== '') return zeile.value
|
|
return (envName ? (process.env[envName] ?? '') : '').trim()
|
|
}
|
|
|
|
export function setSetting(key: string, value: string): void {
|
|
schreiben.run(key, value, now())
|
|
}
|
|
|
|
/** Ob ein Wert aus der Datenbank kommt (true) oder aus der Umgebung (false).
|
|
* Die Oberflaeche sagt das dazu — sonst wundert sich jemand, warum ein
|
|
* geleertes Feld weiterhin wirkt. */
|
|
export function ausDatenbank(key: string): boolean {
|
|
const zeile = lesen.get(key) as { value: string } | undefined
|
|
return Boolean(zeile && zeile.value !== '')
|
|
}
|
|
|
|
// ------------------------------------------------------------ Adressvorlagen
|
|
//
|
|
// Eine Vorlage ist der Bauplan einer oeffentlichen Adresse:
|
|
//
|
|
// vehicles/{model}.webp -> https://…/f/vehicles/adder.webp
|
|
//
|
|
// Wozu speichern? Weil genau diese Zeichenkette an der anderen Seite gebraucht
|
|
// wird -- im Handy, im Skript, im Fotostudio -- und weil sie sich jemand sonst
|
|
// aus Ordnername plus Endung zusammenreimt. Einmal falsch zusammengereimt
|
|
// heisst: das Bild fehlt, und gesucht wird an der falschen Stelle.
|
|
//
|
|
// Als JSON in EINER Zeile der Einstellungstabelle. Eine eigene Tabelle fuer
|
|
// eine Handvoll Zeilen waere eine Wanderung mehr, die niemand braucht.
|
|
|
|
/** Wie der Dienst sich nennt.
|
|
*
|
|
* DIESELBE Einstellung, die auch in der Fusszeile der Discord-Meldungen steht
|
|
* (`D4RKST3R // MEDIA`). Ein zweiter Name fuer die Oberflaeche waere ein
|
|
* zweiter, der beim naechsten Mal anders lautet als der erste -- und dann
|
|
* heisst dasselbe Ding an zwei Stellen verschieden.
|
|
*
|
|
* Der Standard steht in meldung.ts; hier wird er nur weitergereicht. */
|
|
export const MARKE_KEY = 'marke.name'
|
|
|
|
export const VORLAGEN_KEY = 'vorlagen'
|
|
|
|
export type Vorlage = { name: string; muster: string }
|
|
|
|
export function vorlagen(): Vorlage[] {
|
|
try {
|
|
const roh = getSetting(VORLAGEN_KEY)
|
|
if (!roh) return []
|
|
const daten = JSON.parse(roh)
|
|
if (!Array.isArray(daten)) return []
|
|
return daten.filter(
|
|
(v): v is Vorlage =>
|
|
v && typeof v.name === 'string' && typeof v.muster === 'string',
|
|
)
|
|
} catch {
|
|
// Ein kaputter Eintrag darf die Einstellungsseite nicht mitreissen.
|
|
return []
|
|
}
|
|
}
|
|
|
|
export function setzeVorlagen(liste: Vorlage[]): void {
|
|
setSetting(VORLAGEN_KEY, JSON.stringify(liste))
|
|
}
|
|
|
|
// ----------------------------------------------------------------- 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',
|
|
/** Ein ZWEITER lokaler Ablageort, moeglichst auf einer anderen Platte.
|
|
*
|
|
* WOZU, wenn es doch schon die Nextcloud gibt: die ist weit weg. Wer
|
|
* 100 GB zurueckholen muss, laedt sie ueber die Leitung -- das ist die
|
|
* Sicherung fuer den Fall, dass das Haus abbrennt, nicht fuer den Fall,
|
|
* dass eine Platte stirbt. Fuer den zweiten will man die Kopie IM Rechner
|
|
* haben, nur eben nicht auf derselben Platte.
|
|
*
|
|
* Der Pfad gilt auf dem HOST, nicht im Container -- ihn prueft deshalb
|
|
* nicht der Dienst, sondern tools/sichern.ps1. */
|
|
zweitziel: 'sicherung.zweitziel',
|
|
/** 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
|
|
zweitziel: string
|
|
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,
|
|
zweitziel: getSetting(SICHERUNG_KEYS.zweitziel),
|
|
hatPasswort: Boolean(passwort),
|
|
bereit: Boolean(basis && benutzer && passwort),
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------- Discord
|
|
|
|
export const DISCORD_KEYS = {
|
|
clientId: 'discord.client_id',
|
|
clientSecret: 'discord.client_secret',
|
|
guildId: 'discord.guild_id',
|
|
/** MEHRERE Rollen, mit Komma getrennt. Der alte Schluessel mit genau einer
|
|
* Rolle wird weiter gelesen (siehe rollen()) — wer schon eine eingetragen
|
|
* hatte, soll sie nicht verlieren, nur weil hier eine Liste daraus wurde. */
|
|
roleIds: 'discord.role_ids',
|
|
roleIdAlt: 'discord.role_id',
|
|
} as const
|
|
|
|
export type DiscordConfig = {
|
|
clientId: string
|
|
clientSecret: string
|
|
guildId: string
|
|
/** Wer EINE davon hat, darf herein. Leere Liste heisst: nicht eingerichtet. */
|
|
roleIds: string[]
|
|
/** Erst wenn alle vier stehen, hat die Anmeldung ueberhaupt eine Chance.
|
|
* Ein halb eingerichteter Weg, der auf halber Strecke abbricht, ist
|
|
* schlimmer als ein Knopf, den es nicht gibt. */
|
|
bereit: boolean
|
|
}
|
|
|
|
/** Die Rollen, aus welcher Quelle sie auch kommen.
|
|
*
|
|
* Reihenfolge: die neue Liste, dann der alte Einzelwert, dann die Umgebung.
|
|
* Getrennt wird an Komma, Semikolon, Leerzeichen und Zeilenumbruch — wer eine
|
|
* Liste aus Discord kopiert, soll sie nicht erst von Hand aufraeumen. */
|
|
export function rollen(): string[] {
|
|
const roh =
|
|
getSetting(DISCORD_KEYS.roleIds) ||
|
|
getSetting(DISCORD_KEYS.roleIdAlt) ||
|
|
(process.env.DISCORD_ROLE_IDS ?? process.env.DISCORD_ROLE_ID ?? '')
|
|
|
|
return [...new Set(roh.split(/[\s,;]+/).map((t) => t.trim()).filter(Boolean))]
|
|
}
|
|
|
|
export function discordConfig(): DiscordConfig {
|
|
const clientId = getSetting(DISCORD_KEYS.clientId, 'DISCORD_CLIENT_ID')
|
|
const clientSecret = getSetting(DISCORD_KEYS.clientSecret, 'DISCORD_CLIENT_SECRET')
|
|
const guildId = getSetting(DISCORD_KEYS.guildId, 'DISCORD_GUILD_ID')
|
|
const roleIds = rollen()
|
|
|
|
return {
|
|
clientId,
|
|
clientSecret,
|
|
guildId,
|
|
roleIds,
|
|
bereit: Boolean(clientId && clientSecret && guildId && roleIds.length > 0),
|
|
}
|
|
}
|