Files
d4rk_media/server/src/settings.ts
T
D4rkst3randClaude Opus 5 72fd330ae6 feat: der Dienst nennt sich selbst -- und Cloudflare bricht die WebP-Auslieferung
DER NAME KAM VIERMAL FEST AUS DEM QUELLTEXT: Kopfzeile, Anmeldeseite, Fusszeile
einer Freigabe, Tab-Titel -- und zwar als "d4rk_media", was der Name des
Repositorys ist und nicht der des Dienstes. Wer ihn aendern wollte, musste vier
Stellen finden und eine davon uebersehen.

Er kommt jetzt vom Server, aus der Einstellung marke.name -- DERSELBEN, die
schon 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.

Ausgeliefert wird er ueber /api/dash/auth/config (oeffentlich, die
Anmeldeseite hat noch keine Sitzung) und in der Freigabe-Antwort (die Seite ist
oeffentlich und holt sonst nichts, was ihn kennen koennte). Angezeigt wird
"<marke> Media" -- die Marke allein waere falsch, sie steht ueber allem, was
hier laeuft.

EINE STELLE BLEIBT ABSICHTLICH: der Schluessel im Speicher des Browsers
(merker.ts) heisst weiter "d4rk_media.". Wer ihn umbenennt, wirft still alle
gemerkten Ansichtseinstellungen weg, und niemand versteht, warum die Galerie
ploetzlich wieder bei 60 Kacheln steht. Steht als Kommentar daneben.

---

UND DER NEUE NAME HAENGT HINTER CLOUDFLARE, was die WebP-Auslieferung bricht.

NPM ist sauber, sogar besser als vorgeschlagen: beide Namen auf DEMSELBEN Host,
die drei ^~-Bloecke gelten damit fuer beide. Der DNS-Eintrag geht aber ueber
Cloudflare (188.114.97.3, Server: cloudflare), waehrend fivemanage direkt auf
88.218.224.10 zeigt.

Gemessen, dieselbe Datei:

    fivemanage (direkt)      ohne Accept  ->  image/png   12295 B   richtig
                              mit Accept  ->  image/webp   2270 B   richtig

    media (Cloudflare)     1. mit Accept  ->  image/webp   2270 B   MISS
                           2. mit Accept  ->  image/webp   2270 B   HIT
                           3. OHNE Accept ->  image/webp   2270 B   HIT   FALSCH

Und andersherum genauso -- wer zuerst kommt, praegt den Zwischenspeicher fuer
alle. Es sind unsere eigenen Bytes (2270 = unsere WebP-Fassung), also kein
Polish, sondern ein Cache-Schluessel ohne Accept. Vary: Accept wird an den
Client durchgereicht, aber nicht zum Unterscheiden benutzt.

Fuer ein Lua-Skript heisst das: items/foo.png liefert WebP-Bytes unter einem
.png-Namen, und das faellt erst auf, wenn irgendwo ein Bild nicht laedt.

PUBLIC_URL ist deshalb NICHT umgestellt. Genau davor stand die Warnung im
letzten Commit, und sie war keine Vorsicht, sondern eine Vorhersage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:43:29 +02:00

196 lines
7.5 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',
/** 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
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),
}
}