feat: Token-Grenzen, Speicher nach Art, Aufbewahrungsbericht, Discord-Meldungen

KEINE TOKEN-ARTEN, SONDERN TOKEN-GRENZEN. Fivemanage hat Token-Typen; wir haben
schon ein Rechtesystem, es steht nur auf einer anderen Achse (Praefix und
Loeschrecht). Was wirklich fehlte, sind die drei Schrauben, die die Frage
beantworten "was passiert, wenn DIESER Token abhandenkommt":

  Groessengrenze   ein Token fuer Fahrzeugbilder braucht keine 64 MB
  erlaubte Arten   nur Bilder, kein Video
  Ablaufdatum      fuer eine Anbindung, die man mal ausprobiert

Alle drei am laufenden Dienst durchgemessen: 600 KB gegen 512 KB -> 413 mit
beiden Zahlen; ein mp4 gegen einen Bild-Token -> 403 mit Art UND MIME im Text;
ausserhalb des Praefix -> 403; abgelaufen -> 401 mit DEM DATUM. "Abgelaufen"
und "unbekannt" auseinanderzuhalten spart eine halbe Stunde Suche nach einem
Tippfehler, den es nicht gibt.

Mehr als die Dienstgrenze zu erlauben wird abgelehnt statt still gekappt -- es
waere eine Behauptung, denn durchgelassen wird ohnehin die schaerfere.

SPEICHER NACH ART: ein Balken fuer die Verhaeltnisse, eine Tabelle fuer die
Zahlen, dazu die groessten Dateien.

AUFBEWAHRUNG VORBEREITET, NICHT GEBAUT. Automatisches Loeschen zu bauen, bevor
man weiss, was da liegt, ist der Weg, wie man Daten verliert. Stattdessen der
Bericht davor: wie viel ist aelter als 7/30/90/365 Tage, was liegt doppelt
(gleicher SHA-256 unter zwei Pfaden) und wie viel Platz das kostet. "Alles
aelter als 90 Tage loeschen" ist eine Behauptung, solange niemand weiss, wie
viel das waere. Diese Seite loescht nichts.

DISCORD-MELDUNGEN -- und der Grund ist nicht Discord, sondern eine Luecke, die
wir heute selbst gebaut haben: die Sicherung laeuft nachts um halb fuenf, und
wenn sie aufhoert zu laufen, merkt es niemand. Der Dienst sieht jetzt
STUENDLICH nach und meldet, wenn der letzte Lauf gescheitert oder aelter als 26
Stunden ist (24 waeren zu knapp, 48 zu spaet). Hoechstens EINMAL je Zustand --
eine Meldung, die stuendlich wiederkommt, wird weggeklickt, und dann auch die,
die zaehlt.

Ein Webhook und keine Bot-Anbindung: eine Adresse, kein Token, keine
Berechtigungen. Die Adresse wird geprueft (nur echte discord.com-Webhooks),
und der Testknopf steht gleich daneben -- ein Webhook, den man eintraegt und
erst in drei Wochen im Fehlerfall ausprobiert, ist einer, der dann nicht geht.

Aus deren Doku mitgenommen und NICHT gebaut: presigned URLs (kurzlebige
signierte Adressen fuer Client-Uploads). Waere der richtige Weg fuer
Spieler-Screenshots -- solange keine Spieler hochladen, ist es Vorrat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 20:30:03 +02:00
co-authored by Claude Opus 5
parent 37bf6a68ed
commit c9cd8eaa84
11 changed files with 851 additions and 21 deletions
+19
View File
@@ -120,6 +120,18 @@ CREATE TABLE IF NOT EXISTS settings (
// und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe // und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe
// avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des // avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des
// Schemas eine Baustelle in jeder Zeile. // Schemas eine Baustelle in jeder Zeile.
// Grenzen je Token. Sie beantworten die Frage, die "Token-Arten" nur
// scheinbar beantwortet: was passiert, wenn DIESER Token abhandenkommt.
const tSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[]
if (!tSpalten.some((s) => s.name === 'max_bytes')) {
db.exec('ALTER TABLE tokens ADD COLUMN max_bytes INTEGER')
// Komma-getrennte Artnamen ('bild,video'). Leer heisst: alles, was der
// Dienst kennt.
db.exec("ALTER TABLE tokens ADD COLUMN arten TEXT NOT NULL DEFAULT ''")
db.exec('ALTER TABLE tokens ADD COLUMN expires_at INTEGER')
console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt')
}
const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[] const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[]
if (!eSpalten.some((s) => s.name === 'von')) { if (!eSpalten.some((s) => s.name === 'von')) {
// Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese // Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese
@@ -206,6 +218,13 @@ export type Token = {
can_delete: number can_delete: number
created_at: number created_at: number
last_used_at: number | null last_used_at: number | null
/** Groesste erlaubte Datei fuer DIESEN Token. Null heisst: die Grenze des
* Dienstes gilt (MAX_UPLOAD_MB). */
max_bytes: number | null
/** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */
arten: string
/** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */
expires_at: number | null
} }
export type Media = { export type Media = {
+9
View File
@@ -29,6 +29,7 @@ import { logger } from 'hono/logger'
import { config } from './config.js' import { config } from './config.js'
import { ensureAdmin } from './auth.js' import { ensureAdmin } from './auth.js'
import { pruneSessions } from './db.js' import { pruneSessions } from './db.js'
import { pruefeSicherung } from './meldung.js'
import { absolutePath, checkPath, mimeFor, thumbPath } from './storage.js' import { absolutePath, checkPath, mimeFor, thumbPath } from './storage.js'
import { uploadRoutes } from './routes/upload.js' import { uploadRoutes } from './routes/upload.js'
import { dashRoutes } from './routes/dash.js' import { dashRoutes } from './routes/dash.js'
@@ -296,6 +297,14 @@ ensureAdmin()
pruneSessions() pruneSessions()
setInterval(pruneSessions, 3600_000).unref() setInterval(pruneSessions, 3600_000).unref()
// Der Wachhund fuer die Sicherung. Beim Start mit Verzoegerung (der Dienst
// soll erst stehen) und danach stuendlich.
//
// DAS IST DER PUNKT DER GANZEN MELDUNGSFUNKTION: eine Sicherung, die aufhoert
// zu laufen, faellt sonst erst auf, wenn man sie braucht.
setTimeout(() => void pruefeSicherung(), 60_000).unref()
setInterval(() => void pruefeSicherung(), 3600_000).unref()
serve({ fetch: app.fetch, port: config.port }, (info) => { serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`[d4rk_media] Port ${info.port}`) console.log(`[d4rk_media] Port ${info.port}`)
console.log( console.log(
+128
View File
@@ -0,0 +1,128 @@
// Meldungen nach Discord.
//
// WOZU. Die Sicherung laeuft nachts um halb fuenf, und wenn sie aufhoert zu
// laufen, merkt es niemand -- das Panel zeigt es nur dem, der hinschaut. Genau
// dafuer ist das hier: nicht fuer Jubelmeldungen, sondern fuer die zwei, drei
// Faelle, in denen jemand etwas tun muss.
//
// Ein WEBHOOK und keine Bot-Anbindung: eine Adresse, kein Token, keine
// Berechtigungen, kein zweites Geheimnis, das ablaeuft. Wer ihn zurueckziehen
// will, loescht ihn in Discord.
//
// Der Dienst schickt HOECHSTENS EINMAL je Anlass. Eine Meldung, die stuendlich
// wiederkommt, wird nach dem dritten Mal weggeklickt -- und dann auch die, die
// zaehlt.
import { getSetting, setSetting } from './settings.js'
import { now } from './db.js'
export const MELDUNG_KEYS = {
webhook: 'discord.webhook',
/** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste'. */
anlaesse: 'discord.anlaesse',
/** Womit zuletzt gemeldet wurde -- gegen Wiederholungen. */
zuletzt: 'discord.zuletzt',
} as const
export type MeldungConfig = {
hatWebhook: boolean
anlaesse: string[]
}
export function meldungConfig(): MeldungConfig {
const roh = getSetting(MELDUNG_KEYS.anlaesse)
return {
hatWebhook: Boolean(getSetting(MELDUNG_KEYS.webhook)),
// Ohne Eintrag gilt: alles, was es gibt. Wer einen Webhook eintraegt,
// will Meldungen -- sonst haette er es gelassen.
anlaesse: roh ? roh.split(',').map((a) => a.trim()).filter(Boolean) : ['sicherung', 'verwaiste'],
}
}
/** Eine Nachricht schicken. Wirft nie.
*
* `einmalig` ist der Schluessel gegen Wiederholungen: dieselbe Zeichenkette
* wird nur einmal gemeldet, bis sich der Zustand aendert. */
export async function melde(
text: string,
opt: { anlass?: string; einmalig?: string; farbe?: number } = {},
): Promise<boolean> {
try {
const webhook = getSetting(MELDUNG_KEYS.webhook)
if (!webhook) return false
if (opt.anlass && !meldungConfig().anlaesse.includes(opt.anlass)) return false
if (opt.einmalig) {
const zuletzt = getSetting(MELDUNG_KEYS.zuletzt)
if (zuletzt === opt.einmalig) return false
setSetting(MELDUNG_KEYS.zuletzt, opt.einmalig)
}
const antwort = await fetch(webhook, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: 'd4rk_media',
embeds: [
{
description: text,
color: opt.farbe ?? 0x4ea3ff,
timestamp: new Date(now()).toISOString(),
},
],
}),
})
// Discord antwortet auf einen Webhook mit 204.
if (!antwort.ok) {
console.error('[discord] Webhook antwortete mit', antwort.status)
return false
}
return true
} catch (err) {
// Eine Meldung, die den Dienst mitreisst, waere die Buchhaltung, die
// das Geschaeft verhindert.
console.error('[discord] Meldung fehlgeschlagen:', err)
return false
}
}
/** Wacht ueber die Sicherung.
*
* Der eigentliche Punkt der ganzen Datei. Geprueft wird stuendlich, gemeldet
* hoechstens einmal je Zustand:
*
* - der letzte Lauf ist gescheitert
* - der letzte Lauf ist laenger als 26 Stunden her (taeglich um 04:30 plus
* Luft; 24 waeren zu knapp, 48 zu spaet)
* - es gab noch nie einen
*/
export async function pruefeSicherung(): Promise<void> {
const roh = getSetting('sicherung.letzte')
if (!roh) {
await melde(
'⚠️ Es wurde noch **nie** eine Sicherung gemeldet. Läuft die geplante Aufgabe?',
{ anlass: 'sicherung', einmalig: 'nie', farbe: 0xd29922 },
)
return
}
try {
const letzte = JSON.parse(roh) as { at: number; ok: boolean; fehler?: string }
const stunden = (now() - letzte.at) / 3_600_000
if (!letzte.ok) {
await melde(
`🔴 Die letzte Sicherung ist **fehlgeschlagen**.\n${letzte.fehler ?? 'kein Grund gemeldet'}`,
{ anlass: 'sicherung', einmalig: `fehler:${letzte.at}`, farbe: 0xf85149 },
)
} else if (stunden > 26) {
await melde(
`⚠️ Die letzte Sicherung ist **${Math.floor(stunden)} Stunden** her. Läuft die geplante Aufgabe noch?`,
{ anlass: 'sicherung', einmalig: `alt:${letzte.at}`, farbe: 0xd29922 },
)
}
} catch {
/* kaputter Eintrag -- beim naechsten Lauf wieder */
}
}
+173 -5
View File
@@ -46,6 +46,7 @@ import {
type Vorlage, type Vorlage,
} from '../settings.js' } from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js' import { discordRoutes, redirectUri } from './discord.js'
import { MELDUNG_KEYS, melde, meldungConfig } from '../meldung.js'
import { import {
absolutePath, absolutePath,
checkPath, checkPath,
@@ -805,6 +806,7 @@ dashRoutes.get('/settings', (c) => {
}, },
}, },
vorlagen: vorlagen(), vorlagen: vorlagen(),
meldungen: meldungConfig(),
sicherung: (() => { sicherung: (() => {
const sc = sicherungConfig() const sc = sicherungConfig()
let letzte: unknown = null let letzte: unknown = null
@@ -830,6 +832,7 @@ dashRoutes.post('/settings', async (c) => {
roleIds?: string | string[] roleIds?: string | string[]
} }
vorlagen?: { name?: string; muster?: string }[] vorlagen?: { name?: string; muster?: string }[]
meldungen?: { webhook?: string; anlaesse?: string[] }
sicherung?: { sicherung?: {
basis?: string basis?: string
benutzer?: string benutzer?: string
@@ -840,6 +843,37 @@ dashRoutes.post('/settings', async (c) => {
}>(c) }>(c)
if (!body) return c.json(KEIN_JSON, 400) if (!body) return c.json(KEIN_JSON, 400)
// -- Meldungen ----------------------------------------------------------
if (body.meldungen) {
const m = body.meldungen
if (m.webhook !== undefined) {
const v = m.webhook.trim()
if (v === '-') setSetting(MELDUNG_KEYS.webhook, '')
else if (v) {
// Nur echte Discord-Webhooks. Wer hier versehentlich eine
// andere Adresse eintraegt, schickt seine Meldungen sonst
// irgendwohin.
if (!/^https:\/\/(canary\.|ptb\.)?discord(app)?\.com\/api\/webhooks\//.test(v)) {
return c.json(
{ error: 'Das sieht nicht nach einer Discord-Webhook-Adresse aus.' },
400,
)
}
setSetting(MELDUNG_KEYS.webhook, v)
}
}
if (Array.isArray(m.anlaesse)) {
const erlaubt = ['sicherung', 'verwaiste']
setSetting(
MELDUNG_KEYS.anlaesse,
m.anlaesse.filter((a) => erlaubt.includes(a)).join(','),
)
}
if (!body.discord && !body.sicherung && !body.vorlagen) {
return c.json({ ok: true, meldungen: meldungConfig() })
}
}
// -- Adressvorlagen ----------------------------------------------------- // -- Adressvorlagen -----------------------------------------------------
if (body.vorlagen) { if (body.vorlagen) {
if (!Array.isArray(body.vorlagen)) { if (!Array.isArray(body.vorlagen)) {
@@ -1293,6 +1327,54 @@ dashRoutes.get('/export', (c) => {
}) })
}) })
/** Speicher nach Medienart, groesste Dateien, und was fuer eine spaetere
* Aufbewahrungsregel wichtig waere.
*
* ERST SEHEN, DANN ENTSCHEIDEN. Automatisches Loeschen zu bauen, bevor man
* weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route
* loescht nichts -- sie zeigt.
*/
dashRoutes.get('/speicher', (c) => {
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
const z = db
.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM media WHERE ${bedingung}`)
.get() as { n: number; bytes: number }
return { art: name, files: z.n, bytes: z.bytes }
})
const groesste = (
db.prepare('SELECT * FROM media ORDER BY size DESC LIMIT 10').all() as Media[]
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
const aelteste = (
db.prepare('SELECT * FROM media ORDER BY created_at ASC LIMIT 10').all() as Media[]
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
// Derselbe Inhalt unter zwei Pfaden. Kein Fehler -- aber jedes Doppel ist
// Platz, den niemand gewaehlt hat.
const doppelte = db
.prepare(
`SELECT sha256, COUNT(*) AS n, SUM(size) AS bytes,
GROUP_CONCAT(path, '|') AS pfade
FROM media GROUP BY sha256 HAVING n > 1 ORDER BY bytes DESC LIMIT 20`,
)
.all() as { sha256: string; n: number; bytes: number; pfade: string }[]
const verschwendet = doppelte.reduce((s, d) => s + (d.bytes / d.n) * (d.n - 1), 0)
// Wie alt der Bestand ist, in Stufen. Das ist die Zahl, die eine
// Aufbewahrungsregel braucht: "aelter als 90 Tage" ist eine Behauptung,
// solange niemand weiss, wie viel das waere.
const stufen = [7, 30, 90, 365].map((tage) => {
const z = db
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE created_at < ?')
.get(now() - tage * 86_400_000) as { n: number; bytes: number }
return { tage, files: z.n, bytes: z.bytes }
})
return c.json({ nachArt, groesste, aelteste, doppelte, verschwendet, stufen })
})
dashRoutes.get('/stats', (c) => { dashRoutes.get('/stats', (c) => {
const media = db const media = db
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
@@ -1319,9 +1401,21 @@ dashRoutes.get('/stats', (c) => {
dashRoutes.get('/tokens', (c) => { dashRoutes.get('/tokens', (c) => {
const rows = db const rows = db
.prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC') .prepare(
`SELECT id, name, prefix, can_delete, created_at, last_used_at,
max_bytes, arten, expires_at
FROM tokens ORDER BY created_at DESC`,
)
.all() as Omit<Token, 'hash'>[] .all() as Omit<Token, 'hash'>[]
return c.json({ items: rows }) return c.json({
items: rows.map((r) => ({
...r,
// Abgelaufen ist eine Eigenschaft, keine Rechnung fuer die
// Oberflaeche: sonst steht in zwei Ansichten zweimal dieselbe
// Bedingung, und eine davon ist irgendwann falsch.
abgelaufen: Boolean(r.expires_at && r.expires_at < now()),
})),
})
}) })
dashRoutes.post('/tokens', async (c) => { dashRoutes.post('/tokens', async (c) => {
@@ -1329,12 +1423,55 @@ dashRoutes.post('/tokens', async (c) => {
name?: string name?: string
prefix?: string prefix?: string
canDelete?: boolean canDelete?: boolean
maxMb?: number | string
arten?: string[]
gueltigTage?: number | string
}>(c) }>(c)
if (!body) return c.json(KEIN_JSON, 400) if (!body) return c.json(KEIN_JSON, 400)
const { name, prefix, canDelete } = body const { name, prefix, canDelete } = body
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400) if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
// -- Die drei Grenzen --------------------------------------------------
let maxBytes: number | null = null
if (body.maxMb !== undefined && String(body.maxMb).trim() !== '') {
const mb = Number(body.maxMb)
if (!Number.isFinite(mb) || mb <= 0) {
return c.json({ error: 'Groessengrenze: eine Zahl groesser als 0' }, 400)
}
// Groesser als die Grenze des Dienstes zu erlauben waere eine
// Behauptung: durchgelassen wird ohnehin die schaerfere.
if (mb * 1024 * 1024 > config.maxUploadBytes) {
return c.json(
{
error: `Der Dienst nimmt hoechstens ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB — mehr kann auch dieser Token nicht.`,
},
400,
)
}
maxBytes = Math.round(mb * 1024 * 1024)
}
const ERLAUBTE_ARTEN = ['bild', 'video', 'ton', 'dokument', 'andere']
let arten = ''
if (Array.isArray(body.arten) && body.arten.length > 0) {
const falsch = body.arten.filter((a) => !ERLAUBTE_ARTEN.includes(a))
if (falsch.length) return c.json({ error: `Unbekannte Art: ${falsch.join(', ')}` }, 400)
// Alle fuenf gewaehlt heisst dasselbe wie keine Einschraenkung -- dann
// steht in der Datenbank auch nichts, sonst muesste jede spaetere
// Erweiterung der Artenliste alte Token nachziehen.
arten = body.arten.length === ERLAUBTE_ARTEN.length ? '' : body.arten.join(',')
}
let expiresAt: number | null = null
if (body.gueltigTage !== undefined && String(body.gueltigTage).trim() !== '') {
const tage = Number(body.gueltigTage)
if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) {
return c.json({ error: 'Gueltigkeit: 1 bis 3650 Tage' }, 400)
}
expiresAt = now() + Math.round(tage) * 86_400_000
}
// Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft. // Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft.
let cleanPrefix = '' let cleanPrefix = ''
if (prefix?.trim()) { if (prefix?.trim()) {
@@ -1349,10 +1486,20 @@ dashRoutes.post('/tokens', async (c) => {
const token = generateToken() const token = generateToken()
const info = db const info = db
.prepare( .prepare(
`INSERT INTO tokens (name, hash, prefix, can_delete, created_at) `INSERT INTO tokens (name, hash, prefix, can_delete, created_at,
VALUES (?, ?, ?, ?, ?)`, max_bytes, arten, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
name.trim(),
tokenHash(token),
cleanPrefix,
canDelete ? 1 : 0,
now(),
maxBytes,
arten,
expiresAt,
) )
.run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now())
// Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in // Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in
// der Datenbank, und auch wir koennen ihn nicht mehr zeigen. // der Datenbank, und auch wir koennen ihn nicht mehr zeigen.
@@ -1361,6 +1508,9 @@ dashRoutes.post('/tokens', async (c) => {
name: name.trim(), name: name.trim(),
prefix: cleanPrefix, prefix: cleanPrefix,
canDelete: Boolean(canDelete), canDelete: Boolean(canDelete),
maxBytes,
arten,
expiresAt,
token, token,
}) })
}) })
@@ -1371,6 +1521,24 @@ dashRoutes.delete('/tokens/:id', (c) => {
return c.json({ ok: true }) return c.json({ ok: true })
}) })
/** Eine Testnachricht schicken.
*
* Ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall
* ausprobiert, ist ein Webhook, der dann nicht funktioniert. */
dashRoutes.post('/maintenance/melden', async (c) => {
const ok = await melde(
`✅ Testnachricht von d4rk_media — ausgelöst von **${c.get('user').username}**.`,
{ farbe: 0x3fb950 },
)
if (!ok) {
return c.json(
{ error: 'Nichts geschickt. Ist ein Webhook eingetragen, und stimmt die Adresse?' },
400,
)
}
return c.json({ ok: true })
})
dashRoutes.post('/maintenance/prune-sessions', (c) => { dashRoutes.post('/maintenance/prune-sessions', (c) => {
pruneSessions() pruneSessions()
pruneEvents() pruneEvents()
+45 -10
View File
@@ -52,10 +52,34 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
if (!token) { if (!token) {
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401) return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
} }
// Abgelaufen ist etwas anderes als unbekannt, und die Meldung sagt es:
// sonst sucht jemand eine halbe Stunde nach einem Tippfehler im Token.
if (token.expires_at && token.expires_at < now()) {
return c.json(
{
error: `Dieser Token ist am ${new Date(token.expires_at).toLocaleDateString('de-DE')} abgelaufen.`,
},
401,
)
}
c.set('token', token) c.set('token', token)
await next() await next()
} }
/** Welche Medienart ist das? Dieselben Namen wie im Dashboard.
*
* An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der
* Galerie nicht auseinanderlaufen koennen. */
function artVon(mime: string): string {
const m = mime.split(';')[0]?.trim().toLowerCase() ?? ''
if (m.startsWith('image/')) return 'bild'
if (m.startsWith('video/')) return 'video'
if (m.startsWith('audio/')) return 'ton'
if (m === 'application/pdf' || m.startsWith('text/') || m.startsWith('application/json'))
return 'dokument'
return 'andere'
}
uploadRoutes.use('/upload', requireToken) uploadRoutes.use('/upload', requireToken)
uploadRoutes.use('/media/*', requireToken) uploadRoutes.use('/media/*', requireToken)
uploadRoutes.use('/exists/*', requireToken) uploadRoutes.use('/exists/*', requireToken)
@@ -96,12 +120,13 @@ uploadRoutes.post('/upload', async (c) => {
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload // Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
// abgewiesen wird, BEVOR er im Speicher liegt. // abgewiesen wird, BEVOR er im Speicher liegt.
// Die schaerfere der beiden Grenzen gilt: die des Dienstes und die dieses
// Tokens. Ein Token fuer Fahrzeugbilder braucht keine 64 MB.
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
const announced = Number(c.req.header('content-length') ?? 0) const announced = Number(c.req.header('content-length') ?? 0)
if (announced > config.maxUploadBytes) { if (announced > grenze) {
return c.json( return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413)
{ error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` },
413,
)
} }
let body: Awaited<ReturnType<typeof readBody>> let body: Awaited<ReturnType<typeof readBody>>
@@ -113,11 +138,8 @@ uploadRoutes.post('/upload', async (c) => {
const { data } = body const { data } = body
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400) if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
if (data.length > config.maxUploadBytes) { if (data.length > grenze) {
return c.json( return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413)
{ error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` },
413,
)
} }
const digest = sha256(data) const digest = sha256(data)
@@ -160,6 +182,19 @@ uploadRoutes.post('/upload', async (c) => {
const mime = c.req.header('x-mime') ?? mimeFor(path) const mime = c.req.header('x-mime') ?? mimeFor(path)
if (token.arten) {
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
const art = artVon(mime)
if (!erlaubt.includes(art)) {
return c.json(
{
error: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen — das hier ist "${art}" (${mime}).`,
},
403,
)
}
}
try { try {
await writeFileAtomic(path, data) await writeFileAtomic(path, data)
} catch (err) { } catch (err) {
+31 -2
View File
@@ -127,6 +127,15 @@ export type Stats = {
publicUrl: string publicUrl: string
} }
export type Speicher = {
nachArt: { art: string; files: number; bytes: number }[]
groesste: MediaItem[]
aelteste: MediaItem[]
doppelte: { sha256: string; n: number; bytes: number; pfade: string }[]
verschwendet: number
stufen: { tage: number; files: number; bytes: number }[]
}
export type TokenRow = { export type TokenRow = {
id: number id: number
name: string name: string
@@ -134,6 +143,12 @@ export type TokenRow = {
can_delete: number can_delete: number
created_at: number created_at: number
last_used_at: number | null last_used_at: number | null
/** Groesste erlaubte Datei. Null = die Grenze des Dienstes. */
max_bytes: number | null
/** Erlaubte Arten, mit Komma. Leer = alle. */
arten: string
expires_at: number | null
abgelaufen: boolean
} }
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der /** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
@@ -207,6 +222,7 @@ export type LetzteSicherung = {
export type Einstellungen = { export type Einstellungen = {
vorlagen: Vorlage[] vorlagen: Vorlage[]
meldungen: { hatWebhook: boolean; anlaesse: string[] }
sicherung: { sicherung: {
basis: string basis: string
benutzer: string benutzer: string
@@ -350,6 +366,12 @@ export const api = {
einstellungen: () => get<Einstellungen>('/api/dash/settings'), einstellungen: () => get<Einstellungen>('/api/dash/settings'),
sicherungSpeichern: (sicherung: Record<string, string | number>) => sicherungSpeichern: (sicherung: Record<string, string | number>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }), post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }),
meldungenSpeichern: (meldungen: { webhook?: string; anlaesse?: string[] }) =>
post<{ ok: true; meldungen: { hatWebhook: boolean; anlaesse: string[] } }>(
'/api/dash/settings',
{ meldungen },
),
meldenTesten: () => post<{ ok: true }>('/api/dash/maintenance/melden'),
vorlagenSpeichern: (liste: Vorlage[]) => vorlagenSpeichern: (liste: Vorlage[]) =>
post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }), post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }),
einstellungenSpeichern: (discord: Record<string, string>) => einstellungenSpeichern: (discord: Record<string, string>) =>
@@ -357,6 +379,7 @@ export const api = {
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'), uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
stand: () => get<Stand>('/api/dash/stand'), stand: () => get<Stand>('/api/dash/stand'),
speicher: () => get<Speicher>('/api/dash/speicher'),
stats: () => get<Stats>('/api/dash/stats'), stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'), thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'), verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
@@ -367,7 +390,13 @@ export const api = {
), ),
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'), tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
createToken: (name: string, prefix: string, canDelete: boolean) => createToken: (daten: {
post<NewToken>('/api/dash/tokens', { name, prefix, canDelete }), name: string
prefix: string
canDelete: boolean
maxMb?: string
arten?: string[]
gueltigTage?: string
}) => post<NewToken>('/api/dash/tokens', daten),
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`), deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
} }
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import { Bell, BellOff, Send } from 'lucide-react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from './Bausteine'
const ANLAESSE = [
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
['verwaiste', 'Verwaiste Dateien gefunden'],
] as const
/** Meldungen nach Discord.
*
* Der Grund ist nicht Discord, sondern eine Lücke: die Sicherung läuft nachts
* um halb fünf, und wenn sie aufhört zu laufen, merkt es niemand — das Panel
* zeigt es nur dem, der hinschaut. Ein Webhook schließt genau das.
*
* Deshalb steht der Testknopf gleich daneben. Ein Webhook, den man einträgt
* und erst in drei Wochen im Fehlerfall ausprobiert, ist ein Webhook, der dann
* nicht funktioniert. */
export function MeldungenKarte() {
const note = useStore((s) => s.note)
const [hatWebhook, setHatWebhook] = useState(false)
const [anlaesse, setAnlaesse] = useState<string[]>([])
const [webhook, setWebhook] = useState('')
const [speichert, setSpeichert] = useState(false)
const [testet, setTestet] = useState(false)
async function laden() {
try {
const e = await api.einstellungen()
setHatWebhook(e.meldungen.hatWebhook)
setAnlaesse(e.meldungen.anlaesse)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Karte
titel="Meldungen nach Discord"
rechts={
hatWebhook ? (
<span className="flex items-center gap-1.5 text-xs text-gut">
<Bell size={14} /> eingerichtet
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
<BellOff size={14} /> aus
</span>
)
}
>
<div className="flex flex-col gap-4">
<p className="text-xs text-schrift-leise">
Nicht für Jubelmeldungen, sondern für die zwei Fälle, in denen jemand etwas
tun muss. Der wichtigste: <strong>die Sicherung läuft nachts und niemand
merkt, wenn sie aufhört.</strong> Der Dienst sieht stündlich nach und meldet
höchstens einmal je Anlass.
</p>
<Feld
label="Webhook-Adresse"
type="password"
value={webhook}
placeholder={
hatWebhook ? '•••••••• (hinterlegt)' : 'https://discord.com/api/webhooks/…'
}
hinweis={
'In Discord: Serverkanal → Bearbeiten → Integrationen → Webhooks. ' +
'Leer lassen heißt unverändert, ein „-" entfernt ihn.'
}
onChange={(e) => setWebhook(e.target.value)}
/>
<div>
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Wann gemeldet wird
</span>
<div className="flex flex-col gap-1">
{ANLAESSE.map(([wert, text]) => (
<label key={wert} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={anlaesse.includes(wert)}
onChange={(e) =>
setAnlaesse((a) =>
e.target.checked
? [...a, wert]
: a.filter((x) => x !== wert),
)
}
className="accent-akzent"
/>
{text}
</label>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Knopf
art="haupt"
wartet={speichert}
onClick={async () => {
setSpeichert(true)
const r = await run(
() =>
api.meldungenSpeichern({
...(webhook ? { webhook } : {}),
anlaesse,
}),
(x) =>
x.meldungen.hatWebhook
? 'Gespeichert. Jetzt den Testknopf drücken.'
: 'Gespeichert — ohne Webhook wird nichts gemeldet.',
)
setSpeichert(false)
if (r) {
setWebhook('')
void laden()
}
}}
kind="Speichern"
/>
<Knopf
wartet={testet}
disabled={!hatWebhook}
onClick={async () => {
setTestet(true)
await run(
() => api.meldenTesten(),
'Testnachricht geschickt — steht sie in Discord?',
)
setTestet(false)
}}
kind={
<>
<Send size={15} />
Testnachricht
</>
}
/>
</div>
</div>
</Karte>
)
}
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react'
import { FileQuestion, FileText, Film, Image, Loader2, Music } from 'lucide-react'
import { api, type Speicher } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Leer } from './Bausteine'
const ARTEN: Record<string, { text: string; kind: typeof Image; farbe: string }> = {
bild: { text: 'Bilder', kind: Image, farbe: 'bg-akzent' },
video: { text: 'Video', kind: Film, farbe: 'bg-warn' },
ton: { text: 'Ton', kind: Music, farbe: 'bg-gut' },
dokument: { text: 'Dokumente', kind: FileText, farbe: 'bg-schrift-leise' },
andere: { text: 'Anderes', kind: FileQuestion, farbe: 'bg-schlecht' },
}
/** Was liegt da eigentlich — nach Art, nach Größe, nach Alter.
*
* ERST SEHEN, DANN ENTSCHEIDEN. Eine Aufbewahrungsregel („alles älter als 90
* Tage weg") ist eine Behauptung, solange niemand weiß, wie viel das wäre.
* Diese Karten löschen nichts; sie liefern die Zahlen, mit denen man
* entscheiden kann. */
export function SpeicherKarten() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<Speicher | null>(null)
useEffect(() => {
api.speicher()
.then(setDaten)
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
}, [note])
if (!daten) {
return (
<Karte titel="Nach Medienart">
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Karte>
)
}
const gesamt = daten.nachArt.reduce((s, a) => s + a.bytes, 0) || 1
return (
<>
<Karte titel="Nach Medienart">
<div className="flex flex-col gap-2">
{/* Ein Balken für alles, damit die Verhältnisse auf einen
Blick stimmen — Zahlen untereinander liest man, Anteile
sieht man. */}
<div className="flex h-2.5 overflow-hidden rounded-full bg-grund">
{daten.nachArt
.filter((a) => a.bytes > 0)
.map((a) => (
<div
key={a.art}
className={ARTEN[a.art]?.farbe ?? 'bg-rand'}
style={{ width: `${(a.bytes / gesamt) * 100}%` }}
title={`${ARTEN[a.art]?.text}: ${bytes(a.bytes)}`}
/>
))}
</div>
<table className="w-full text-sm">
<tbody>
{daten.nachArt.map((a) => {
const g = ARTEN[a.art]
const Kind = g?.kind ?? FileQuestion
return (
<tr
key={a.art}
className={`border-t border-rand first:border-0 ${
a.files === 0 ? 'text-schrift-leise/50' : ''
}`}
>
<td className="py-1.5">
<span className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${g?.farbe ?? 'bg-rand'}`}
/>
<Kind size={14} />
{g?.text ?? a.art}
</span>
</td>
<td className="py-1.5 text-right tabular-nums">
{a.files}
</td>
<td className="w-24 py-1.5 text-right tabular-nums">
{bytes(a.bytes)}
</td>
<td className="w-16 py-1.5 text-right text-xs text-schrift-leise tabular-nums">
{gesamt > 1 ? `${Math.round((a.bytes / gesamt) * 100)} %` : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Karte>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Die größten Dateien">
{daten.groesste.length === 0 ? (
<Leer text="Noch nichts da." />
) : (
<ul className="flex flex-col gap-1 text-sm">
{daten.groesste.map((m) => (
<li key={m.id} className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
title={m.path}
>
{m.path}
</span>
<span className="shrink-0 text-xs tabular-nums">
{bytes(m.size)}
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte titel="Doppelt abgelegt">
{daten.doppelte.length === 0 ? (
<p className="py-2 text-sm text-schrift-leise">
Nichts doppelt jeder Inhalt liegt genau einmal.
</p>
) : (
<div className="flex flex-col gap-2">
<p className="text-xs text-schrift-leise">
Derselbe Inhalt unter mehreren Pfaden. Kein Fehler aber{' '}
<strong className="text-warn">{bytes(daten.verschwendet)}</strong>{' '}
Platz, den niemand gewählt hat.
</p>
<ul className="flex max-h-48 flex-col gap-1 overflow-auto text-xs">
{daten.doppelte.map((d) => (
<li key={d.sha256} className="rounded border border-rand p-1.5">
<span className="text-schrift-leise">
{d.n}× · {bytes(d.bytes / d.n)}
</span>
<span className="mt-0.5 block truncate font-mono">
{d.pfade.replaceAll('|', ' · ')}
</span>
</li>
))}
</ul>
</div>
)}
</Karte>
</div>
<Karte titel="Wie alt der Bestand ist">
<p className="mb-3 text-xs text-schrift-leise">
Die Zahlen, die eine Aufbewahrungsregel braucht. Alles älter als 90 Tage
löschen" ist eine Behauptung, solange niemand weiß, wie viel das wäre —
hier steht es. <strong>Gelöscht wird hier nichts.</strong>
</p>
<table className="w-full text-sm">
<tbody>
{daten.stufen.map((st) => (
<tr key={st.tage} className="border-t border-rand first:border-0">
<td className="py-1.5">älter als {st.tage} Tage</td>
<td className="py-1.5 text-right tabular-nums">
{st.files} Dateien
</td>
<td className="w-24 py-1.5 text-right tabular-nums">
{bytes(st.bytes)}
</td>
</tr>
))}
</tbody>
</table>
{daten.aelteste[0] && (
<p className="mt-3 text-xs text-schrift-leise">
Das älteste ist{' '}
<code className="font-mono">{daten.aelteste[0].path}</code> vom{' '}
{zeit(daten.aelteste[0].created_at)}.
</p>
)}
</Karte>
</>
)
}
+2
View File
@@ -6,6 +6,7 @@ import { bytes, zeit } from '../format'
import { Feld, Karte, Knopf } from '../components/Bausteine' import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte' import { DiscordKarte } from '../components/DiscordKarte'
import { VorlagenKarte } from '../components/VorlagenKarte' import { VorlagenKarte } from '../components/VorlagenKarte'
import { MeldungenKarte } from '../components/MeldungenKarte'
/** Alles, was sich einstellen lässt, an einer Stelle. /** Alles, was sich einstellen lässt, an einer Stelle.
* *
@@ -28,6 +29,7 @@ export function Einstellungen() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} /> <VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
<SicherungKarte /> <SicherungKarte />
<MeldungenKarte />
<DiscordKarte /> <DiscordKarte />
</div> </div>
) )
+3
View File
@@ -14,6 +14,7 @@ import { run, useStore } from '../store'
import { bytes } from '../format' import { bytes } from '../format'
import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { type Verwaiste } from '../api' import { type Verwaiste } from '../api'
import { SpeicherKarten } from '../components/SpeicherKarten'
export function Speicher() { export function Speicher() {
const note = useStore((s) => s.note) const note = useStore((s) => s.note)
@@ -59,6 +60,8 @@ export function Speicher() {
<Zahl kind={<KeyRound size={16} />} was="Token" wert={String(stats.tokens)} /> <Zahl kind={<KeyRound size={16} />} was="Token" wert={String(stats.tokens)} />
</div> </div>
<SpeicherKarten />
<Karte titel="Öffentliche Adresse"> <Karte titel="Öffentliche Adresse">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 truncate rounded border border-rand bg-grund px-3 py-1.5 font-mono text-sm"> <code className="flex-1 truncate rounded border border-rand bg-grund px-3 py-1.5 font-mono text-sm">
+103 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react'
import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react' import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
import { api, type NewToken, type TokenRow } from '../api' import { api, type NewToken, type TokenRow } from '../api'
import { run, useStore } from '../store' import { run, useStore } from '../store'
import { zeit } from '../format' import { bytes, zeit } from '../format'
import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
export function Tokens() { export function Tokens() {
@@ -13,6 +13,9 @@ export function Tokens() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [prefix, setPrefix] = useState('') const [prefix, setPrefix] = useState('')
const [darfLoeschen, setDarfLoeschen] = useState(false) const [darfLoeschen, setDarfLoeschen] = useState(false)
const [maxMb, setMaxMb] = useState('')
const [gueltigTage, setGueltigTage] = useState('')
const [arten, setArten] = useState<string[]>([])
const [legtAn, setLegtAn] = useState(false) const [legtAn, setLegtAn] = useState(false)
const [frisch, setFrisch] = useState<NewToken | null>(null) const [frisch, setFrisch] = useState<NewToken | null>(null)
@@ -41,7 +44,15 @@ export function Tokens() {
e.preventDefault() e.preventDefault()
setLegtAn(true) setLegtAn(true)
const neu = await run( const neu = await run(
() => api.createToken(name.trim(), prefix.trim(), darfLoeschen), () =>
api.createToken({
name: name.trim(),
prefix: prefix.trim(),
canDelete: darfLoeschen,
maxMb: maxMb.trim(),
arten,
gueltigTage: gueltigTage.trim(),
}),
(t) => `Token "${t.name}" angelegt.`, (t) => `Token "${t.name}" angelegt.`,
) )
setLegtAn(false) setLegtAn(false)
@@ -50,6 +61,9 @@ export function Tokens() {
setName('') setName('')
setPrefix('') setPrefix('')
setDarfLoeschen(false) setDarfLoeschen(false)
setMaxMb('')
setGueltigTage('')
setArten([])
void laden() void laden()
} }
} }
@@ -84,6 +98,69 @@ export function Tokens() {
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Größte Datei (MB, optional)"
type="number"
min={0}
step="0.5"
value={maxMb}
placeholder="leer = Grenze des Dienstes"
hinweis="Ein Token für Fahrzeugbilder braucht keine 64 MB."
onChange={(e) => setMaxMb(e.target.value)}
/>
<Feld
label="Gültig für (Tage, optional)"
type="number"
min={1}
value={gueltigTage}
placeholder="leer = unbefristet"
hinweis="Danach antwortet der Dienst mit einer Meldung, die das sagt."
onChange={(e) => setGueltigTage(e.target.value)}
/>
</div>
<div>
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Erlaubte Arten (optional)
</span>
<div className="flex flex-wrap gap-1.5">
{(
[
['bild', 'Bilder'],
['video', 'Video'],
['ton', 'Ton'],
['dokument', 'Dokumente'],
['andere', 'Anderes'],
] as const
).map(([wert, text]) => {
const an = arten.includes(wert)
return (
<button
key={wert}
type="button"
onClick={() =>
setArten((a) =>
an ? a.filter((x) => x !== wert) : [...a, wert],
)
}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
an
? 'border-akzent bg-akzent/15 text-schrift'
: 'border-rand text-schrift-leise hover:text-schrift'
}`}
>
{text}
</button>
)
})}
</div>
<span className="mt-1 block text-xs text-schrift-leise">
Nichts gewählt heißt: alles erlaubt. Ein Token, der nur Bilder darf,
kann kein Video hochladen auch wenn er abhandenkommt.
</span>
</div>
<label className="flex items-start gap-2 text-sm"> <label className="flex items-start gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
@@ -134,6 +211,7 @@ export function Tokens() {
<th className="pb-2 font-medium">Name</th> <th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Präfix</th> <th className="pb-2 font-medium">Präfix</th>
<th className="pb-2 font-medium">Rechte</th> <th className="pb-2 font-medium">Rechte</th>
<th className="pb-2 font-medium">Grenzen</th>
<th className="pb-2 font-medium">Angelegt</th> <th className="pb-2 font-medium">Angelegt</th>
<th className="pb-2 font-medium">Zuletzt benutzt</th> <th className="pb-2 font-medium">Zuletzt benutzt</th>
<th className="pb-2" /> <th className="pb-2" />
@@ -141,8 +219,18 @@ export function Tokens() {
</thead> </thead>
<tbody> <tbody>
{rows.map((t) => ( {rows.map((t) => (
<tr key={t.id} className="border-t border-rand"> <tr
<td className="py-2 font-medium">{t.name}</td> key={t.id}
className={`border-t border-rand ${t.abgelaufen ? 'opacity-50' : ''}`}
>
<td className="py-2 font-medium">
{t.name}
{t.abgelaufen && (
<span className="ml-2 rounded bg-schlecht/15 px-1.5 py-0.5 text-[11px] text-schlecht">
abgelaufen
</span>
)}
</td>
<td className="py-2 font-mono text-schrift-leise"> <td className="py-2 font-mono text-schrift-leise">
{t.prefix ? `${t.prefix}/` : '— (überall)'} {t.prefix ? `${t.prefix}/` : '— (überall)'}
</td> </td>
@@ -157,6 +245,17 @@ export function Tokens() {
</span> </span>
)} )}
</td> </td>
<td className="py-2 text-xs text-schrift-leise">
{[
t.max_bytes ? `max ${bytes(t.max_bytes)}` : null,
t.arten ? t.arten.replaceAll(',', ', ') : null,
t.expires_at
? `bis ${zeit(t.expires_at).slice(0, 10)}`
: null,
]
.filter(Boolean)
.join(' · ') || '—'}
</td>
<td className="py-2 text-schrift-leise"> <td className="py-2 text-schrift-leise">
{zeit(t.created_at)} {zeit(t.created_at)}
</td> </td>