feat: Verlauf -- wer hat wann was abgelegt, ersetzt oder geloescht

Neuer Reiter, und dahinter eine eigene Tabelle. WARUM NICHT EINFACH
media.created_at -- drei Gruende, jeder allein wuerde reichen:

  1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg, samt
     der Frage, wer es geloescht hat.
  2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
     Auskunft, wann die Datei urspruenglich kam.
  3. media.token_id ist beim Upload aus dem Dashboard NULL -- dort hat niemand
     einen Token, sondern eine Sitzung. Das "von wem" stand also nirgends.

Das ist NICHT das Logging, das die ROADMAP ablehnt: dort geht es um
Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je Upload in
derselben SQLite-Datei, und nach einem halben Jahr raeumt pruneEvents auf.

DER VERLAUF STARTET NICHT LEER. Beim ersten Start wird er aus dem Bestand
nachgetragen (68 Eintraege) -- ein Tab, der am ersten Tag leer ist, obwohl 68
Bilder in der Ablage liegen, sieht aus wie ein kaputter Tab. Die nachgetragenen
Eintraege sind als solche gekennzeichnet: Zeitpunkt stimmt, Urheber steht als
"vor der Aufzeichnung", weil ihn niemand mehr kennt. Die Oberflaeche sagt das
unter der Tabelle, statt es zu verschweigen.

logEvent wirft nie: ein Verlauf, der einen Upload scheitern laesst, waere die
Buchhaltung, die das Geschaeft verhindert.

Nachgemessen: Upload, Ersetzen und Loeschen aus dem Dashboard erscheinen als
user:admin, die Uploads des Fotostudios als token:d4rk_photostudio; beim
Loeschen wird die Groesse VOR dem Loeschen geholt, sonst stuende dort nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:33:52 +02:00
co-authored by Claude Opus 5
parent 3ddab937e7
commit 0b295a2302
6 changed files with 412 additions and 3 deletions
+67 -1
View File
@@ -9,7 +9,17 @@ import { Hono } from 'hono'
import type { Context } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { config } from '../config.js'
import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js'
import {
db,
logEvent,
now,
pruneEvents,
pruneSessions,
type Media,
type MediaEvent,
type Token,
type User,
} from '../db.js'
import {
SESSION_COOKIE,
createSession,
@@ -339,6 +349,7 @@ dashRoutes.delete('/media/:id', async (c) => {
await deleteFile(row.path)
db.prepare('DELETE FROM media WHERE id = ?').run(id)
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
return c.json({ deleted: row.path })
})
@@ -360,6 +371,7 @@ dashRoutes.post('/media/delete', async (c) => {
if (!row) continue
await deleteFile(row.path)
remove.run(id)
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
deleted.push(row.path)
}
@@ -467,6 +479,14 @@ dashRoutes.post('/upload', async (c) => {
).run(path, data.length, digest, mime, zeit, zeit)
}
logEvent(
vorhanden ? 'replace' : 'upload',
path,
data.length,
'user',
c.get('user').username,
)
ergebnisse.push({
name,
ok: true,
@@ -498,6 +518,51 @@ dashRoutes.post('/upload', async (c) => {
return c.json({ hochgeladen: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
})
/** Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht.
*
* Nur lesen, nie schreiben — geschrieben wird an den Stellen, an denen es
* passiert. Ein Verlauf, den man von aussen fuellen kann, ist keiner. */
dashRoutes.get('/events', (c) => {
const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
const kind = c.req.query('kind')?.trim() ?? ''
const actor = c.req.query('actor')?.trim() ?? ''
const bedingungen: string[] = []
const params: unknown[] = []
if (kind && ['upload', 'replace', 'delete'].includes(kind)) {
bedingungen.push('kind = ?')
params.push(kind)
}
if (actor) {
bedingungen.push('actor = ?')
params.push(actor)
}
const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
const total = db.prepare(`SELECT COUNT(*) AS n FROM events ${where}`).get(...params) as {
n: number
}
const items = db
.prepare(`SELECT * FROM events ${where} ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`)
.all(...params, limit, offset) as MediaEvent[]
// Wer ueberhaupt vorkommt -- damit die Oberflaeche einen Filter anbieten
// kann, ohne alle Eintraege durchzublaettern.
const akteure = db
.prepare(
`SELECT actor, actor_kind, COUNT(*) AS n, MAX(at) AS zuletzt
FROM events GROUP BY actor, actor_kind ORDER BY n DESC`,
)
.all() as { actor: string; actor_kind: string; n: number; zuletzt: number }[]
// Ab wann aufgezeichnet wird. Alles davor steht nur in der Galerie, und
// das gehoert dazugesagt statt verschwiegen.
const seit = db.prepare('SELECT MIN(at) AS a FROM events').get() as { a: number | null }
return c.json({ total: total.n, limit, offset, items, akteure, seit: seit.a })
})
dashRoutes.get('/stats', (c) => {
const media = db
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
@@ -578,6 +643,7 @@ dashRoutes.delete('/tokens/:id', (c) => {
dashRoutes.post('/maintenance/prune-sessions', (c) => {
pruneSessions()
pruneEvents()
return c.json({ ok: true })
})
+11 -1
View File
@@ -22,7 +22,7 @@
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { config } from '../config.js'
import { db, now, type Media, type Token } from '../db.js'
import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js'
import {
PathError,
@@ -185,6 +185,8 @@ uploadRoutes.post('/upload', async (c) => {
).run(path, data.length, digest, mime, token.id, timestamp, timestamp)
}
logEvent(existing ? 'replace' : 'upload', path, data.length, 'token', token.name)
return c.json({
url: publicUrlFor(path),
path,
@@ -221,8 +223,16 @@ uploadRoutes.delete('/media/:pfad{.+}', async (c) => {
}
if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404)
// Die Groesse VOR dem Loeschen holen: danach steht sie nirgends mehr, und
// ein Verlaufseintrag ohne Groesse beantwortet die Frage "wie viel Platz
// wurde da frei" nicht.
const zeile = db.prepare('SELECT size FROM media WHERE path = ?').get(path) as
| { size: number }
| undefined
await deleteFile(path)
db.prepare('DELETE FROM media WHERE path = ?').run(path)
logEvent('delete', path, zeile?.size ?? null, 'token', token.name)
return c.json({ deleted: path })
})