feat: Konten sichtbar, Kontingent je Token, Plattenwaechter, 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, waeren aus zwei
unbemerkt zwanzig geworden: man erfaehrt es nicht, und wegnehmen ging auch
nicht.

Jetzt eine Liste mit Weg (Discord/Passwort), angelegt, zuletzt da und offenen
Sitzungen. Die Spalte "Weg" ist dabei die wichtigere Auskunft als der Name: ein
Discord-Konto haengt an einer Rolle, die jemand anders vergibt.

Zwei Sperren, beide mit Grund: das EIGENE Konto laesst sich hier nicht
entfernen -- das ist keine Bevormundung, sondern die Vermeidung des einen
Klicks, nach dem niemand mehr hereinkommt -- und das LETZTE auch nicht. Die
Rueckfrage sagt bei einem Discord-Konto dazu, dass die naechste Anmeldung ein
neues anlegt, solange die Rolle bleibt; sonst haelt jemand das Entfernen fuer
eine Aussperrung.

"zuletzt da" wird hoechstens einmal je Stunde geschrieben. Bei jedem Aufruf
waere es ein UPDATE je Anfrage, und das Dashboard stellt beim Blaettern durch
3600 Bilder eine Menge davon.

KONTINGENT JE TOKEN. max_bytes gab es laengst, aber es galt JE DATEI: ein Token
mit "hoechstens 2 MB je Bild" konnte trotzdem die Platte fuellen, es brauchte
nur genug Bilder. quota_bytes gilt fuer alles zusammen, gezaehlt ueber
media.token_id -- also ueber das, was wirklich liegt, statt ueber einen
mitlaufenden Zaehler, der auseinanderlaufen kann.

Auf BEIDEN Wegen geprueft, eigene API und Fivemanage-Weg: eine Grenze, die nur
an einer von zwei Tueren haengt, ist keine.

Die erste Fassung meldete "haelt 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 waechst jetzt mit: "haelt 12
KB von 20 KB, und diese Datei braucht 12 KB."

PLATTENWAECHTER. Meldet bei 80, 90 und 95 Prozent, je Stufe genau einmal; wird
wieder Platz frei, meldet der naechste 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 Waechter 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 frueher -- die richtige Richtung. Wer spaeter
den Unterschied zu df sucht, findet ihn als Kommentar an Ort und Stelle.

Dabei fiel ein SCHLAFENDER FEHLER auf: der Merker gegen Wiederholungen lag unter
EINEM Schluessel fuer alle Wachhunde. Mit zwei davon haetten sie sich
gegenseitig ueberschrieben -- einer meldet, der andere haelt daraufhin seinen
eigenen Zustand fuer neu und meldet auch, oder schweigt, obwohl sich etwas
geaendert hat. Der Merker haengt jetzt am Anlass.

UMBENENNEN IM VERLAUF. Stand dort als "verschoben". Technisch stimmt das, fuer
den Leser nicht: verschoben heisst "liegt woanders", umbenannt heisst "heisst
anders, liegt noch da" -- und genau diesen Unterschied schlaegt man im Verlauf
nach. Eigene Art mit eigenem Zeichen. Die Filterleiste kennt jetzt auch move und
rename, die vorher gar nicht filterbar waren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:26:22 +02:00
co-authored by Claude Opus 5
parent 06e924daf5
commit 458775f9d9
15 changed files with 580 additions and 18 deletions
+61
View File
@@ -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
+14 -1
View File
@@ -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) {
+38 -2
View File
@@ -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
}
+7 -1
View File
@@ -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()
+96 -5
View File
@@ -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<void> {
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',
})
}
+86 -6
View File
@@ -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<Token, 'hash'>[]
@@ -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,
)
+8
View File
@@ -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 --
+62
View File
@@ -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<ReturnType<typeof readBody>>
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)
+22 -2
View File
@@ -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<NewToken>('/api/dash/tokens', daten),
+159
View File
@@ -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<BenutzerRow[] | null>(null)
const [ich, setIch] = useState(0)
const [weg, setWeg] = useState<BenutzerRow | null>(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 (
<Karte titel="Konten">
<p className="mb-3 text-xs text-schrift-leise">
Wer sich hier anmelden kann. Ein Konto über Discord entsteht{' '}
<strong className="text-schrift">von selbst</strong>, 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.
</p>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Weg</th>
<th className="pb-2 font-medium">Angelegt</th>
<th className="pb-2 font-medium">Zuletzt da</th>
<th className="pb-2 text-right font-medium">Sitzungen</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{items.map((u) => (
<tr key={u.id} className="border-t border-rand">
<td className="py-2">
<span className="flex items-center gap-2">
<Avatar url={u.avatar} name={u.username} groesse={22} />
<span className="font-medium">{u.username}</span>
{u.id === ich && (
<span className="rounded bg-akzent/15 px-1.5 py-0.5 text-[11px] text-akzent">
das bist du
</span>
)}
</span>
</td>
<td className="py-2">
{u.weg === 'discord' ? (
<span className="flex items-center gap-1 text-xs text-schrift-leise">
<UserRound size={12} /> Discord
</span>
) : (
<span className="flex items-center gap-1 text-xs text-schrift-leise">
<KeyRound size={12} /> Passwort
</span>
)}
</td>
<td className="py-2 text-schrift-leise">{zeit(u.created_at)}</td>
<td className="py-2 text-schrift-leise">
{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.
<span title="Gezählt wird erst seit dem Einbau dieser Spalte.">
</span>
)}
</td>
<td className="py-2 text-right tabular-nums text-schrift-leise">
{u.sitzungen}
</td>
<td className="py-2 text-right">
<button
onClick={() => setWeg(u)}
disabled={u.id === ich || items.length <= 1}
title={
u.id === ich
? 'Das eigene Konto lässt sich hier nicht entfernen'
: items.length <= 1
? 'Das letzte Konto bleibt'
: 'Konto entfernen'
}
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht
disabled:cursor-not-allowed disabled:opacity-30
disabled:hover:bg-transparent disabled:hover:text-schrift-leise"
aria-label={`${u.username} entfernen`}
>
<Trash2 size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Nachfrage
offen={weg !== null}
titel="Konto entfernen?"
bestaetigung="Entfernen"
onNein={() => 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={
<p>
<code className="font-mono">{weg?.username}</code> kommt danach nicht mehr
herein, und die {weg?.sitzungen ?? 0} offenen Sitzungen enden sofort.
{weg?.weg === 'discord' && (
<>
{' '}
<strong className="text-schrift">Aber:</strong> 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.
</>
)}
</p>
}
/>
</Karte>
)
}
@@ -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.
+3
View File
@@ -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() {
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
<FreigabenKarte />
<BenutzerKarte />
<SicherungKarte />
<MeldungenKarte />
<DiscordKarte />
+2
View File
@@ -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<EventKind, { text: string; farbe: string; kind: typeof Uploa
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
}
/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich.
+16 -1
View File
@@ -14,6 +14,7 @@ export function Tokens() {
const [prefix, setPrefix] = useState('')
const [darfLoeschen, setDarfLoeschen] = useState(false)
const [maxMb, setMaxMb] = useState('')
const [quotaMb, setQuotaMb] = useState('')
const [gueltigTage, setGueltigTage] = useState('')
const [arten, setArten] = useState<string[]>([])
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)}
/>
<Feld
label="Kontingent insgesamt (MB, optional)"
type="number"
min={0}
step="1"
value={quotaMb}
placeholder="leer = unbegrenzt"
hinweis="Die Grenze links gilt je Datei. Diese hier gilt für alles zusammen — ohne sie kann ein Token die Platte füllen, es braucht nur genug kleine Dateien."
onChange={(e) => setQuotaMb(e.target.value)}
/>
<Feld
label="Gültig für (Tage, optional)"
type="number"
@@ -255,7 +267,10 @@ export function Tokens() {
</td>
<td className="py-2 text-xs text-schrift-leise">
{[
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)}`
+5
View File
@@ -4,6 +4,7 @@ import {
ChevronRight,
History,
Loader2,
PenLine,
RefreshCw,
Search,
FolderInput,
@@ -26,6 +27,10 @@ const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Uploa
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
// Eigene Art, obwohl es technisch ein Verschieben ist: verschoben heisst
// „liegt woanders", umbenannt heisst „heisst anders, liegt noch da". Wer
// den Verlauf aufschlägt, will genau diesen Unterschied wissen.
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
}
export function Verlauf() {