diff --git a/ROADMAP.md b/ROADMAP.md index 91ac92d..b192f84 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -727,6 +727,67 @@ Token, was damit abgelegt wurde: `media.token_id` wird seit dem ersten Tag geschrieben und war **nirgends** zu sehen. Gemessen: `d4rk_photostudio` hält 810 Dateien / 99,5 MB, 3640 Dateien liegen ohne Token da (aus dem Dashboard). +### ✅ Fertig — Konten, Kontingent, Plattenwächter, Umbenennen im Verlauf + +**Konten sichtbar.** Es gab zwei — `admin` und eines aus Discord — und **keine +Stelle, an der man sie sehen konnte**. Weil jeder mit der Rolle auf dem +Discord-Server beim ersten Anmelden still ein Konto bekommt, wären aus zwei +unbemerkt zwanzig geworden. Jetzt: Liste unter *Einstellungen → Konten* mit +Weg (Discord/Passwort), angelegt, zuletzt da, offene Sitzungen — und ein +Entfernen, das die Sitzungen mitnimmt. + +Zwei Sperren, beide mit Grund: das **eigene** Konto lässt sich hier nicht +entfernen (der eine Klick, nach dem niemand mehr hereinkommt), und das +**letzte** auch nicht. Und die Rückfrage sagt bei einem Discord-Konto dazu, dass +die nächste Anmeldung ein neues anlegt, solange die Rolle bleibt — sonst hält +jemand das Entfernen für eine Aussperrung. + +**Kontingent je Token.** `max_bytes` gab es längst, aber es galt **je Datei**: +ein Token mit „höchstens 2 MB je Bild" konnte trotzdem die Platte füllen, es +brauchte nur genug Bilder. `quota_bytes` gilt für alles zusammen, gezählt über +`media.token_id` — also über das, was wirklich liegt, statt über einen +mitlaufenden Zähler, der auseinanderlaufen kann. Auf **beiden** Wegen geprüft +(eigene API und Fivemanage-Weg): eine Grenze, die nur an einer von zwei Türen +hängt, ist keine. + +> Die erste Fassung meldete *„hält 0.0 MB von 0.0 MB, und diese Datei braucht +> 0.0 MB"* — formal richtig, praktisch die Meldung, die genau die Frage nicht +> beantwortet, wegen der man sie liest. Die Einheit wächst jetzt mit: +> **„hält 12 KB von 20 KB, und diese Datei braucht 12 KB."** + +**Plattenwächter.** Meldet nach Discord bei 80 % (knapp), 90 % (eng) und 95 % +(kritisch) — je Stufe genau einmal, und wenn wieder Luft ist, meldet der nächste +Engpass erneut. Der Grund steht in der Meldung: eine volle Platte ist der eine +Zustand, in dem gleichzeitig nichts mehr hereinkommt **und die Sicherung nicht +mehr schreiben kann**. + +Gemessen — und die Abweichung zu `df` ist gewollt: + +``` +gesamt 1006,9 GB +frei (bavail) 909,5 GB -> 9,7 % belegt <- was der Wächter sieht +frei (bfree) 960,7 GB -> 4,6 % belegt +df sagt 5 % +``` + +`bavail` zieht die ext4-Reserve ab, an die der Dienst als Nicht-root ohnehin +nicht herankommt. Er warnt damit etwas früher — die richtige Richtung. + +> Dabei fiel ein **schlafender Fehler** auf: der Merker gegen Wiederholungen +> lag unter **einem** Schlüssel für alle Wachhunde. Mit zwei davon hätten sie +> sich gegenseitig überschrieben — einer meldet, der andere hält daraufhin +> seinen Zustand für neu. Der Merker hängt jetzt am Anlass. + +**Umbenennen im Verlauf.** Stand dort als „verschoben". Technisch stimmt das, +für den Leser nicht: verschoben heißt „liegt woanders", umbenannt heißt „heißt +anders, liegt noch da" — und genau diesen Unterschied schlägt man im Verlauf +nach. Eigene Art mit eigenem Zeichen; die Filterleiste kennt jetzt auch +`move` und `rename`, die vorher gar nicht filterbar waren. + +**Und der Einzeiler im Fotostudio** ist gesetzt: `Upload.exists` fragt über +`/api/exists?pfad=…` statt über den Pfad, mit dem Grund als Kommentar daneben. +Syntaktisch gegengeprüft (`luac -p`). + ### ⬜ Als Nächstes **Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container diff --git a/server/src/auth.ts b/server/src/auth.ts index d18a8da..58d0b8e 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -50,6 +50,13 @@ export function createSession(userId: number): { id: string; expiresAt: number } return { id, expiresAt } } +/** Wann jemand zuletzt da war -- hoechstens einmal je Stunde geschrieben. + * + * Bei jedem Aufruf zu schreiben waere ein UPDATE je Anfrage, und das Dashboard + * stellt beim Blaettern durch 3600 Bilder eine Menge davon. Auf die Stunde + * genau reicht fuer die Frage "benutzt das noch jemand" vollauf. */ +const STUNDE = 3600_000 + export function userForSession(sessionId: string | undefined): User | null { if (!sessionId) return null const row = db @@ -59,7 +66,13 @@ export function userForSession(sessionId: string | undefined): User | null { WHERE s.id = ? AND s.expires_at > ?`, ) .get(sessionId, now()) as User | undefined - return row ?? null + if (!row) return null + + const jetzt = now() + if (!row.zuletzt_da || jetzt - row.zuletzt_da > STUNDE) { + db.prepare('UPDATE users SET zuletzt_da = ? WHERE id = ?').run(jetzt, row.id) + } + return row } export function destroySession(sessionId: string) { diff --git a/server/src/db.ts b/server/src/db.ts index ebdc08a..4812a86 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -169,6 +169,15 @@ CREATE TABLE IF NOT EXISTS settings ( // 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. + // Das GESAMTKONTINGENT, nicht die Groesse EINER Datei -- max_bytes gab es + // laengst, aber es galt je Upload. Ein Token mit "hoechstens 2 MB je Bild" + // konnte trotzdem die Platte fuellen, es brauchte nur genug Bilder. + const kSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[] + if (!kSpalten.some((s) => s.name === 'quota_bytes')) { + db.exec('ALTER TABLE tokens ADD COLUMN quota_bytes INTEGER') + console.log('[db] Spalte tokens.quota_bytes angelegt') + } + 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') @@ -206,6 +215,14 @@ CREATE TABLE IF NOT EXISTS settings ( console.log('[db] Spalten media.abrufe und zuletzt_abgerufen angelegt') } + // Wann war der zuletzt da? Steht sonst nirgends: sessions weiss es, aber + // eine abgelaufene Sitzung ist weg, und dann sieht ein Konto, das seit + // einem halben Jahr niemand benutzt, aus wie eines von gestern. + if (!spalten.some((s) => s.name === 'zuletzt_da')) { + db.exec('ALTER TABLE users ADD COLUMN zuletzt_da INTEGER') + console.log('[db] Spalte users.zuletzt_da angelegt') + } + if (!spalten.some((s) => s.name === 'discord_avatar')) { db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT') db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT') @@ -239,6 +256,7 @@ CREATE TABLE IF NOT EXISTS settings ( } export type User = { + zuletzt_da?: number | null id: number username: string password_hash: string @@ -285,7 +303,10 @@ export type Token = { last_used_at: number | null /** Groesste erlaubte Datei fuer DIESEN Token. Null heisst: die Grenze des * Dienstes gilt (MAX_UPLOAD_MB). */ + /** Groesste erlaubte EINZELNE Datei. */ max_bytes: number | null + /** Wieviel dieser Token INSGESAMT halten darf. Null = so viel er will. */ + quota_bytes: number | null /** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */ arten: string /** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */ @@ -318,6 +339,16 @@ export type Freigabe = { zuletzt: number | null } +export type BenutzerZeile = { + id: number + username: string + created_at: number + zuletzt_da: number | null + discord_id: string | null + discord_name: string | null + discord_avatar: string | null +} + export type PapierkorbEintrag = { id: number path: string @@ -329,7 +360,12 @@ export type PapierkorbEintrag = { ablage: string } -export type EventKind = 'upload' | 'replace' | 'delete' | 'move' +// 'rename' steht neben 'move', obwohl es technisch dasselbe ist: eine Datei +// bekommt einen anderen Pfad. Fuer den, der den Verlauf LIEST, ist es aber +// etwas anderes -- verschoben heisst "liegt woanders", umbenannt heisst "heisst +// anders, liegt noch da". Ein Verlauf, der beides "verschoben" nennt, laesst +// genau die Frage offen, wegen der man ihn aufschlaegt. +export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename' export type MediaEvent = { id: number @@ -339,7 +375,7 @@ export type MediaEvent = { size: number | null actor_kind: 'token' | 'user' | 'unbekannt' actor: string - /** Nur bei 'move': der Pfad, unter dem die Datei vorher lag. */ + /** Bei 'move' und 'rename': der Pfad, unter dem die Datei vorher lag. */ von: string | null } diff --git a/server/src/index.ts b/server/src/index.ts index cddcdcb..5dc6aa5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -29,7 +29,7 @@ import { logger } from 'hono/logger' import { config } from './config.js' import { ensureAdmin } from './auth.js' import { pruneSessions, schreibeAbrufe, zaehleAbruf } from './db.js' -import { pruefeSicherung } from './meldung.js' +import { pruefePlatte, pruefeSicherung } from './meldung.js' import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js' import { uploadRoutes } from './routes/upload.js' import { fivemanageRoutes } from './routes/fivemanage.js' @@ -381,6 +381,12 @@ setInterval(pruneSessions, 3600_000).unref() setTimeout(() => void pruefeSicherung(), 60_000).unref() setInterval(() => void pruefeSicherung(), 3600_000).unref() +// Und der Platz auf der Platte. Derselbe Takt und derselbe Grund: eine volle +// Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und +// nichts mehr gesichert wird. +setTimeout(() => void pruefePlatte(), 75_000).unref() +setInterval(() => void pruefePlatte(), 3600_000).unref() + // Die gezaehlten Abrufe wegschreiben. Alle 30 Sekunden reicht: wer wissen // will, ob eine Datei benutzt wird, braucht keine Genauigkeit auf die Sekunde. setInterval(schreibeAbrufe, 30_000).unref() diff --git a/server/src/meldung.ts b/server/src/meldung.ts index 065c7cc..3194d05 100644 --- a/server/src/meldung.ts +++ b/server/src/meldung.ts @@ -19,14 +19,22 @@ // ist eine Warnung, die man ueberliest -- und diese Meldungen gibt es nur, // weil jemand sie sehen soll. +import { statfs } from 'node:fs/promises' +import { config } from './config.js' 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'. */ + /** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste', 'platte'. */ anlaesse: 'discord.anlaesse', - /** Womit zuletzt gemeldet wurde -- gegen Wiederholungen. */ + /** Womit zuletzt gemeldet wurde -- gegen Wiederholungen. + * + * ALS PRAEFIX, nicht als ein Schluessel: es gibt inzwischen ZWEI + * Wachhunde (Sicherung und Plattenplatz), und mit einem gemeinsamen + * Merker haetten sie sich gegenseitig ueberschrieben. Der eine meldet, + * der andere haelt daraufhin seinen eigenen Zustand fuer neu und meldet + * auch -- oder umgekehrt schweigt einer, obwohl sich etwas geaendert hat. */ zuletzt: 'discord.zuletzt', /** Der Name in der Fusszeile. Wie beim Bot einstellbar, mit demselben * Standard. */ @@ -57,7 +65,7 @@ export function meldungConfig(): MeldungConfig { // will Meldungen -- sonst haette er es gelassen. anlaesse: roh ? roh.split(',').map((a) => a.trim()).filter(Boolean) - : ['sicherung', 'verwaiste'], + : ['sicherung', 'verwaiste', 'platte'], } } @@ -85,9 +93,10 @@ export async function melde( if (opt.anlass && !meldungConfig().anlaesse.includes(opt.anlass)) return false if (opt.einmalig) { - const zuletzt = getSetting(MELDUNG_KEYS.zuletzt) + const merker = `${MELDUNG_KEYS.zuletzt}.${opt.anlass ?? 'allgemein'}` + const zuletzt = getSetting(merker) if (zuletzt === opt.einmalig) return false - setSetting(MELDUNG_KEYS.zuletzt, opt.einmalig) + setSetting(merker, opt.einmalig) } const marke = getSetting(MELDUNG_KEYS.marke) || MARKE_STANDARD @@ -211,3 +220,85 @@ export async function testMeldung(wer: string, bestand: { files: number; bytes: schwere: 'gut', }) } + +// ------------------------------------------------------------ Plattenplatz +// +// WOZU. Der Dienst schreibt so lange Bilder, wie die Platte sie annimmt -- und +// wenn sie voll ist, faellt das nicht beim Hochladen auf, sondern bei der +// naechsten SICHERUNG, die dann auch nicht mehr schreiben kann. Eine volle +// Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und +// nichts mehr gerettet wird. +// +// Gemeldet wird nach STUFEN und nicht bei jedem Prozentpunkt: `einmalig` +// bekommt die Stufe als Wert, es kommt also je Stufe genau eine Meldung. Faellt +// der Platz weiter, meldet die naechste Stufe; wird wieder Platz frei, faellt +// der Zustand auf "gut" und der naechste Engpass meldet erneut. + +// Die Schweregrade heissen hier so wie ueberall im Dienst -- 'schlecht', +// 'warnung', 'marke'. Ich hatte zuerst 'fehler' und 'hinweis' geschrieben, was +// naheliegt und falsch ist; der Uebersetzer hat es gefangen. +const STUFEN = [ + { ab: 0.95, name: 'kritisch' as const, schwere: 'schlecht' as const }, + { ab: 0.9, name: 'eng' as const, schwere: 'warnung' as const }, + { ab: 0.8, name: 'knapp' as const, schwere: 'marke' as const }, +] + +export async function pruefePlatte(): Promise { + let frei: number + let gesamt: number + try { + // statfs gibt es in node:fs/promises seit 18.15 -- hier laeuft 22. + const s = await statfs(config.dataDir) + gesamt = s.blocks * s.bsize + // bavail und NICHT bfree: ein Teil ist fuer root reserviert, und der + // Dienst laeuft nicht als root -- bfree waere eine Zahl, an die wir gar + // nicht herankommen. + // + // DAS HEISST: WIR MELDEN FRUEHER ALS `df`. Auf dieser Platte gemessen: + // + // gesamt 1006,9 GB + // frei (bavail) 909,5 GB -> 9,7 % belegt <- was hier zaehlt + // frei (bfree) 960,7 GB -> 4,6 % belegt + // df sagt 5 % + // + // Die Luecke ist die ext4-Reserve. Der Unterschied ist gewollt: wer + // gewarnt werden will, bevor es eng wird, will die Zahl, die IHN + // betrifft. Wer spaeter den Unterschied zu `df` sucht, findet ihn hier. + frei = s.bavail * s.bsize + } catch (err) { + // Kein Grund zu laermen: auf manchen Dateisystemen gibt es das nicht. + console.error('[platte] liess sich nicht messen:', err) + return + } + + if (!gesamt) return + const belegt = 1 - frei / gesamt + const stufe = STUFEN.find((s) => belegt >= s.ab) + + const gb = (n: number) => `${(n / 1073741824).toFixed(1)} GB` + + if (!stufe) { + // Wieder Luft. Den Merker zuruecksetzen, damit der naechste Engpass + // wieder meldet -- ohne das bliebe es nach einem einmaligen Volllaufen + // fuer immer still. + setSetting(`${MELDUNG_KEYS.zuletzt}.platte`, 'gut') + return + } + + await melde(`Der Platz wird ${stufe.name}`, { + text: + `Unter \`${config.dataDir}\` sind noch **${gb(frei)}** von ${gb(gesamt)} frei ` + + `(${(belegt * 100).toFixed(1)} % belegt).\n\n` + + 'Ist die Platte voll, nimmt der Dienst nichts mehr an **und die Sicherung ' + + 'kann nicht mehr schreiben** — beides zugleich.', + felder: [ + { name: 'Frei', value: gb(frei), inline: true }, + { name: 'Gesamt', value: gb(gesamt), inline: true }, + { name: 'Belegt', value: `${(belegt * 100).toFixed(1)} %`, inline: true }, + ], + schwere: stufe.schwere, + anlass: 'platte', + einmalig: stufe.name, + tag: 'PLATTE', + }) +} diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index e6caad1..e182a19 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -18,6 +18,7 @@ import { pruneEvents, pruneSessions, type Media, + type BenutzerZeile, type Freigabe, type MediaEvent, type PapierkorbEintrag, @@ -947,7 +948,7 @@ dashRoutes.get('/events', (c) => { const bedingungen: string[] = [] const params: unknown[] = [] - if (kind && ['upload', 'replace', 'delete'].includes(kind)) { + if (kind && ['upload', 'replace', 'delete', 'move', 'rename'].includes(kind)) { bedingungen.push('kind = ?') params.push(kind) } @@ -1469,7 +1470,7 @@ dashRoutes.post('/media/umbenennen', async (c) => { await moveFile(row.path, neu) db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run(neu, now(), row.id) - logEvent('move', neu, row.size, 'user', c.get('user').username, row.path) + logEvent('rename', neu, row.size, 'user', c.get('user').username, row.path) return c.json({ alt: row.path, neu, url: publicUrlFor(neu), abrufe: row.abrufe }) }) @@ -1548,7 +1549,7 @@ dashRoutes.post('/folders/umbenennen', async (c) => { // EIN Eintrag im Verlauf und nicht 3576. Wer nachliest, was passiert ist, // will "items -> gegenstaende" lesen und nicht dreitausend Zeilen davon. - logEvent('move', neu, 0, 'user', wer, alt) + logEvent('rename', neu, 0, 'user', wer, alt) return c.json({ alt, neu, dateien: betroffen.length }) }) @@ -1868,6 +1869,71 @@ dashRoutes.post('/papierkorb/leeren', async (c) => { return c.json({ geleert: weg }) }) +// ------------------------------------------------------------ Benutzer +// +// WOZU. Es gab zwei Konten und keine Stelle, an der man sie sehen konnte -- +// "admin" und eines aus Discord. Und weil jeder mit der Rolle auf dem Server +// beim ersten Anmelden STILL ein Konto bekommt, waere aus zwei unbemerkt +// zwanzig geworden. Wer hereinkommt, gehoert auf einen Bildschirm. + +dashRoutes.get('/benutzer', (c) => { + const items = ( + db + .prepare( + `SELECT id, username, created_at, zuletzt_da, discord_id, discord_name, + discord_avatar + FROM users ORDER BY created_at`, + ) + .all() as BenutzerZeile[] + ).map((u) => ({ + id: u.id, + username: u.username, + created_at: u.created_at, + zuletzt_da: u.zuletzt_da, + // Ueber welchen Weg jemand hereinkommt, ist die wichtigere Auskunft als + // sein Name: ein Discord-Konto haengt an einer Rolle, die jemand anders + // vergibt. + weg: u.discord_id ? 'discord' : 'passwort', + discordName: u.discord_name, + avatar: avatarUrl(u.discord_id, u.discord_avatar, 32), + // Wieviele Sitzungen gerade offen sind -- die Zahl, die nach dem + // Entfernen auf 0 steht. + sitzungen: ( + db + .prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND expires_at > ?') + .get(u.id, now()) as { n: number } + ).n, + })) + return c.json({ items, ich: c.get('user').id }) +}) + +/** Ein Konto entfernen. + * + * SICH SELBST NICHT -- das ist keine Bevormundung, sondern die Vermeidung des + * einen Klicks, nach dem niemand mehr hereinkommt. Und den LETZTEN nicht: ein + * Dienst ohne Konto laesst sich nur noch ueber die Datenbank reparieren. + * + * Die Sitzungen gehen mit (ON DELETE CASCADE), der Zugang ist also sofort weg + * und nicht erst in 30 Tagen. */ +dashRoutes.delete('/benutzer/:id', (c) => { + const id = Number(c.req.param('id')) + if (id === c.get('user').id) { + return c.json({ error: 'Das eigene Konto laesst sich hier nicht entfernen.' }, 400) + } + const wieviele = (db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n + if (wieviele <= 1) { + return c.json({ error: 'Das letzte Konto bleibt — sonst kommt niemand mehr herein.' }, 400) + } + + const wer = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as + | { username: string } + | undefined + if (!wer) return c.json({ error: 'nicht gefunden' }, 404) + + db.prepare('DELETE FROM users WHERE id = ?').run(id) + return c.json({ ok: true, username: wer.username }) +}) + // ------------------------------------------------------------- Freigaben // // Was hier verwaltet wird, ist die LISTE eines Ordners -- die Dateien selbst @@ -2032,7 +2098,7 @@ dashRoutes.get('/tokens', (c) => { const rows = db .prepare( `SELECT id, name, prefix, can_delete, created_at, last_used_at, - max_bytes, arten, expires_at + max_bytes, quota_bytes, arten, expires_at FROM tokens ORDER BY created_at DESC`, ) .all() as Omit[] @@ -2068,6 +2134,7 @@ dashRoutes.post('/tokens', async (c) => { prefix?: string canDelete?: boolean maxMb?: number | string + quotaMb?: number | string arten?: string[] gueltigTage?: number | string }>(c) @@ -2096,6 +2163,18 @@ dashRoutes.post('/tokens', async (c) => { maxBytes = Math.round(mb * 1024 * 1024) } + // DAS GESAMTKONTINGENT -- etwas anderes als die Groesse EINER Datei, und + // deshalb ohne Deckelung durch config.maxUploadBytes: "hoechstens 2 MB je + // Bild, aber insgesamt 5 GB" ist eine voellig sinnvolle Angabe. + let quotaBytes: number | null = null + if (body.quotaMb !== undefined && String(body.quotaMb).trim() !== '') { + const mb = Number(body.quotaMb) + if (!Number.isFinite(mb) || mb <= 0) { + return c.json({ error: 'Kontingent: eine Zahl groesser als 0' }, 400) + } + quotaBytes = Math.round(mb * 1024 * 1024) + } + const ERLAUBTE_ARTEN = ['bild', 'video', 'ton', 'dokument', 'andere'] let arten = '' if (Array.isArray(body.arten) && body.arten.length > 0) { @@ -2131,8 +2210,8 @@ dashRoutes.post('/tokens', async (c) => { const info = db .prepare( `INSERT INTO tokens (name, hash, prefix, can_delete, created_at, - max_bytes, arten, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + max_bytes, quota_bytes, arten, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( name.trim(), @@ -2141,6 +2220,7 @@ dashRoutes.post('/tokens', async (c) => { canDelete ? 1 : 0, now(), maxBytes, + quotaBytes, arten, expiresAt, ) diff --git a/server/src/routes/fivemanage.ts b/server/src/routes/fivemanage.ts index ca4c419..0fb812e 100644 --- a/server/src/routes/fivemanage.ts +++ b/server/src/routes/fivemanage.ts @@ -30,6 +30,7 @@ import type { Context, Next } from 'hono' import { config } from '../config.js' import { db, logEvent, now, type Media, type Token } from '../db.js' import { tokenAllows, tokenFromHeader } from '../auth.js' +import { kontingentUeberschritten } from './upload.js' import { checkPath, kannBewegtVorschau, @@ -201,6 +202,13 @@ async function ablegen( return { ok: false, antwort: c.json({ status: 'error', message: 'leere Datei' }, 400) } } + // Dasselbe Kontingent wie auf dem eigenen Weg -- eine Grenze, die nur an + // einer von zwei Tueren haengt, ist keine. + const zuViel = kontingentUeberschritten(token, data.length) + if (zuViel) { + return { ok: false, antwort: c.json({ status: 'error', message: zuViel }, 413) } + } + const digest = sha256(data) const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? '' // Der Name entscheidet, wenn der Absender nur mit den Schultern zuckt -- diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 279c88f..f8a4282 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -70,6 +70,55 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => { await next() } +/** Prueft das Gesamtkontingent. Gibt einen SATZ zurueck, wenn es reisst. + * + * Die Meldung nennt Zahlen: "belegt 1,9 GB von 2 GB, diese Datei braucht 130 + * KB" beantwortet die Frage, die man danach hat. Ein blosses "Kontingent + * erschoepft" beantwortet sie nicht. + * + * Ein ERSETZEN zaehlt hier grosszuegig als voller Neuzugang: der genaue Wert + * waere (neu - alt), aber dafuer muesste hier schon feststehen, unter welchem + * Pfad die Datei landet -- und das steht erst nach dem Lesen des Rumpfes fest. + * Die Ungenauigkeit geht zugunsten der Grenze, nicht darueber hinaus. */ +export function kontingentUeberschritten(token: Token, kommt: number): string | null { + if (!token.quota_bytes) return null + + const belegt = ( + db + .prepare('SELECT COALESCE(SUM(size),0) AS b FROM media WHERE token_id = ?') + .get(token.id) as { b: number } + ).b + + if (belegt + kommt <= token.quota_bytes) return null + + return ( + `Kontingent erschoepft: dieser Token haelt ${groesse(belegt)} von ` + + `${groesse(token.quota_bytes)}, und diese Datei braucht ${groesse(kommt)}.` + ) +} + +/** Bytes lesbar -- mit MITWACHSENDER Einheit. + * + * Erst stand hier ein festes "MB", und die Meldung lautete daraufhin + * "haelt 0.0 MB von 0.0 MB, und diese Datei braucht 0.0 MB". Formal richtig, + * praktisch eine Meldung, die genau die Frage nicht beantwortet, wegen der man + * sie liest. Bei einem Kontingent von 20 KB muss dort KB stehen. + * + * 1024 und nicht 1000: dieselbe Rechnung wie in der Oberflaeche, sonst nennt + * der Dienst eine andere Zahl als das Dashboard daneben. */ +function groesse(n: number): string { + if (n < 1024) return `${n} B` + const einheiten = ['KB', 'MB', 'GB', 'TB'] + let wert = n / 1024 + let i = 0 + while (wert >= 1024 && i < einheiten.length - 1) { + wert /= 1024 + i++ + } + // Eine Nachkommastelle nur, wo sie etwas sagt: "1,5 GB" ja, "512,0 KB" nein. + return `${wert < 10 ? wert.toFixed(1) : Math.round(wert)} ${einheiten[i]}` +} + /** Welche Medienart ist das? Dieselben Namen wie im Dashboard. * * An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der @@ -138,6 +187,15 @@ uploadRoutes.post('/upload', async (c) => { return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413) } + // DAS GESAMTKONTINGENT, geprueft BEVOR der Rumpf im Speicher liegt. + // + // max_bytes daneben gilt je Datei -- ein Token mit "hoechstens 2 MB je + // Bild" konnte die Platte trotzdem fuellen, es brauchte nur genug Bilder. + // Gezaehlt wird ueber media.token_id, also ueber das, was WIRKLICH liegt, + // und nicht ueber einen mitlaufenden Zaehler, der auseinanderlaufen kann. + const zuViel = kontingentUeberschritten(token, announced) + if (zuViel) return c.json({ error: zuViel }, 413) + let body: Awaited> try { body = await readBody(c) @@ -150,6 +208,10 @@ uploadRoutes.post('/upload', async (c) => { if (data.length > grenze) { return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413) } + // Noch einmal mit der WIRKLICHEN Groesse: content-length ist eine Ansage + // des Aufrufers, keine Tatsache. + const zuViel2 = kontingentUeberschritten(token, data.length) + if (zuViel2) return c.json({ error: zuViel2 }, 413) const digest = sha256(data) diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index b313445..ca86f59 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -174,8 +174,10 @@ export type TokenRow = { can_delete: number created_at: number last_used_at: number | null - /** Groesste erlaubte Datei. Null = die Grenze des Dienstes. */ + /** Groesste erlaubte EINZELNE Datei. Null = die Grenze des Dienstes. */ max_bytes: number | null + /** Wieviel dieser Token INSGESAMT halten darf. Null = unbegrenzt. */ + quota_bytes: number | null /** Erlaubte Arten, mit Komma. Leer = alle. */ arten: string expires_at: number | null @@ -228,6 +230,20 @@ export type Uebersicht = { export type Vorlage = { name: string; muster: string } +/** Ein Konto. `weg` sagt, worueber jemand hereinkommt — die wichtigere Auskunft + * als der Name, denn ein Discord-Konto haengt an einer Rolle, die jemand + * anders vergibt. */ +export type BenutzerRow = { + id: number + username: string + created_at: number + zuletzt_da: number | null + weg: 'discord' | 'passwort' + discordName: string | null + avatar: string | null + sitzungen: number +} + /** Eine Freigabe: ein Ordner, den man ohne Anmeldung ansehen kann. * * Freigegeben ist die LISTE, nicht der Inhalt — die Dateien sind ohnehin @@ -324,7 +340,7 @@ export type Einstellungen = { } } -export type EventKind = 'upload' | 'replace' | 'delete' | 'move' +export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename' export type MediaEvent = { id: number @@ -478,6 +494,9 @@ export const api = { { aktion }, ), + benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'), + benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`), + freigaben: () => get<{ items: FreigabeRow[] }>('/api/dash/freigaben'), freigabeAnlegen: (ordner: string, titel: string) => post<{ id: number; schluessel: string; ordner: string; dateien: number; url: string }>( @@ -505,6 +524,7 @@ export const api = { prefix: string canDelete: boolean maxMb?: string + quotaMb?: string arten?: string[] gueltigTage?: string }) => post('/api/dash/tokens', daten), diff --git a/server/ui/src/components/BenutzerKarte.tsx b/server/ui/src/components/BenutzerKarte.tsx new file mode 100644 index 0000000..6bdfb39 --- /dev/null +++ b/server/ui/src/components/BenutzerKarte.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from 'react' +import { KeyRound, Trash2, UserRound } from 'lucide-react' +import { api, type BenutzerRow } from '../api' +import { run, useStore } from '../store' +import { zeit } from '../format' +import { Avatar } from './Avatar' +import { Karte, Nachfrage } from './Bausteine' + +/** Wer ein Konto hat. + * + * WOZU ÜBERHAUPT. Es gab zwei Konten und keine Stelle, an der man sie sehen + * konnte. Und weil jeder mit der Rolle auf dem Discord-Server beim ersten + * Anmelden **still** ein Konto bekommt, wären aus zwei unbemerkt zwanzig + * geworden — man erfährt es nicht, und wegnehmen ging auch nicht. + * + * Die Spalte „Weg" ist dabei die wichtigere Auskunft als der Name: ein + * Discord-Konto hängt an einer Rolle, die jemand anders vergibt. Wer die Rolle + * verliert, behält trotzdem sein Konto hier — geprüft wird die Rolle nur beim + * Anmelden. */ +export function BenutzerKarte() { + const note = useStore((s) => s.note) + const [items, setItems] = useState(null) + const [ich, setIch] = useState(0) + const [weg, setWeg] = useState(null) + + async function laden() { + try { + const r = await api.benutzer() + setItems(r.items) + setIch(r.ich) + } catch (err) { + note('fehler', err instanceof Error ? err.message : 'Unbekannt') + } + } + + useEffect(() => { + void laden() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!items) return null + + return ( + +

+ Wer sich hier anmelden kann. Ein Konto über Discord entsteht{' '} + von selbst, sobald sich jemand mit der + passenden Rolle zum ersten Mal anmeldet — deshalb steht diese Liste hier. + Entfernen wirft auch die offenen Sitzungen weg, der Zugang ist also sofort zu. +

+ + + + + + + + + + + + + {items.map((u) => ( + + + + + + + + + ))} + +
NameWegAngelegtZuletzt daSitzungen +
+ + + {u.username} + {u.id === ich && ( + + das bist du + + )} + + + {u.weg === 'discord' ? ( + + Discord + + ) : ( + + Passwort + + )} + {zeit(u.created_at)} + {u.zuletzt_da ? ( + zeit(u.zuletzt_da) + ) : ( + // Nicht „nie": gezählt wird erst, seit es + // die Spalte gibt. Eine Null zu behaupten, + // wo nichts gemessen wurde, wäre gelogen. + + — + + )} + + {u.sitzungen} + + +
+ + setWeg(null)} + onJa={async () => { + const u = weg + setWeg(null) + if (!u) return + await run(() => api.benutzerWeg(u.id), (r) => `${r.username} entfernt.`) + void laden() + }} + text={ +

+ {weg?.username} kommt danach nicht mehr + herein, und die {weg?.sitzungen ?? 0} offenen Sitzungen enden sofort. + {weg?.weg === 'discord' && ( + <> + {' '} + Aber: hat diese Person + weiterhin die Rolle auf deinem Discord-Server, legt die nächste + Anmeldung ein neues Konto an. Wer dauerhaft draußen bleiben soll, + verliert die Rolle. + + )} +

+ } + /> +
+ ) +} diff --git a/server/ui/src/components/MeldungenKarte.tsx b/server/ui/src/components/MeldungenKarte.tsx index fe6c78f..fde73a0 100644 --- a/server/ui/src/components/MeldungenKarte.tsx +++ b/server/ui/src/components/MeldungenKarte.tsx @@ -7,6 +7,7 @@ import { Feld, Karte, Knopf } from './Bausteine' const ANLAESSE = [ ['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'], ['verwaiste', 'Verwaiste Dateien gefunden'], + ['platte', 'Der Platz auf der Platte wird knapp'], ] as const /** Meldungen nach Discord. diff --git a/server/ui/src/pages/Einstellungen.tsx b/server/ui/src/pages/Einstellungen.tsx index bf55ef8..7d1010b 100644 --- a/server/ui/src/pages/Einstellungen.tsx +++ b/server/ui/src/pages/Einstellungen.tsx @@ -7,6 +7,7 @@ import { Feld, Karte, Knopf } from '../components/Bausteine' import { DiscordKarte } from '../components/DiscordKarte' import { VorlagenKarte } from '../components/VorlagenKarte' import { FreigabenKarte } from '../components/FreigabenKarte' +import { BenutzerKarte } from '../components/BenutzerKarte' import { MeldungenKarte } from '../components/MeldungenKarte' /** Alles, was sich einstellen lässt, an einer Stelle. @@ -31,6 +32,8 @@ export function Einstellungen() { + + diff --git a/server/ui/src/pages/Start.tsx b/server/ui/src/pages/Start.tsx index 8bf3a74..8784c3a 100644 --- a/server/ui/src/pages/Start.tsx +++ b/server/ui/src/pages/Start.tsx @@ -13,6 +13,7 @@ import { Trash2, TrendingUp, Upload, + PenLine, } from 'lucide-react' import { api, vorschauUrl, type EventKind, type Uebersicht } from '../api' import { useStore } from '../store' @@ -27,6 +28,7 @@ const arten: Record([]) const [legtAn, setLegtAn] = useState(false) @@ -52,6 +53,7 @@ export function Tokens() { prefix: prefix.trim(), canDelete: darfLoeschen, maxMb: maxMb.trim(), + quotaMb: quotaMb.trim(), arten, gueltigTage: gueltigTage.trim(), }), @@ -111,6 +113,16 @@ export function Tokens() { hinweis="Ein Token für Fahrzeugbilder braucht keine 64 MB." onChange={(e) => setMaxMb(e.target.value)} /> + setQuotaMb(e.target.value)} + /> {[ - t.max_bytes ? `max ${bytes(t.max_bytes)}` : null, + t.max_bytes ? `max ${bytes(t.max_bytes)}/Datei` : null, + t.quota_bytes + ? `${bytes(t.bytes)} von ${bytes(t.quota_bytes)}` + : null, t.arten ? t.arten.replaceAll(',', ', ') : null, t.expires_at ? `bis ${zeit(t.expires_at).slice(0, 10)}` diff --git a/server/ui/src/pages/Verlauf.tsx b/server/ui/src/pages/Verlauf.tsx index bf1f789..331144b 100644 --- a/server/ui/src/pages/Verlauf.tsx +++ b/server/ui/src/pages/Verlauf.tsx @@ -4,6 +4,7 @@ import { ChevronRight, History, Loader2, + PenLine, RefreshCw, Search, FolderInput, @@ -26,6 +27,10 @@ const arten: Record