diff --git a/docs/ideen.md b/docs/ideen.md index a504cc0..3f68776 100644 --- a/docs/ideen.md +++ b/docs/ideen.md @@ -11,7 +11,11 @@ Sortiert nach dem, was ich zuerst täte. ## 1 · Sicher wertvoll -### Die Oberfläche auf dem Telefon +### ~~Die Oberfläche auf dem Telefon~~ — abgelehnt am 12.08.2026 + +> Vom Betreiber verworfen: das Dashboard wird nicht mobil benutzt. Der Rest des +> Abschnitts bleibt stehen, damit die Messung nicht verlorengeht, falls sich das +> je ändert. **Gemessen:** 18 Umbruchpunkte (`sm:`/`lg:`/`xl:`) im ganzen Frontend. Die Galerie ist ein Raster und passt sich an — die **vier Tabellen** in Verlauf, diff --git a/server/src/auth.ts b/server/src/auth.ts index 58d0b8e..107317d 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -75,6 +75,32 @@ export function userForSession(sessionId: string | undefined): User | null { return row } +/** Alle Sitzungen eines Benutzers beenden -- ausser der laufenden. + * + * WOZU: wer sich an einem fremden Rechner angemeldet hat und es spaeter merkt, + * hatte bisher keine Moeglichkeit, das zurueckzunehmen. Das Passwort zu + * aendern half nicht -- die Sitzungen haengen an einer eigenen Tabelle und + * ueberleben es. + * + * Die EIGENE bleibt stehen: wer auf den Knopf drueckt, will sich nicht selbst + * aussperren. */ +export function destroyOtherSessions(userId: number, behalten: string): number { + const r = db + .prepare('DELETE FROM sessions WHERE user_id = ? AND id <> ?') + .run(userId, behalten) + return r.changes +} + +/** Wie viele Sitzungen gerade offen sind. Abgelaufene zaehlen nicht mit -- + * sie sind nur noch nicht weggeraeumt. */ +export function countSessions(userId: number): number { + return ( + db + .prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND expires_at > ?') + .get(userId, now()) as { n: number } + ).n +} + export function destroySession(sessionId: string) { db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId) } diff --git a/server/src/db.ts b/server/src/db.ts index 4812a86..4343689 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -145,6 +145,38 @@ CREATE TABLE IF NOT EXISTS freigaben ( ); CREATE INDEX IF NOT EXISTS freigaben_ordner ON freigaben(ordner); +-- Was jemand VERWALTET hat -- Einstellungen, Token, Konten, Freigaben. +-- +-- EINE EIGENE TABELLE und nicht events, obwohl beides ein Verlauf ist. Der +-- Grund steht in den Spalten: events traegt path und size und beantwortet +-- "was ist mit den DATEIEN passiert". Wer dort eine Einstellungsaenderung +-- hineinschriebe, muesste path mit etwas fuellen, das kein Pfad ist -- und +-- +-- (Und in diesem Kommentar stehen keine Backticks, obwohl sie sich hier +-- anboeten: das Schema liegt in einem Template-Literal, und ein Backtick +-- beendet es. Der Uebersetzer hat es gefangen, der Browser haette es nicht.) +-- danach filtert jede Ansicht, die Dateien zeigen will, den Fremdkoerper wieder +-- heraus. +-- +-- WAS HIER NIE HINEINGEHOERT: der WERT einer Einstellung. Unter den +-- Schluesseln stehen das Discord-Geheimnis und das Nextcloud-Passwort. Ein +-- Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort eine +-- Tabelle mit allen Passwoertern, die es je gab. Aufgeschrieben wird deshalb +-- WELCHER Schluessel angefasst wurde, nicht womit. +CREATE TABLE IF NOT EXISTS verwaltung ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + wer TEXT NOT NULL, + -- 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner' + bereich TEXT NOT NULL, + -- 'angelegt' | 'geaendert' | 'entfernt' + tat TEXT NOT NULL, + ziel TEXT NOT NULL DEFAULT '', + -- Kurz und ohne Werte: "webhook, anlaesse" oder "quota_bytes, arten". + was TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS verwaltung_zeit ON verwaltung(at DESC); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -349,6 +381,16 @@ export type BenutzerZeile = { discord_avatar: string | null } +export type VerwaltungZeile = { + id: number + at: number + wer: string + bereich: string + tat: string + ziel: string + was: string +} + export type PapierkorbEintrag = { id: number path: string @@ -447,6 +489,32 @@ export function schreibeAbrufe(): void { } } +/** Eine Verwaltungshandlung festhalten. + * + * Wirft NIE: ein Verlauf, der die Handlung verhindert, ueber die er berichten + * soll, waere die falsche Reihenfolge. Faellt er aus, steht die Zeile eben + * nicht da -- die Aenderung selbst ist trotzdem passiert. */ +export function logVerwaltung( + wer: string, + bereich: string, + tat: string, + ziel = '', + was = '', +): void { + try { + db.prepare( + 'INSERT INTO verwaltung (at, wer, bereich, tat, ziel, was) VALUES (?, ?, ?, ?, ?, ?)', + ).run(now(), wer, bereich, tat, ziel, was) + } catch (err) { + console.error('[verwaltung] nicht geschrieben:', err) + } +} + +/** Dieselbe Frist wie beim Datei-Verlauf. */ +export function pruneVerwaltung(tage = 180) { + return db.prepare('DELETE FROM verwaltung WHERE at < ?').run(now() - tage * 86_400_000) +} + export function pruneEvents(tage = 180) { db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000) } diff --git a/server/src/index.ts b/server/src/index.ts index ef6a895..98c0ebe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,7 +28,7 @@ import { Hono } from 'hono' import { logger } from 'hono/logger' import { config } from './config.js' import { ensureAdmin } from './auth.js' -import { pruneEvents, pruneSessions, schreibeAbrufe, zaehleAbruf } from './db.js' +import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js' import { pruefePlatte, pruefeSicherung } from './meldung.js' import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js' import { uploadRoutes } from './routes/upload.js' @@ -400,8 +400,14 @@ setInterval(pruneSessions, 3600_000).unref() // Nicht stuendlich wie die Sitzungen: ein DELETE ueber eine Zeitspanne von // einem halben Jahr hat es nicht eilig, und 24 Stunden Verzoegerung beim // Wegraeumen fallen niemandem auf. -setTimeout(() => pruneEvents(), 120_000).unref() -setInterval(() => pruneEvents(), 86_400_000).unref() +setTimeout(() => { + pruneEvents() + pruneVerwaltung() +}, 120_000).unref() +setInterval(() => { + pruneEvents() + pruneVerwaltung() +}, 86_400_000).unref() // Der Wachhund fuer die Sicherung. Beim Start mit Verzoegerung (der Dienst // soll erst stehen) und danach stuendlich. diff --git a/server/src/meldung.ts b/server/src/meldung.ts index 3194d05..af39ee5 100644 --- a/server/src/meldung.ts +++ b/server/src/meldung.ts @@ -24,6 +24,23 @@ import { config } from './config.js' import { getSetting, setSetting } from './settings.js' import { now } from './db.js' +/** Wofuer es Meldungen gibt -- an EINER Stelle. + * + * Die Liste stand zweimal da: hier als Standard, und in dash.ts noch einmal + * als Weissliste beim Speichern. Beim Einbau des Plattenwaechters habe ich nur + * die erste angefasst -- Ergebnis: das Kaestchen liess sich ankreuzen, der + * Server warf den Wert beim Speichern weg, und beim naechsten Laden war es + * wieder aus. Ohne ein Wort dazu. + * + * Nachgemessen am laufenden Dienst, bevor es hier stand: + * geschickt ["sicherung","verwaiste","platte"] + * gespeichert ["sicherung","verwaiste"] + * + * Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch + * ist. Jetzt ist es eine. + */ +export const ANLAESSE = ['sicherung', 'verwaiste', 'platte'] as const + export const MELDUNG_KEYS = { webhook: 'discord.webhook', /** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste', 'platte'. */ @@ -65,7 +82,7 @@ export function meldungConfig(): MeldungConfig { // will Meldungen -- sonst haette er es gelassen. anlaesse: roh ? roh.split(',').map((a) => a.trim()).filter(Boolean) - : ['sicherung', 'verwaiste', 'platte'], + : [...ANLAESSE], } } diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index e954379..d31fee2 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -14,11 +14,13 @@ import { avatarUrl, db, logEvent, + logVerwaltung, now, pruneEvents, pruneSessions, type Media, type BenutzerZeile, + type VerwaltungZeile, type Freigabe, type MediaEvent, type PapierkorbEintrag, @@ -28,6 +30,8 @@ import { import { SESSION_COOKIE, createSession, + countSessions, + destroyOtherSessions, destroySession, generateToken, hashPassword, @@ -51,7 +55,7 @@ import { type Vorlage, } from '../settings.js' import { discordRoutes, redirectUri } from './discord.js' -import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js' +import { ANLAESSE, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js' import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js' import { neuerSchluessel } from './freigabe.js' import { @@ -259,7 +263,9 @@ const zeigen = (user: User) => ({ dashRoutes.get('/auth/me', (c) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) if (!user) return c.json({ error: 'nicht angemeldet' }, 401) - return c.json({ user: zeigen(user) }) + // Die Zahl der offenen Sitzungen geht MIT: das Kontomenue zeigt sie an, und + // eine Zahl, die man erst auf einer anderen Seite findet, sieht niemand. + return c.json({ user: zeigen(user), sitzungen: countSessions(user.id) }) }) /** Was die Anmeldeseite wissen muss, BEVOR jemand angemeldet ist. @@ -332,6 +338,41 @@ const ARTEN: Record = { "AND mime <> 'application/pdf' AND mime NOT LIKE 'text/%' AND mime NOT LIKE 'application/json%')", } +/** Alle anderen Sitzungen beenden. + * + * WOZU: wer sich an einem fremden Rechner angemeldet hat und es spaeter merkt, + * hatte keine Moeglichkeit, das zurueckzunehmen -- ein geaendertes Passwort + * half nicht, die Sitzungen haengen an einer eigenen Tabelle und ueberleben + * es. + * + * Die EIGENE bleibt: wer drueckt, will sich nicht selbst aussperren. + * + * UND DIESER WEG STEHT HINTER DER WACHE, obwohl er unter /auth/ liegt. + * Zuerst stand er bei den anderen /auth-Wegen -- und die liegen mit Absicht + * DAVOR, weil man sich anmelden koennen muss, ohne angemeldet zu sein. Dort + * ist `c.get('user')` aber leer, und der Weg antwortete mit + * "Cannot read properties of undefined (reading 'id')" statt zu arbeiten. + * + * Hono setzt Middleware und Handler in der REIHENFOLGE ihrer Anmeldung + * zusammen: was vor `use('*')` steht, laeuft ohne sie. Der Pfad sagt darueber + * nichts. */ +dashRoutes.post('/auth/sitzungen/beenden', (c) => { + const sid = getCookie(c, SESSION_COOKIE) + if (!sid) return c.json({ error: 'keine Sitzung' }, 401) + const user = c.get('user') + const beendet = destroyOtherSessions(user.id, sid) + if (beendet > 0) { + logVerwaltung( + user.username, + 'benutzer', + 'geaendert', + user.username, + `${beendet} Sitzung(en) beendet`, + ) + } + return c.json({ beendet, offen: countSessions(user.id) }) +}) + dashRoutes.get('/media', (c) => { const query = c.req.query('query')?.trim() ?? '' const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200) @@ -1045,6 +1086,23 @@ dashRoutes.post('/upload-url', async (c) => { }) }) +/** Der Verwaltungs-Verlauf: wer hat wann was EINGESTELLT. + * + * Getrennt vom Datei-Verlauf, weil es eine andere Frage beantwortet -- und + * weil `events` mit `path` und `size` die falschen Spalten dafuer hat. + * + * WERTE STEHEN HIER NIE, nur Schluesselnamen. Unter den Einstellungen liegen + * das Discord-Geheimnis und das Nextcloud-Passwort. */ +dashRoutes.get('/verwaltung', (c) => { + const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500) + const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) + const items = db + .prepare('SELECT * FROM verwaltung ORDER BY at DESC LIMIT ? OFFSET ?') + .all(limit, offset) as VerwaltungZeile[] + const total = (db.prepare('SELECT COUNT(*) AS n FROM verwaltung').get() as { n: number }).n + return c.json({ items, total, limit, offset }) +}) + dashRoutes.get('/events', (c) => { const limit = Math.min(Number(c.req.query('limit') ?? 50) || 50, 500) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) @@ -1201,13 +1259,16 @@ dashRoutes.post('/settings', async (c) => { } } if (Array.isArray(m.anlaesse)) { - const erlaubt = ['sicherung', 'verwaiste'] + // Die Weissliste kommt aus meldung.ts und steht NICHT noch einmal + // hier. Genau diese Verdopplung hat den Plattenwaechter + // unbenutzbar gemacht: dort ergaenzt, hier vergessen. setSetting( MELDUNG_KEYS.anlaesse, - m.anlaesse.filter((a) => erlaubt.includes(a)).join(','), + m.anlaesse.filter((a) => (ANLAESSE as readonly string[]).includes(a)).join(','), ) } if (!body.discord && !body.sicherung && !body.vorlagen) { + logVerwaltung(c.get('user').username, 'einstellung', 'geaendert', 'Meldungen') return c.json({ ok: true, meldungen: meldungConfig() }) } } @@ -1252,6 +1313,7 @@ dashRoutes.post('/settings', async (c) => { } setzeVorlagen(sauber) if (!body.discord && !body.sicherung) { + logVerwaltung(c.get('user').username, 'einstellung', 'geaendert', 'Adressvorlagen', `${sauber.length} Stueck`) return c.json({ ok: true, vorlagen: sauber }) } } @@ -1354,6 +1416,16 @@ dashRoutes.post('/settings', async (c) => { else if (v) setSetting(DISCORD_KEYS.clientSecret, v) } + // Am Ende angekommen heisst: Discord und/oder Sicherung wurden angefasst. + // Aufgeschrieben wird WELCHER Bereich -- nie ein Wert. Darunter liegen das + // Discord-Geheimnis und das Nextcloud-Passwort. + logVerwaltung( + c.get('user').username, + 'einstellung', + 'geaendert', + [body.discord && 'Discord', body.sicherung && 'Sicherung'].filter(Boolean).join(' + '), + ) + const jetzt = discordConfig() return c.json({ ok: true, @@ -2037,6 +2109,7 @@ dashRoutes.delete('/benutzer/:id', (c) => { if (!wer) return c.json({ error: 'nicht gefunden' }, 404) db.prepare('DELETE FROM users WHERE id = ?').run(id) + logVerwaltung(c.get('user').username, 'benutzer', 'entfernt', wer.username) return c.json({ ok: true, username: wer.username }) }) @@ -2081,7 +2154,7 @@ dashRoutes.post('/freigaben', async (c) => { ) .run(schluessel, ordner, (body.titel ?? '').trim(), now()) - logEvent('upload', ordner, 0, 'user', c.get('user').username) + logVerwaltung(c.get('user').username, 'freigabe', 'angelegt', ordner, `${drin.n} Dateien`) return c.json({ id: Number(eintrag.lastInsertRowid), @@ -2093,8 +2166,13 @@ dashRoutes.post('/freigaben', async (c) => { }) dashRoutes.delete('/freigaben/:id', (c) => { - const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(Number(c.req.param('id'))) + const id = Number(c.req.param('id')) + const vorher = db.prepare('SELECT ordner FROM freigaben WHERE id = ?').get(id) as + | { ordner: string } + | undefined + const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(id) if (r.changes === 0) return c.json({ error: 'nicht gefunden' }, 404) + logVerwaltung(c.get('user').username, 'freigabe', 'entfernt', vorher?.ordner ?? String(id)) return c.json({ ok: true }) }) @@ -2331,6 +2409,8 @@ dashRoutes.post('/tokens', async (c) => { expiresAt, ) + logVerwaltung(c.get('user').username, 'token', 'angelegt', name.trim(), cleanPrefix || 'ueberall') + // Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in // der Datenbank, und auch wir koennen ihn nicht mehr zeigen. return c.json({ @@ -2461,6 +2541,8 @@ dashRoutes.patch('/tokens/:id', async (c) => { FROM tokens WHERE id = ?`, ) .get(id) as Omit + logVerwaltung(c.get('user').username, 'token', 'geaendert', neu.name, spalten.join(', ')) + return c.json({ ...neu, abgelaufen: Boolean(neu.expires_at && neu.expires_at < now()), @@ -2469,8 +2551,15 @@ dashRoutes.patch('/tokens/:id', async (c) => { }) dashRoutes.delete('/tokens/:id', (c) => { - const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(Number(c.req.param('id'))) + const id = Number(c.req.param('id')) + // Erst den Namen holen: nach dem DELETE ist er weg, und "Token 7 entfernt" + // sagt in einem halben Jahr niemandem mehr etwas. + const vorher = db.prepare('SELECT name FROM tokens WHERE id = ?').get(id) as + | { name: string } + | undefined + const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(id) if (info.changes === 0) return c.json({ error: 'nicht gefunden' }, 404) + logVerwaltung(c.get('user').username, 'token', 'entfernt', vorher?.name ?? String(id)) return c.json({ ok: true }) }) diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 331496f..d95256d 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -340,6 +340,20 @@ export type Einstellungen = { } } +/** Eine Zeile aus dem Verwaltungs-Verlauf. + * + * `was` traegt NIE einen Wert, nur Schluesselnamen — unter den Einstellungen + * liegen das Discord-Geheimnis und das Nextcloud-Passwort. */ +export type VerwaltungRow = { + id: number + at: number + wer: string + bereich: 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner' + tat: 'angelegt' | 'geaendert' | 'entfernt' + ziel: string + was: string +} + export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename' export type MediaEvent = { @@ -406,7 +420,7 @@ export const api = { logout: () => post<{ ok: true }>('/api/dash/auth/logout'), /** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */ authConfig: () => get<{ discord: boolean; marke: string }>('/api/dash/auth/config'), - me: () => get<{ user: User }>('/api/dash/auth/me'), + me: () => get<{ user: User; sitzungen?: number }>('/api/dash/auth/me'), changePassword: (current: string, next: string) => post<{ ok: true }>('/api/dash/auth/password', { current, next }), @@ -494,6 +508,13 @@ export const api = { { aktion }, ), + verwaltung: (limit = 100, offset = 0) => + get<{ items: VerwaltungRow[]; total: number; limit: number; offset: number }>( + `/api/dash/verwaltung?limit=${limit}&offset=${offset}`, + ), + sitzungenBeenden: () => + post<{ beendet: number; offen: number }>('/api/dash/auth/sitzungen/beenden'), + benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'), benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`), diff --git a/server/ui/src/components/Kontomenue.tsx b/server/ui/src/components/Kontomenue.tsx index 9867b14..64f8af3 100644 --- a/server/ui/src/components/Kontomenue.tsx +++ b/server/ui/src/components/Kontomenue.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from 'react' -import { ChevronDown, LogOut, UserCog } from 'lucide-react' +import { ChevronDown, LogOut, UserCog , ShieldOff } from 'lucide-react' import { run, useStore } from '../store' import { gehZu } from '../router' import { Avatar } from './Avatar' +import { api } from '../api' /** Das Konto hinter dem eigenen Namen. * @@ -16,6 +17,8 @@ import { Avatar } from './Avatar' export function Kontomenue() { const user = useStore((s) => s.user) const logout = useStore((s) => s.logout) + const sitzungen = useStore((s) => s.sitzungen) + const checkSession = useStore((s) => s.checkSession) const [offen, setOffen] = useState(false) const huelle = useRef(null) @@ -85,6 +88,33 @@ export function Kontomenue() { Konto und Passwort + {/* Nur wenn es ueberhaupt eine zweite gibt. Ein Knopf, der + "0 andere Sitzungen beendet" meldet, ist ein Knopf, der + nichts tut -- und beim naechsten Mal drueckt man ihn + wieder, weil man es vergessen hat. */} + {sitzungen > 1 && ( + + )} + ) } + +/** Die zwei Verläufe nebeneinander. + * + * Als Reiter und nicht als zweite Seite im Menü: es ist beides ein Verlauf, + * und wer einen sucht, sucht nicht zwei Menüeinträge ab. */ +function Reiter({ + reiter, + setReiter, +}: { + reiter: 'dateien' | 'verwaltung' + setReiter: (r: 'dateien' | 'verwaltung') => void +}) { + const stil = (an: boolean) => + `rounded-md px-3 py-1.5 text-sm ${ + an + ? 'bg-flaeche-hoch text-schrift' + : 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift' + }` + return ( +
+ + +
+ ) +} diff --git a/server/ui/src/store.ts b/server/ui/src/store.ts index 53eafbf..35f3e7e 100644 --- a/server/ui/src/store.ts +++ b/server/ui/src/store.ts @@ -19,6 +19,8 @@ type State = { checking: boolean notes: Note[] + /** Wie viele Sitzungen dieses Konto offen hat. 0 = noch nicht geladen. */ + sitzungen: number checkSession: () => Promise login: (username: string, password: string) => Promise logout: () => Promise @@ -30,12 +32,13 @@ type State = { export const useStore = create((set, get) => ({ user: null, checking: true, + sitzungen: 0, notes: [], async checkSession() { try { - const { user } = await api.me() - set({ user, checking: false }) + const { user, sitzungen } = await api.me() + set({ user, sitzungen: sitzungen ?? 0, checking: false }) } catch (err) { // 401 ist hier kein Fehler, sondern die Antwort "noch nicht // angemeldet". Alles andere ist einer und wird gesagt.