diff --git a/server/src/db.ts b/server/src/db.ts index b0b1b19..dcced8d 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -120,6 +120,18 @@ CREATE TABLE IF NOT EXISTS settings ( // und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe // avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des // Schemas eine Baustelle in jeder Zeile. + // Grenzen je Token. Sie beantworten die Frage, die "Token-Arten" nur + // scheinbar beantwortet: was passiert, wenn DIESER Token abhandenkommt. + const tSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[] + if (!tSpalten.some((s) => s.name === 'max_bytes')) { + db.exec('ALTER TABLE tokens ADD COLUMN max_bytes INTEGER') + // Komma-getrennte Artnamen ('bild,video'). Leer heisst: alles, was der + // Dienst kennt. + db.exec("ALTER TABLE tokens ADD COLUMN arten TEXT NOT NULL DEFAULT ''") + db.exec('ALTER TABLE tokens ADD COLUMN expires_at INTEGER') + console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt') + } + const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[] if (!eSpalten.some((s) => s.name === 'von')) { // Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese @@ -206,6 +218,13 @@ export type Token = { can_delete: number created_at: number last_used_at: number | null + /** Groesste erlaubte Datei fuer DIESEN Token. Null heisst: die Grenze des + * Dienstes gilt (MAX_UPLOAD_MB). */ + max_bytes: number | null + /** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */ + arten: string + /** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */ + expires_at: number | null } export type Media = { diff --git a/server/src/index.ts b/server/src/index.ts index 4f9a275..010921f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -29,6 +29,7 @@ import { logger } from 'hono/logger' import { config } from './config.js' import { ensureAdmin } from './auth.js' import { pruneSessions } from './db.js' +import { pruefeSicherung } from './meldung.js' import { absolutePath, checkPath, mimeFor, thumbPath } from './storage.js' import { uploadRoutes } from './routes/upload.js' import { dashRoutes } from './routes/dash.js' @@ -296,6 +297,14 @@ ensureAdmin() pruneSessions() setInterval(pruneSessions, 3600_000).unref() +// Der Wachhund fuer die Sicherung. Beim Start mit Verzoegerung (der Dienst +// soll erst stehen) und danach stuendlich. +// +// DAS IST DER PUNKT DER GANZEN MELDUNGSFUNKTION: eine Sicherung, die aufhoert +// zu laufen, faellt sonst erst auf, wenn man sie braucht. +setTimeout(() => void pruefeSicherung(), 60_000).unref() +setInterval(() => void pruefeSicherung(), 3600_000).unref() + serve({ fetch: app.fetch, port: config.port }, (info) => { console.log(`[d4rk_media] Port ${info.port}`) console.log( diff --git a/server/src/meldung.ts b/server/src/meldung.ts new file mode 100644 index 0000000..f81d1e0 --- /dev/null +++ b/server/src/meldung.ts @@ -0,0 +1,128 @@ +// Meldungen nach Discord. +// +// WOZU. Die Sicherung laeuft nachts um halb fuenf, und wenn sie aufhoert zu +// laufen, merkt es niemand -- das Panel zeigt es nur dem, der hinschaut. Genau +// dafuer ist das hier: nicht fuer Jubelmeldungen, sondern fuer die zwei, drei +// Faelle, in denen jemand etwas tun muss. +// +// Ein WEBHOOK und keine Bot-Anbindung: eine Adresse, kein Token, keine +// Berechtigungen, kein zweites Geheimnis, das ablaeuft. Wer ihn zurueckziehen +// will, loescht ihn in Discord. +// +// Der Dienst schickt HOECHSTENS EINMAL je Anlass. Eine Meldung, die stuendlich +// wiederkommt, wird nach dem dritten Mal weggeklickt -- und dann auch die, die +// zaehlt. + +import { getSetting, setSetting } from './settings.js' +import { now } from './db.js' + +export const MELDUNG_KEYS = { + webhook: 'discord.webhook', + /** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste'. */ + anlaesse: 'discord.anlaesse', + /** Womit zuletzt gemeldet wurde -- gegen Wiederholungen. */ + zuletzt: 'discord.zuletzt', +} as const + +export type MeldungConfig = { + hatWebhook: boolean + anlaesse: string[] +} + +export function meldungConfig(): MeldungConfig { + const roh = getSetting(MELDUNG_KEYS.anlaesse) + return { + hatWebhook: Boolean(getSetting(MELDUNG_KEYS.webhook)), + // Ohne Eintrag gilt: alles, was es gibt. Wer einen Webhook eintraegt, + // will Meldungen -- sonst haette er es gelassen. + anlaesse: roh ? roh.split(',').map((a) => a.trim()).filter(Boolean) : ['sicherung', 'verwaiste'], + } +} + +/** Eine Nachricht schicken. Wirft nie. + * + * `einmalig` ist der Schluessel gegen Wiederholungen: dieselbe Zeichenkette + * wird nur einmal gemeldet, bis sich der Zustand aendert. */ +export async function melde( + text: string, + opt: { anlass?: string; einmalig?: string; farbe?: number } = {}, +): Promise { + try { + const webhook = getSetting(MELDUNG_KEYS.webhook) + if (!webhook) return false + + if (opt.anlass && !meldungConfig().anlaesse.includes(opt.anlass)) return false + + if (opt.einmalig) { + const zuletzt = getSetting(MELDUNG_KEYS.zuletzt) + if (zuletzt === opt.einmalig) return false + setSetting(MELDUNG_KEYS.zuletzt, opt.einmalig) + } + + const antwort = await fetch(webhook, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username: 'd4rk_media', + embeds: [ + { + description: text, + color: opt.farbe ?? 0x4ea3ff, + timestamp: new Date(now()).toISOString(), + }, + ], + }), + }) + // Discord antwortet auf einen Webhook mit 204. + if (!antwort.ok) { + console.error('[discord] Webhook antwortete mit', antwort.status) + return false + } + return true + } catch (err) { + // Eine Meldung, die den Dienst mitreisst, waere die Buchhaltung, die + // das Geschaeft verhindert. + console.error('[discord] Meldung fehlgeschlagen:', err) + return false + } +} + +/** Wacht ueber die Sicherung. + * + * Der eigentliche Punkt der ganzen Datei. Geprueft wird stuendlich, gemeldet + * hoechstens einmal je Zustand: + * + * - der letzte Lauf ist gescheitert + * - der letzte Lauf ist laenger als 26 Stunden her (taeglich um 04:30 plus + * Luft; 24 waeren zu knapp, 48 zu spaet) + * - es gab noch nie einen + */ +export async function pruefeSicherung(): Promise { + const roh = getSetting('sicherung.letzte') + if (!roh) { + await melde( + '⚠️ Es wurde noch **nie** eine Sicherung gemeldet. Läuft die geplante Aufgabe?', + { anlass: 'sicherung', einmalig: 'nie', farbe: 0xd29922 }, + ) + return + } + + try { + const letzte = JSON.parse(roh) as { at: number; ok: boolean; fehler?: string } + const stunden = (now() - letzte.at) / 3_600_000 + + if (!letzte.ok) { + await melde( + `🔴 Die letzte Sicherung ist **fehlgeschlagen**.\n${letzte.fehler ?? 'kein Grund gemeldet'}`, + { anlass: 'sicherung', einmalig: `fehler:${letzte.at}`, farbe: 0xf85149 }, + ) + } else if (stunden > 26) { + await melde( + `⚠️ Die letzte Sicherung ist **${Math.floor(stunden)} Stunden** her. Läuft die geplante Aufgabe noch?`, + { anlass: 'sicherung', einmalig: `alt:${letzte.at}`, farbe: 0xd29922 }, + ) + } + } catch { + /* kaputter Eintrag -- beim naechsten Lauf wieder */ + } +} diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index e1493ad..69e6c9f 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -46,6 +46,7 @@ import { type Vorlage, } from '../settings.js' import { discordRoutes, redirectUri } from './discord.js' +import { MELDUNG_KEYS, melde, meldungConfig } from '../meldung.js' import { absolutePath, checkPath, @@ -805,6 +806,7 @@ dashRoutes.get('/settings', (c) => { }, }, vorlagen: vorlagen(), + meldungen: meldungConfig(), sicherung: (() => { const sc = sicherungConfig() let letzte: unknown = null @@ -830,6 +832,7 @@ dashRoutes.post('/settings', async (c) => { roleIds?: string | string[] } vorlagen?: { name?: string; muster?: string }[] + meldungen?: { webhook?: string; anlaesse?: string[] } sicherung?: { basis?: string benutzer?: string @@ -840,6 +843,37 @@ dashRoutes.post('/settings', async (c) => { }>(c) if (!body) return c.json(KEIN_JSON, 400) + // -- Meldungen ---------------------------------------------------------- + if (body.meldungen) { + const m = body.meldungen + if (m.webhook !== undefined) { + const v = m.webhook.trim() + if (v === '-') setSetting(MELDUNG_KEYS.webhook, '') + else if (v) { + // Nur echte Discord-Webhooks. Wer hier versehentlich eine + // andere Adresse eintraegt, schickt seine Meldungen sonst + // irgendwohin. + if (!/^https:\/\/(canary\.|ptb\.)?discord(app)?\.com\/api\/webhooks\//.test(v)) { + return c.json( + { error: 'Das sieht nicht nach einer Discord-Webhook-Adresse aus.' }, + 400, + ) + } + setSetting(MELDUNG_KEYS.webhook, v) + } + } + if (Array.isArray(m.anlaesse)) { + const erlaubt = ['sicherung', 'verwaiste'] + setSetting( + MELDUNG_KEYS.anlaesse, + m.anlaesse.filter((a) => erlaubt.includes(a)).join(','), + ) + } + if (!body.discord && !body.sicherung && !body.vorlagen) { + return c.json({ ok: true, meldungen: meldungConfig() }) + } + } + // -- Adressvorlagen ----------------------------------------------------- if (body.vorlagen) { if (!Array.isArray(body.vorlagen)) { @@ -1293,6 +1327,54 @@ dashRoutes.get('/export', (c) => { }) }) +/** Speicher nach Medienart, groesste Dateien, und was fuer eine spaetere + * Aufbewahrungsregel wichtig waere. + * + * ERST SEHEN, DANN ENTSCHEIDEN. Automatisches Loeschen zu bauen, bevor man + * weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route + * loescht nichts -- sie zeigt. + */ +dashRoutes.get('/speicher', (c) => { + const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => { + const z = db + .prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM media WHERE ${bedingung}`) + .get() as { n: number; bytes: number } + return { art: name, files: z.n, bytes: z.bytes } + }) + + const groesste = ( + db.prepare('SELECT * FROM media ORDER BY size DESC LIMIT 10').all() as Media[] + ).map((r) => ({ ...r, url: publicUrlFor(r.path) })) + + const aelteste = ( + db.prepare('SELECT * FROM media ORDER BY created_at ASC LIMIT 10').all() as Media[] + ).map((r) => ({ ...r, url: publicUrlFor(r.path) })) + + // Derselbe Inhalt unter zwei Pfaden. Kein Fehler -- aber jedes Doppel ist + // Platz, den niemand gewaehlt hat. + const doppelte = db + .prepare( + `SELECT sha256, COUNT(*) AS n, SUM(size) AS bytes, + GROUP_CONCAT(path, '|') AS pfade + FROM media GROUP BY sha256 HAVING n > 1 ORDER BY bytes DESC LIMIT 20`, + ) + .all() as { sha256: string; n: number; bytes: number; pfade: string }[] + + const verschwendet = doppelte.reduce((s, d) => s + (d.bytes / d.n) * (d.n - 1), 0) + + // Wie alt der Bestand ist, in Stufen. Das ist die Zahl, die eine + // Aufbewahrungsregel braucht: "aelter als 90 Tage" ist eine Behauptung, + // solange niemand weiss, wie viel das waere. + const stufen = [7, 30, 90, 365].map((tage) => { + const z = db + .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE created_at < ?') + .get(now() - tage * 86_400_000) as { n: number; bytes: number } + return { tage, files: z.n, bytes: z.bytes } + }) + + return c.json({ nachArt, groesste, aelteste, doppelte, verschwendet, stufen }) +}) + dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') @@ -1319,9 +1401,21 @@ dashRoutes.get('/stats', (c) => { dashRoutes.get('/tokens', (c) => { const rows = db - .prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC') + .prepare( + `SELECT id, name, prefix, can_delete, created_at, last_used_at, + max_bytes, arten, expires_at + FROM tokens ORDER BY created_at DESC`, + ) .all() as Omit[] - return c.json({ items: rows }) + return c.json({ + items: rows.map((r) => ({ + ...r, + // Abgelaufen ist eine Eigenschaft, keine Rechnung fuer die + // Oberflaeche: sonst steht in zwei Ansichten zweimal dieselbe + // Bedingung, und eine davon ist irgendwann falsch. + abgelaufen: Boolean(r.expires_at && r.expires_at < now()), + })), + }) }) dashRoutes.post('/tokens', async (c) => { @@ -1329,12 +1423,55 @@ dashRoutes.post('/tokens', async (c) => { name?: string prefix?: string canDelete?: boolean + maxMb?: number | string + arten?: string[] + gueltigTage?: number | string }>(c) if (!body) return c.json(KEIN_JSON, 400) const { name, prefix, canDelete } = body if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400) + // -- Die drei Grenzen -------------------------------------------------- + let maxBytes: number | null = null + if (body.maxMb !== undefined && String(body.maxMb).trim() !== '') { + const mb = Number(body.maxMb) + if (!Number.isFinite(mb) || mb <= 0) { + return c.json({ error: 'Groessengrenze: eine Zahl groesser als 0' }, 400) + } + // Groesser als die Grenze des Dienstes zu erlauben waere eine + // Behauptung: durchgelassen wird ohnehin die schaerfere. + if (mb * 1024 * 1024 > config.maxUploadBytes) { + return c.json( + { + error: `Der Dienst nimmt hoechstens ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB — mehr kann auch dieser Token nicht.`, + }, + 400, + ) + } + maxBytes = Math.round(mb * 1024 * 1024) + } + + const ERLAUBTE_ARTEN = ['bild', 'video', 'ton', 'dokument', 'andere'] + let arten = '' + if (Array.isArray(body.arten) && body.arten.length > 0) { + const falsch = body.arten.filter((a) => !ERLAUBTE_ARTEN.includes(a)) + if (falsch.length) return c.json({ error: `Unbekannte Art: ${falsch.join(', ')}` }, 400) + // Alle fuenf gewaehlt heisst dasselbe wie keine Einschraenkung -- dann + // steht in der Datenbank auch nichts, sonst muesste jede spaetere + // Erweiterung der Artenliste alte Token nachziehen. + arten = body.arten.length === ERLAUBTE_ARTEN.length ? '' : body.arten.join(',') + } + + let expiresAt: number | null = null + if (body.gueltigTage !== undefined && String(body.gueltigTage).trim() !== '') { + const tage = Number(body.gueltigTage) + if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) { + return c.json({ error: 'Gueltigkeit: 1 bis 3650 Tage' }, 400) + } + expiresAt = now() + Math.round(tage) * 86_400_000 + } + // Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft. let cleanPrefix = '' if (prefix?.trim()) { @@ -1349,10 +1486,20 @@ dashRoutes.post('/tokens', async (c) => { const token = generateToken() const info = db .prepare( - `INSERT INTO tokens (name, hash, prefix, can_delete, created_at) - VALUES (?, ?, ?, ?, ?)`, + `INSERT INTO tokens (name, hash, prefix, can_delete, created_at, + max_bytes, arten, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + name.trim(), + tokenHash(token), + cleanPrefix, + canDelete ? 1 : 0, + now(), + maxBytes, + arten, + expiresAt, ) - .run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now()) // Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in // der Datenbank, und auch wir koennen ihn nicht mehr zeigen. @@ -1361,6 +1508,9 @@ dashRoutes.post('/tokens', async (c) => { name: name.trim(), prefix: cleanPrefix, canDelete: Boolean(canDelete), + maxBytes, + arten, + expiresAt, token, }) }) @@ -1371,6 +1521,24 @@ dashRoutes.delete('/tokens/:id', (c) => { return c.json({ ok: true }) }) +/** Eine Testnachricht schicken. + * + * Ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall + * ausprobiert, ist ein Webhook, der dann nicht funktioniert. */ +dashRoutes.post('/maintenance/melden', async (c) => { + const ok = await melde( + `✅ Testnachricht von d4rk_media — ausgelöst von **${c.get('user').username}**.`, + { farbe: 0x3fb950 }, + ) + if (!ok) { + return c.json( + { error: 'Nichts geschickt. Ist ein Webhook eingetragen, und stimmt die Adresse?' }, + 400, + ) + } + return c.json({ ok: true }) +}) + dashRoutes.post('/maintenance/prune-sessions', (c) => { pruneSessions() pruneEvents() diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index de7c3fd..624793d 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -52,10 +52,34 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => { if (!token) { return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401) } + // Abgelaufen ist etwas anderes als unbekannt, und die Meldung sagt es: + // sonst sucht jemand eine halbe Stunde nach einem Tippfehler im Token. + if (token.expires_at && token.expires_at < now()) { + return c.json( + { + error: `Dieser Token ist am ${new Date(token.expires_at).toLocaleDateString('de-DE')} abgelaufen.`, + }, + 401, + ) + } c.set('token', token) await next() } +/** Welche Medienart ist das? Dieselben Namen wie im Dashboard. + * + * An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der + * Galerie nicht auseinanderlaufen koennen. */ +function artVon(mime: string): string { + const m = mime.split(';')[0]?.trim().toLowerCase() ?? '' + if (m.startsWith('image/')) return 'bild' + if (m.startsWith('video/')) return 'video' + if (m.startsWith('audio/')) return 'ton' + if (m === 'application/pdf' || m.startsWith('text/') || m.startsWith('application/json')) + return 'dokument' + return 'andere' +} + uploadRoutes.use('/upload', requireToken) uploadRoutes.use('/media/*', requireToken) uploadRoutes.use('/exists/*', requireToken) @@ -96,12 +120,13 @@ uploadRoutes.post('/upload', async (c) => { // Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload // abgewiesen wird, BEVOR er im Speicher liegt. + // Die schaerfere der beiden Grenzen gilt: die des Dienstes und die dieses + // Tokens. Ein Token fuer Fahrzeugbilder braucht keine 64 MB. + const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER) + const announced = Number(c.req.header('content-length') ?? 0) - if (announced > config.maxUploadBytes) { - return c.json( - { error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` }, - 413, - ) + if (announced > grenze) { + return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413) } let body: Awaited> @@ -113,11 +138,8 @@ uploadRoutes.post('/upload', async (c) => { const { data } = body if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400) - if (data.length > config.maxUploadBytes) { - return c.json( - { error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` }, - 413, - ) + if (data.length > grenze) { + return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413) } const digest = sha256(data) @@ -160,6 +182,19 @@ uploadRoutes.post('/upload', async (c) => { const mime = c.req.header('x-mime') ?? mimeFor(path) + if (token.arten) { + const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean) + const art = artVon(mime) + if (!erlaubt.includes(art)) { + return c.json( + { + error: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen — das hier ist "${art}" (${mime}).`, + }, + 403, + ) + } + } + try { await writeFileAtomic(path, data) } catch (err) { diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index ce32df4..13b2647 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -127,6 +127,15 @@ export type Stats = { publicUrl: string } +export type Speicher = { + nachArt: { art: string; files: number; bytes: number }[] + groesste: MediaItem[] + aelteste: MediaItem[] + doppelte: { sha256: string; n: number; bytes: number; pfade: string }[] + verschwendet: number + stufen: { tage: number; files: number; bytes: number }[] +} + export type TokenRow = { id: number name: string @@ -134,6 +143,12 @@ export type TokenRow = { can_delete: number created_at: number last_used_at: number | null + /** Groesste erlaubte Datei. Null = die Grenze des Dienstes. */ + max_bytes: number | null + /** Erlaubte Arten, mit Komma. Leer = alle. */ + arten: string + expires_at: number | null + abgelaufen: boolean } /** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der @@ -207,6 +222,7 @@ export type LetzteSicherung = { export type Einstellungen = { vorlagen: Vorlage[] + meldungen: { hatWebhook: boolean; anlaesse: string[] } sicherung: { basis: string benutzer: string @@ -350,6 +366,12 @@ export const api = { einstellungen: () => get('/api/dash/settings'), sicherungSpeichern: (sicherung: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }), + meldungenSpeichern: (meldungen: { webhook?: string; anlaesse?: string[] }) => + post<{ ok: true; meldungen: { hatWebhook: boolean; anlaesse: string[] } }>( + '/api/dash/settings', + { meldungen }, + ), + meldenTesten: () => post<{ ok: true }>('/api/dash/maintenance/melden'), vorlagenSpeichern: (liste: Vorlage[]) => post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }), einstellungenSpeichern: (discord: Record) => @@ -357,6 +379,7 @@ export const api = { uebersicht: () => get('/api/dash/uebersicht'), stand: () => get('/api/dash/stand'), + speicher: () => get('/api/dash/speicher'), stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), verwaiste: () => get('/api/dash/maintenance/orphans'), @@ -367,7 +390,13 @@ export const api = { ), tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'), - createToken: (name: string, prefix: string, canDelete: boolean) => - post('/api/dash/tokens', { name, prefix, canDelete }), + createToken: (daten: { + name: string + prefix: string + canDelete: boolean + maxMb?: string + arten?: string[] + gueltigTage?: string + }) => post('/api/dash/tokens', daten), deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`), } diff --git a/server/ui/src/components/MeldungenKarte.tsx b/server/ui/src/components/MeldungenKarte.tsx new file mode 100644 index 0000000..fe6c78f --- /dev/null +++ b/server/ui/src/components/MeldungenKarte.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react' +import { Bell, BellOff, Send } from 'lucide-react' +import { api } from '../api' +import { run, useStore } from '../store' +import { Feld, Karte, Knopf } from './Bausteine' + +const ANLAESSE = [ + ['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'], + ['verwaiste', 'Verwaiste Dateien gefunden'], +] as const + +/** Meldungen nach Discord. + * + * Der Grund ist nicht Discord, sondern eine Lücke: die Sicherung läuft nachts + * um halb fünf, und wenn sie aufhört zu laufen, merkt es niemand — das Panel + * zeigt es nur dem, der hinschaut. Ein Webhook schließt genau das. + * + * Deshalb steht der Testknopf gleich daneben. Ein Webhook, den man einträgt + * und erst in drei Wochen im Fehlerfall ausprobiert, ist ein Webhook, der dann + * nicht funktioniert. */ +export function MeldungenKarte() { + const note = useStore((s) => s.note) + const [hatWebhook, setHatWebhook] = useState(false) + const [anlaesse, setAnlaesse] = useState([]) + const [webhook, setWebhook] = useState('') + const [speichert, setSpeichert] = useState(false) + const [testet, setTestet] = useState(false) + + async function laden() { + try { + const e = await api.einstellungen() + setHatWebhook(e.meldungen.hatWebhook) + setAnlaesse(e.meldungen.anlaesse) + } catch (err) { + note('fehler', err instanceof Error ? err.message : 'Unbekannt') + } + } + + useEffect(() => { + void laden() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + eingerichtet + + ) : ( + + aus + + ) + } + > +
+

+ Nicht für Jubelmeldungen, sondern für die zwei Fälle, in denen jemand etwas + tun muss. Der wichtigste: die Sicherung läuft nachts und niemand + merkt, wenn sie aufhört. Der Dienst sieht stündlich nach und meldet + höchstens einmal je Anlass. +

+ + setWebhook(e.target.value)} + /> + +
+ + Wann gemeldet wird + +
+ {ANLAESSE.map(([wert, text]) => ( + + ))} +
+
+ +
+ { + setSpeichert(true) + const r = await run( + () => + api.meldungenSpeichern({ + ...(webhook ? { webhook } : {}), + anlaesse, + }), + (x) => + x.meldungen.hatWebhook + ? 'Gespeichert. Jetzt den Testknopf drücken.' + : 'Gespeichert — ohne Webhook wird nichts gemeldet.', + ) + setSpeichert(false) + if (r) { + setWebhook('') + void laden() + } + }} + kind="Speichern" + /> + { + setTestet(true) + await run( + () => api.meldenTesten(), + 'Testnachricht geschickt — steht sie in Discord?', + ) + setTestet(false) + }} + kind={ + <> + + Testnachricht + + } + /> +
+
+
+ ) +} diff --git a/server/ui/src/components/SpeicherKarten.tsx b/server/ui/src/components/SpeicherKarten.tsx new file mode 100644 index 0000000..0c2a4ca --- /dev/null +++ b/server/ui/src/components/SpeicherKarten.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react' +import { FileQuestion, FileText, Film, Image, Loader2, Music } from 'lucide-react' +import { api, type Speicher } from '../api' +import { useStore } from '../store' +import { bytes, zeit } from '../format' +import { Karte, Leer } from './Bausteine' + +const ARTEN: Record = { + bild: { text: 'Bilder', kind: Image, farbe: 'bg-akzent' }, + video: { text: 'Video', kind: Film, farbe: 'bg-warn' }, + ton: { text: 'Ton', kind: Music, farbe: 'bg-gut' }, + dokument: { text: 'Dokumente', kind: FileText, farbe: 'bg-schrift-leise' }, + andere: { text: 'Anderes', kind: FileQuestion, farbe: 'bg-schlecht' }, +} + +/** Was liegt da eigentlich — nach Art, nach Größe, nach Alter. + * + * ERST SEHEN, DANN ENTSCHEIDEN. Eine Aufbewahrungsregel („alles älter als 90 + * Tage weg") ist eine Behauptung, solange niemand weiß, wie viel das wäre. + * Diese Karten löschen nichts; sie liefern die Zahlen, mit denen man + * entscheiden kann. */ +export function SpeicherKarten() { + const note = useStore((s) => s.note) + const [daten, setDaten] = useState(null) + + useEffect(() => { + api.speicher() + .then(setDaten) + .catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt')) + }, [note]) + + if (!daten) { + return ( + +
+ +
+
+ ) + } + + const gesamt = daten.nachArt.reduce((s, a) => s + a.bytes, 0) || 1 + + return ( + <> + +
+ {/* Ein Balken für alles, damit die Verhältnisse auf einen + Blick stimmen — Zahlen untereinander liest man, Anteile + sieht man. */} +
+ {daten.nachArt + .filter((a) => a.bytes > 0) + .map((a) => ( +
+ ))} +
+ + + + {daten.nachArt.map((a) => { + const g = ARTEN[a.art] + const Kind = g?.kind ?? FileQuestion + return ( + + + + + + + ) + })} + +
+ + + + {g?.text ?? a.art} + + + {a.files} + + {bytes(a.bytes)} + + {gesamt > 1 ? `${Math.round((a.bytes / gesamt) * 100)} %` : '—'} +
+
+ + +
+ + {daten.groesste.length === 0 ? ( + + ) : ( +
    + {daten.groesste.map((m) => ( +
  • + + {m.path} + + + {bytes(m.size)} + +
  • + ))} +
+ )} +
+ + + {daten.doppelte.length === 0 ? ( +

+ Nichts doppelt — jeder Inhalt liegt genau einmal. +

+ ) : ( +
+

+ Derselbe Inhalt unter mehreren Pfaden. Kein Fehler — aber{' '} + {bytes(daten.verschwendet)}{' '} + Platz, den niemand gewählt hat. +

+
    + {daten.doppelte.map((d) => ( +
  • + + {d.n}× · {bytes(d.bytes / d.n)} + + + {d.pfade.replaceAll('|', ' · ')} + +
  • + ))} +
+
+ )} +
+
+ + +

+ Die Zahlen, die eine Aufbewahrungsregel braucht. „Alles älter als 90 Tage + löschen" ist eine Behauptung, solange niemand weiß, wie viel das wäre — + hier steht es. Gelöscht wird hier nichts. +

+ + + {daten.stufen.map((st) => ( + + + + + + ))} + +
älter als {st.tage} Tage + {st.files} Dateien + + {bytes(st.bytes)} +
+ {daten.aelteste[0] && ( +

+ Das älteste ist{' '} + {daten.aelteste[0].path} vom{' '} + {zeit(daten.aelteste[0].created_at)}. +

+ )} +
+ + ) +} diff --git a/server/ui/src/pages/Einstellungen.tsx b/server/ui/src/pages/Einstellungen.tsx index d4406f2..ce89bbc 100644 --- a/server/ui/src/pages/Einstellungen.tsx +++ b/server/ui/src/pages/Einstellungen.tsx @@ -6,6 +6,7 @@ import { bytes, zeit } from '../format' import { Feld, Karte, Knopf } from '../components/Bausteine' import { DiscordKarte } from '../components/DiscordKarte' import { VorlagenKarte } from '../components/VorlagenKarte' +import { MeldungenKarte } from '../components/MeldungenKarte' /** Alles, was sich einstellen lässt, an einer Stelle. * @@ -28,6 +29,7 @@ export function Einstellungen() {
+
) diff --git a/server/ui/src/pages/Speicher.tsx b/server/ui/src/pages/Speicher.tsx index 63440a0..e8a4bdd 100644 --- a/server/ui/src/pages/Speicher.tsx +++ b/server/ui/src/pages/Speicher.tsx @@ -14,6 +14,7 @@ import { run, useStore } from '../store' import { bytes } from '../format' import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { type Verwaiste } from '../api' +import { SpeicherKarten } from '../components/SpeicherKarten' export function Speicher() { const note = useStore((s) => s.note) @@ -59,6 +60,8 @@ export function Speicher() { } was="Token" wert={String(stats.tokens)} />
+ +
diff --git a/server/ui/src/pages/Tokens.tsx b/server/ui/src/pages/Tokens.tsx index 1e5500d..dfb7ba4 100644 --- a/server/ui/src/pages/Tokens.tsx +++ b/server/ui/src/pages/Tokens.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react' import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react' import { api, type NewToken, type TokenRow } from '../api' import { run, useStore } from '../store' -import { zeit } from '../format' +import { bytes, zeit } from '../format' import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' export function Tokens() { @@ -13,6 +13,9 @@ export function Tokens() { const [name, setName] = useState('') const [prefix, setPrefix] = useState('') const [darfLoeschen, setDarfLoeschen] = useState(false) + const [maxMb, setMaxMb] = useState('') + const [gueltigTage, setGueltigTage] = useState('') + const [arten, setArten] = useState([]) const [legtAn, setLegtAn] = useState(false) const [frisch, setFrisch] = useState(null) @@ -41,7 +44,15 @@ export function Tokens() { e.preventDefault() setLegtAn(true) const neu = await run( - () => api.createToken(name.trim(), prefix.trim(), darfLoeschen), + () => + api.createToken({ + name: name.trim(), + prefix: prefix.trim(), + canDelete: darfLoeschen, + maxMb: maxMb.trim(), + arten, + gueltigTage: gueltigTage.trim(), + }), (t) => `Token "${t.name}" angelegt.`, ) setLegtAn(false) @@ -50,6 +61,9 @@ export function Tokens() { setName('') setPrefix('') setDarfLoeschen(false) + setMaxMb('') + setGueltigTage('') + setArten([]) void laden() } } @@ -84,6 +98,69 @@ export function Tokens() { />
+
+ setMaxMb(e.target.value)} + /> + setGueltigTage(e.target.value)} + /> +
+ +
+ + Erlaubte Arten (optional) + +
+ {( + [ + ['bild', 'Bilder'], + ['video', 'Video'], + ['ton', 'Ton'], + ['dokument', 'Dokumente'], + ['andere', 'Anderes'], + ] as const + ).map(([wert, text]) => { + const an = arten.includes(wert) + return ( + + ) + })} +
+ + Nichts gewählt heißt: alles erlaubt. Ein Token, der nur Bilder darf, + kann kein Video hochladen — auch wenn er abhandenkommt. + +
+