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:
+173
-5
@@ -46,6 +46,7 @@ import {
|
||||
type Vorlage,
|
||||
} from '../settings.js'
|
||||
import { discordRoutes, redirectUri } from './discord.js'
|
||||
import { MELDUNG_KEYS, melde, meldungConfig } from '../meldung.js'
|
||||
import {
|
||||
absolutePath,
|
||||
checkPath,
|
||||
@@ -805,6 +806,7 @@ dashRoutes.get('/settings', (c) => {
|
||||
},
|
||||
},
|
||||
vorlagen: vorlagen(),
|
||||
meldungen: meldungConfig(),
|
||||
sicherung: (() => {
|
||||
const sc = sicherungConfig()
|
||||
let letzte: unknown = null
|
||||
@@ -830,6 +832,7 @@ dashRoutes.post('/settings', async (c) => {
|
||||
roleIds?: string | string[]
|
||||
}
|
||||
vorlagen?: { name?: string; muster?: string }[]
|
||||
meldungen?: { webhook?: string; anlaesse?: string[] }
|
||||
sicherung?: {
|
||||
basis?: string
|
||||
benutzer?: string
|
||||
@@ -840,6 +843,37 @@ dashRoutes.post('/settings', async (c) => {
|
||||
}>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
|
||||
// -- Meldungen ----------------------------------------------------------
|
||||
if (body.meldungen) {
|
||||
const m = body.meldungen
|
||||
if (m.webhook !== undefined) {
|
||||
const v = m.webhook.trim()
|
||||
if (v === '-') setSetting(MELDUNG_KEYS.webhook, '')
|
||||
else if (v) {
|
||||
// Nur echte Discord-Webhooks. Wer hier versehentlich eine
|
||||
// andere Adresse eintraegt, schickt seine Meldungen sonst
|
||||
// irgendwohin.
|
||||
if (!/^https:\/\/(canary\.|ptb\.)?discord(app)?\.com\/api\/webhooks\//.test(v)) {
|
||||
return c.json(
|
||||
{ error: 'Das sieht nicht nach einer Discord-Webhook-Adresse aus.' },
|
||||
400,
|
||||
)
|
||||
}
|
||||
setSetting(MELDUNG_KEYS.webhook, v)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(m.anlaesse)) {
|
||||
const erlaubt = ['sicherung', 'verwaiste']
|
||||
setSetting(
|
||||
MELDUNG_KEYS.anlaesse,
|
||||
m.anlaesse.filter((a) => erlaubt.includes(a)).join(','),
|
||||
)
|
||||
}
|
||||
if (!body.discord && !body.sicherung && !body.vorlagen) {
|
||||
return c.json({ ok: true, meldungen: meldungConfig() })
|
||||
}
|
||||
}
|
||||
|
||||
// -- Adressvorlagen -----------------------------------------------------
|
||||
if (body.vorlagen) {
|
||||
if (!Array.isArray(body.vorlagen)) {
|
||||
@@ -1293,6 +1327,54 @@ dashRoutes.get('/export', (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Speicher nach Medienart, groesste Dateien, und was fuer eine spaetere
|
||||
* Aufbewahrungsregel wichtig waere.
|
||||
*
|
||||
* ERST SEHEN, DANN ENTSCHEIDEN. Automatisches Loeschen zu bauen, bevor man
|
||||
* weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route
|
||||
* loescht nichts -- sie zeigt.
|
||||
*/
|
||||
dashRoutes.get('/speicher', (c) => {
|
||||
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
|
||||
const z = db
|
||||
.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM media WHERE ${bedingung}`)
|
||||
.get() as { n: number; bytes: number }
|
||||
return { art: name, files: z.n, bytes: z.bytes }
|
||||
})
|
||||
|
||||
const groesste = (
|
||||
db.prepare('SELECT * FROM media ORDER BY size DESC LIMIT 10').all() as Media[]
|
||||
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
|
||||
|
||||
const aelteste = (
|
||||
db.prepare('SELECT * FROM media ORDER BY created_at ASC LIMIT 10').all() as Media[]
|
||||
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
|
||||
|
||||
// Derselbe Inhalt unter zwei Pfaden. Kein Fehler -- aber jedes Doppel ist
|
||||
// Platz, den niemand gewaehlt hat.
|
||||
const doppelte = db
|
||||
.prepare(
|
||||
`SELECT sha256, COUNT(*) AS n, SUM(size) AS bytes,
|
||||
GROUP_CONCAT(path, '|') AS pfade
|
||||
FROM media GROUP BY sha256 HAVING n > 1 ORDER BY bytes DESC LIMIT 20`,
|
||||
)
|
||||
.all() as { sha256: string; n: number; bytes: number; pfade: string }[]
|
||||
|
||||
const verschwendet = doppelte.reduce((s, d) => s + (d.bytes / d.n) * (d.n - 1), 0)
|
||||
|
||||
// Wie alt der Bestand ist, in Stufen. Das ist die Zahl, die eine
|
||||
// Aufbewahrungsregel braucht: "aelter als 90 Tage" ist eine Behauptung,
|
||||
// solange niemand weiss, wie viel das waere.
|
||||
const stufen = [7, 30, 90, 365].map((tage) => {
|
||||
const z = db
|
||||
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE created_at < ?')
|
||||
.get(now() - tage * 86_400_000) as { n: number; bytes: number }
|
||||
return { tage, files: z.n, bytes: z.bytes }
|
||||
})
|
||||
|
||||
return c.json({ nachArt, groesste, aelteste, doppelte, verschwendet, stufen })
|
||||
})
|
||||
|
||||
dashRoutes.get('/stats', (c) => {
|
||||
const media = db
|
||||
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
|
||||
@@ -1319,9 +1401,21 @@ dashRoutes.get('/stats', (c) => {
|
||||
|
||||
dashRoutes.get('/tokens', (c) => {
|
||||
const rows = db
|
||||
.prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC')
|
||||
.prepare(
|
||||
`SELECT id, name, prefix, can_delete, created_at, last_used_at,
|
||||
max_bytes, arten, expires_at
|
||||
FROM tokens ORDER BY created_at DESC`,
|
||||
)
|
||||
.all() as Omit<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) => {
|
||||
@@ -1329,12 +1423,55 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
name?: string
|
||||
prefix?: string
|
||||
canDelete?: boolean
|
||||
maxMb?: number | string
|
||||
arten?: string[]
|
||||
gueltigTage?: number | string
|
||||
}>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
const { name, prefix, canDelete } = body
|
||||
|
||||
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
|
||||
|
||||
// -- Die drei Grenzen --------------------------------------------------
|
||||
let maxBytes: number | null = null
|
||||
if (body.maxMb !== undefined && String(body.maxMb).trim() !== '') {
|
||||
const mb = Number(body.maxMb)
|
||||
if (!Number.isFinite(mb) || mb <= 0) {
|
||||
return c.json({ error: 'Groessengrenze: eine Zahl groesser als 0' }, 400)
|
||||
}
|
||||
// Groesser als die Grenze des Dienstes zu erlauben waere eine
|
||||
// Behauptung: durchgelassen wird ohnehin die schaerfere.
|
||||
if (mb * 1024 * 1024 > config.maxUploadBytes) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Der Dienst nimmt hoechstens ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB — mehr kann auch dieser Token nicht.`,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
maxBytes = Math.round(mb * 1024 * 1024)
|
||||
}
|
||||
|
||||
const ERLAUBTE_ARTEN = ['bild', 'video', 'ton', 'dokument', 'andere']
|
||||
let arten = ''
|
||||
if (Array.isArray(body.arten) && body.arten.length > 0) {
|
||||
const falsch = body.arten.filter((a) => !ERLAUBTE_ARTEN.includes(a))
|
||||
if (falsch.length) return c.json({ error: `Unbekannte Art: ${falsch.join(', ')}` }, 400)
|
||||
// Alle fuenf gewaehlt heisst dasselbe wie keine Einschraenkung -- dann
|
||||
// steht in der Datenbank auch nichts, sonst muesste jede spaetere
|
||||
// Erweiterung der Artenliste alte Token nachziehen.
|
||||
arten = body.arten.length === ERLAUBTE_ARTEN.length ? '' : body.arten.join(',')
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null
|
||||
if (body.gueltigTage !== undefined && String(body.gueltigTage).trim() !== '') {
|
||||
const tage = Number(body.gueltigTage)
|
||||
if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) {
|
||||
return c.json({ error: 'Gueltigkeit: 1 bis 3650 Tage' }, 400)
|
||||
}
|
||||
expiresAt = now() + Math.round(tage) * 86_400_000
|
||||
}
|
||||
|
||||
// Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft.
|
||||
let cleanPrefix = ''
|
||||
if (prefix?.trim()) {
|
||||
@@ -1349,10 +1486,20 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
const token = generateToken()
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO tokens (name, hash, prefix, can_delete, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO tokens (name, hash, prefix, can_delete, created_at,
|
||||
max_bytes, arten, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
name.trim(),
|
||||
tokenHash(token),
|
||||
cleanPrefix,
|
||||
canDelete ? 1 : 0,
|
||||
now(),
|
||||
maxBytes,
|
||||
arten,
|
||||
expiresAt,
|
||||
)
|
||||
.run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now())
|
||||
|
||||
// Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in
|
||||
// der Datenbank, und auch wir koennen ihn nicht mehr zeigen.
|
||||
@@ -1361,6 +1508,9 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
name: name.trim(),
|
||||
prefix: cleanPrefix,
|
||||
canDelete: Boolean(canDelete),
|
||||
maxBytes,
|
||||
arten,
|
||||
expiresAt,
|
||||
token,
|
||||
})
|
||||
})
|
||||
@@ -1371,6 +1521,24 @@ dashRoutes.delete('/tokens/:id', (c) => {
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
/** Eine Testnachricht schicken.
|
||||
*
|
||||
* Ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall
|
||||
* ausprobiert, ist ein Webhook, der dann nicht funktioniert. */
|
||||
dashRoutes.post('/maintenance/melden', async (c) => {
|
||||
const ok = await melde(
|
||||
`✅ Testnachricht von d4rk_media — ausgelöst von **${c.get('user').username}**.`,
|
||||
{ farbe: 0x3fb950 },
|
||||
)
|
||||
if (!ok) {
|
||||
return c.json(
|
||||
{ error: 'Nichts geschickt. Ist ein Webhook eingetragen, und stimmt die Adresse?' },
|
||||
400,
|
||||
)
|
||||
}
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
dashRoutes.post('/maintenance/prune-sessions', (c) => {
|
||||
pruneSessions()
|
||||
pruneEvents()
|
||||
|
||||
+45
-10
@@ -52,10 +52,34 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
|
||||
if (!token) {
|
||||
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
|
||||
}
|
||||
// Abgelaufen ist etwas anderes als unbekannt, und die Meldung sagt es:
|
||||
// sonst sucht jemand eine halbe Stunde nach einem Tippfehler im Token.
|
||||
if (token.expires_at && token.expires_at < now()) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Dieser Token ist am ${new Date(token.expires_at).toLocaleDateString('de-DE')} abgelaufen.`,
|
||||
},
|
||||
401,
|
||||
)
|
||||
}
|
||||
c.set('token', token)
|
||||
await next()
|
||||
}
|
||||
|
||||
/** Welche Medienart ist das? Dieselben Namen wie im Dashboard.
|
||||
*
|
||||
* An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der
|
||||
* Galerie nicht auseinanderlaufen koennen. */
|
||||
function artVon(mime: string): string {
|
||||
const m = mime.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||
if (m.startsWith('image/')) return 'bild'
|
||||
if (m.startsWith('video/')) return 'video'
|
||||
if (m.startsWith('audio/')) return 'ton'
|
||||
if (m === 'application/pdf' || m.startsWith('text/') || m.startsWith('application/json'))
|
||||
return 'dokument'
|
||||
return 'andere'
|
||||
}
|
||||
|
||||
uploadRoutes.use('/upload', requireToken)
|
||||
uploadRoutes.use('/media/*', requireToken)
|
||||
uploadRoutes.use('/exists/*', requireToken)
|
||||
@@ -96,12 +120,13 @@ uploadRoutes.post('/upload', async (c) => {
|
||||
|
||||
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
|
||||
// abgewiesen wird, BEVOR er im Speicher liegt.
|
||||
// Die schaerfere der beiden Grenzen gilt: die des Dienstes und die dieses
|
||||
// Tokens. Ein Token fuer Fahrzeugbilder braucht keine 64 MB.
|
||||
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
|
||||
|
||||
const announced = Number(c.req.header('content-length') ?? 0)
|
||||
if (announced > config.maxUploadBytes) {
|
||||
return c.json(
|
||||
{ error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` },
|
||||
413,
|
||||
)
|
||||
if (announced > grenze) {
|
||||
return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413)
|
||||
}
|
||||
|
||||
let body: Awaited<ReturnType<typeof readBody>>
|
||||
@@ -113,11 +138,8 @@ uploadRoutes.post('/upload', async (c) => {
|
||||
|
||||
const { data } = body
|
||||
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
|
||||
if (data.length > config.maxUploadBytes) {
|
||||
return c.json(
|
||||
{ error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` },
|
||||
413,
|
||||
)
|
||||
if (data.length > grenze) {
|
||||
return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413)
|
||||
}
|
||||
|
||||
const digest = sha256(data)
|
||||
@@ -160,6 +182,19 @@ uploadRoutes.post('/upload', async (c) => {
|
||||
|
||||
const mime = c.req.header('x-mime') ?? mimeFor(path)
|
||||
|
||||
if (token.arten) {
|
||||
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
|
||||
const art = artVon(mime)
|
||||
if (!erlaubt.includes(art)) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen — das hier ist "${art}" (${mime}).`,
|
||||
},
|
||||
403,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFileAtomic(path, data)
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user