From 509194d3e66dd2db5be23a1667d3f89a478fe165 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 13 Sep 2026 22:17:00 +0200 Subject: [PATCH] feat(PROJ-15): media cache cleanup for SYSTEM_ADMIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/system-admin/media/purge-cache (SYSTEM_ADMIN-only, 10/min rate limit) plus /system-admin/media page — a date picker (default 30 days back), confirmation dialog, and honest UI copy: this only clears Synapse's *federated*-media cache, which is nearly empty with federation disabled. No summary/size-preview endpoint, matching the tech design's explicit call to not show a number that might be wrong across Synapse versions. Backend agent couldn't reach the live Synapse to confirm sync/async behavior or whether a count comes back (no access to backend/.env in that sandbox), so it built defensively (number | null). Verified directly against matrix.perlbach24.de: the call is synchronous (~1.1s) and does return a real count ({"deleted": 0}). The defensive handling was unnecessary but harmless (correctly treats 0 as a real count, not "no data") and stays in as a safety net for other Synapse versions. Verified end-to-end on talkhub/matrix: purge call returns {"deleted":0}, audit log gets a proper system_admin.media_cache.purged entry (organization_id: null), non-admin gets 403, a future cutoff date is rejected with 400. Test accounts/audit rows cleaned up. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67 --- backend/src/routes/system-admin.ts | 87 +++++++- backend/src/services/synapse.ts | 37 ++++ features/INDEX.md | 2 +- features/PROJ-15-media-cleanup.md | 14 +- frontend/src/App.tsx | 3 + frontend/src/pages/SystemAdminMediaPage.tsx | 234 ++++++++++++++++++++ frontend/src/pages/SystemAdminRoomsPage.tsx | 12 +- 7 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/SystemAdminMediaPage.tsx diff --git a/backend/src/routes/system-admin.ts b/backend/src/routes/system-admin.ts index df0eee9..2d42c99 100644 --- a/backend/src/routes/system-admin.ts +++ b/backend/src/routes/system-admin.ts @@ -5,7 +5,13 @@ import { db } from "../db/index.js"; import { chatRooms, organizations, teams, pendingRoomDeletions } from "../db/schema.js"; import { requireSystemAdmin } from "../lib/authz.js"; import { logAudit } from "../lib/audit.js"; -import { SynapseError, deleteRoom, getRoomDeleteStatus, listRooms } from "../services/synapse.js"; +import { + SynapseError, + deleteRoom, + getRoomDeleteStatus, + listRooms, + purgeMediaCache, +} from "../services/synapse.js"; /** * PROJ-14: Matrix room IDs look like `!abc123:example.org`. Validating the shape @@ -26,6 +32,22 @@ const listQuerySchema = z.object({ const roomParamsSchema = z.object({ roomId: matrixRoomIdSchema }); +/** + * PROJ-15: cut-off for the media cache purge. Everything cached *before* this + * instant is dropped, so a future timestamp would mean "purge the entire + * cache" — rejected here rather than silently accepted, because the frontend's + * confirmation dialog would then be describing a different action than the one + * actually performed. + */ +const purgeMediaSchema = z.object({ + before: z + .string() + .datetime({ offset: true }) + .refine((value) => Date.parse(value) <= Date.now(), { + message: "Der Stichtag darf nicht in der Zukunft liegen.", + }), +}); + interface LocalAssignment { chatRoomId: string; matrixRoomId: string; @@ -274,4 +296,67 @@ export async function systemAdminRoutes(app: FastifyInstance) { return { deleted: true, hadLocalAssignment: local !== undefined }; }, ); + + /** + * PROJ-15: purges Synapse's cache of *remote* (federated) media older than + * the given cut-off. + * + * Deliberately narrow: there is no "free up disk space" endpoint here, and no + * per-file delete (PROJ-15 non-goal). Local uploads — including the profile + * pictures members set through Element — are untouched by this, and since + * TalkTime runs with federation disabled the realistic result is often + * 0 files. The frontend states this plainly; the endpoint is kept honest to + * match rather than dressed up as a general cleanup. + * + * There is no read-side companion (`GET .../media/summary` from the spec's + * acceptance criteria): the tech design dropped the pre-purge size figure + * because the Synapse admin API offers no version-independent way to obtain + * it, and the spec's own edge case prefers showing no number to showing a + * possibly wrong one. + */ + app.post( + "/api/system-admin/media/purge-cache", + { + preHandler: [app.authenticate, requireSystemAdmin], + // Same reasoning as the room delete: a destructive Synapse-side action + // gets a tighter limit than the global 100/min. + config: { rateLimit: { max: 10, timeWindow: "1 minute" } }, + }, + async (request, reply) => { + const parsed = purgeMediaSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const beforeTs = Date.parse(parsed.data.before); + + let result; + try { + result = await purgeMediaCache(beforeTs); + } catch (err) { + app.log.error({ err, beforeTs }, "PROJ-15: failed to purge media cache at Synapse"); + return reply + .code(502) + .send({ error: "Der Medien-Cache konnte bei Synapse nicht aufgeräumt werden." }); + } + + // Audited only on success — a failed purge changed nothing, and logging + // it as a completed action would make the audit trail misleading. + await logAudit({ + // Platform-level action with no club context, like the room purge. + organizationId: null, + actorUserId: request.user.sub, + action: "system_admin.media_cache.purged", + targetType: "synapse_media_cache", + targetId: new Date(beforeTs).toISOString(), + metadata: { beforeTs, deleted: result.deleted }, + }); + + return { + // null means "Synapse did not report a count", which the frontend must + // render as "Aufräumen abgeschlossen" without a figure — not as 0. + deleted: result.deleted, + before: new Date(beforeTs).toISOString(), + }; + }, + ); } diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts index c714175..e7753ce 100644 --- a/backend/src/services/synapse.ts +++ b/backend/src/services/synapse.ts @@ -291,6 +291,43 @@ export async function getRoomDeleteStatus(deleteId: string): Promise { + const result = await synapseFetch<{ deleted?: number } | undefined>( + `/_synapse/admin/v1/purge_media_cache?before_ts=${encodeURIComponent(String(beforeTs))}`, + { method: "POST" }, + ); + return { + deleted: typeof result?.deleted === "number" ? result.deleted : null, + }; +} + export async function addUserToRoom(roomId: string, userId: string): Promise { await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, { method: "POST", diff --git a/features/INDEX.md b/features/INDEX.md index 5aa6108..6493bc7 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -26,7 +26,7 @@ | PROJ-12 | Automatische Löschfrist für ausgetretene Mitglieder (DSGVO) | Planned | [PROJ-12-mitglieder-loeschfrist.md](PROJ-12-mitglieder-loeschfrist.md) | 2026-09-11 | | PROJ-13 | SYSTEM_ADMIN-Rolle | Deployed | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 | | PROJ-14 | Synapse Raum-Übersicht (System-Admin) | Deployed | [PROJ-14-synapse-raum-uebersicht.md](PROJ-14-synapse-raum-uebersicht.md) | 2026-09-12 | -| PROJ-15 | Media-Cleanup (System-Admin) | In Progress | [PROJ-15-media-cleanup.md](PROJ-15-media-cleanup.md) | 2026-09-12 | +| PROJ-15 | Media-Cleanup (System-Admin) | Deployed | [PROJ-15-media-cleanup.md](PROJ-15-media-cleanup.md) | 2026-09-12 | | PROJ-16 | Federation-Status (System-Admin) | Planned | [PROJ-16-federation-status.md](PROJ-16-federation-status.md) | 2026-09-12 | | PROJ-17 | Nachricht melden (Chat) | Planned | [PROJ-17-nachricht-melden.md](PROJ-17-nachricht-melden.md) | 2026-09-12 | | PROJ-18 | Reports/Moderation (System-Admin) | Planned | [PROJ-18-reports-moderation.md](PROJ-18-reports-moderation.md) | 2026-09-12 | diff --git a/features/PROJ-15-media-cleanup.md b/features/PROJ-15-media-cleanup.md index 09d26f5..99d7ea2 100644 --- a/features/PROJ-15-media-cleanup.md +++ b/features/PROJ-15-media-cleanup.md @@ -1,6 +1,6 @@ # PROJ-15: Media-Cleanup (System-Admin) -**Status:** Planned +**Status:** Deployed **Created:** 2026-09-12 ## Summary @@ -71,3 +71,15 @@ Keine — nutzt die bestehende Synapse-Anbindung. ### Offene Punkte für den Backend-Skill Vor der Umsetzung gegen die tatsächlich eingesetzte Synapse-Version prüfen (Lehre aus PROJ-14): ob der Purge-Aufruf synchron oder asynchron antwortet, und ob eine Rückmeldung zur Anzahl/Größe der aufgeräumten Medien überhaupt mitgeliefert wird. Falls nicht, zeigt die Seite nur "Aufräumen angestoßen" statt einer Zahl, die es nicht gibt. + +### Umsetzung (Backend-Skill, 2026-09-13) + +- `POST /api/system-admin/media/purge-cache` (SYSTEM_ADMIN-only, Rate-Limit 10/min), Frontend-Seite `/system-admin/media`. Keine DB-Änderung, keine Migration, keine neuen Pakete. +- **Abweichung von den Acceptance Criteria:** `GET /api/system-admin/media/summary` wurde **nicht** gebaut — das Tech Design (Abschnitt C, "Keine Vorab-Größenanzeige") verwirft die Größenanzeige bewusst. Das UI kommuniziert das Fehlen explizit. +- **Abweichung:** Audit-Action heißt `system_admin.media_cache.purged` statt `system.media_purged` (Konsistenz mit dem bestehenden `system_admin.*`-Namensraum aus PROJ-14). `organizationId: null`, wie beim Raum-Purge. +- **Offener Punkt aus dem Tech Design — jetzt live geklärt:** Direkt gegen `matrix.perlbach24.de` getestet (Assistant hatte Zugriff, der Backend-Agent nicht): `POST /_synapse/admin/v1/purge_media_cache` antwortet **synchron** (~1,1s) und liefert tatsächlich `{"deleted": 0}` — eine echte Zahl, kein fehlendes Feld. Die defensive `number | null`-Behandlung im Code war also nicht nötig, ist aber unschädlich (`typeof result?.deleted === "number"` behandelt `0` korrekt, kein `!result?.deleted`-Fallstrick) und bleibt als Absicherung für andere Synapse-Versionen drin. + +**Ende-zu-Ende auf `talkhub`/`matrix` verifiziert:** Purge-Aufruf liefert `{"deleted":0,"before":"…"}` (200), Audit-Log-Eintrag `system_admin.media_cache.purged` mit `organization_id: null` erscheint korrekt, Non-Admin bekommt `403`, ein Stichtag in der Zukunft wird mit `400` abgelehnt. Testdaten aufgeräumt. + +### Status +**Production-ready: JA.** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b90ca3..621bed9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { RolesPage } from "@/pages/RolesPage"; import { AuditLogPage } from "@/pages/AuditLogPage"; import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage"; import { SystemAdminRoomsPage } from "@/pages/SystemAdminRoomsPage"; +import { SystemAdminMediaPage } from "@/pages/SystemAdminMediaPage"; function App() { return ( @@ -40,6 +41,8 @@ function App() { }> } /> } /> + {/* PROJ-15 */} + } /> } /> diff --git a/frontend/src/pages/SystemAdminMediaPage.tsx b/frontend/src/pages/SystemAdminMediaPage.tsx new file mode 100644 index 0000000..8e8da41 --- /dev/null +++ b/frontend/src/pages/SystemAdminMediaPage.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/lib/auth-context"; + +/** `YYYY-MM-DD` for an , `days` ago in local time. */ +function dateInputValue(daysAgo: number): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().slice(0, 10); +} + +const DEFAULT_DAYS = 30; +/** + * Below this, the chosen cut-off is treated as unusually aggressive and the + * confirmation dialog says so explicitly (PROJ-15 edge case). + */ +const AGGRESSIVE_DAYS = 7; + +function daysBetween(dateValue: string): number { + const chosen = new Date(`${dateValue}T00:00:00`).getTime(); + return Math.round((Date.now() - chosen) / 86_400_000); +} + +interface PurgeResult { + deleted: number | null; + before: string; +} + +export function SystemAdminMediaPage() { + const { token } = useAuth(); + const [before, setBefore] = useState(() => dateInputValue(DEFAULT_DAYS)); + const [confirmOpen, setConfirmOpen] = useState(false); + const [purging, setPurging] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const today = dateInputValue(0); + const ageInDays = daysBetween(before); + // The backend rejects future cut-offs; catch it here too so the confirmation + // dialog can't describe an action the server will refuse. + const isFuture = before > today; + const isAggressive = !isFuture && ageInDays < AGGRESSIVE_DAYS; + + async function onPurgeConfirmed() { + if (!token || isFuture) return; + setPurging(true); + setError(null); + setResult(null); + try { + const res = await fetch("/api/system-admin/media/purge-cache", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ before: new Date(`${before}T00:00:00`).toISOString() }), + }); + const body = (await res.json().catch(() => null)) as + | (PurgeResult & { error?: string }) + | null; + if (!res.ok) { + throw new Error(body?.error ?? `Aufräumen fehlgeschlagen (${res.status})`); + } + setConfirmOpen(false); + setResult({ deleted: body?.deleted ?? null, before: body?.before ?? before }); + } catch (err) { + setError((err as Error).message); + } finally { + setPurging(false); + } + } + + return ( +
+
+
+

Medien

+

+ Wartung des Synapse-Medien-Zwischenspeichers. +

+
+
+ + +
+
+ + {/* Honest expectation-setting is a deliberate requirement of this feature, + not filler text: the underlying Synapse action is far narrower than + "Speicherplatz freigeben" and would otherwise be badly misread. */} + + +

+ Diese Aktion leert ausschließlich den Zwischenspeicher für Medien{" "} + anderer, föderierter Matrix-Server. +

+

+ Nicht betroffen sind lokal hochgeladene Dateien — dazu gehören auch die + Profilbilder, die Mitglieder über Element setzen. Da bei TalkTime die Föderation + deaktiviert ist, werden voraussichtlich kaum oder gar keine Dateien gefunden. +

+

+ Eine Vorschau des freiwerdenden Speicherplatzes ist nicht verfügbar: Synapse liefert + dafür keine versionsübergreifend verlässliche Angabe. Statt einer möglicherweise + falschen Zahl wird hier keine angezeigt. +

+
+
+ + + + Föderations-Medien-Cache aufräumen + + +
+ + { + setBefore(e.target.value); + setResult(null); + setError(null); + }} + className="max-w-xs" + /> +

+ {isFuture + ? "Der Stichtag darf nicht in der Zukunft liegen." + : `Alles, was vor diesem Datum zwischengespeichert wurde, wird entfernt (${ageInDays} Tage).`} +

+
+ + {error && !confirmOpen && ( + + {error} + + )} + + {result && ( + + + {/* null ≠ 0: the backend passes through "Synapse reported no + count", which must not be rendered as "0 Dateien". */} + {result.deleted === null + ? "Aufräumen abgeschlossen. Synapse hat keine Anzahl zurückgemeldet." + : `Aufräumen abgeschlossen: ${result.deleted} Datei(en) entfernt.`} + + + )} + + +
+
+ + { + if (!open && !purging) { + setConfirmOpen(false); + setError(null); + } + }} + > + + + Medien-Cache aufräumen? + + Alle föderierten Medien, die vor dem{" "} + {new Date(`${before}T00:00:00`).toLocaleDateString("de-DE")}{" "} + zwischengespeichert wurden, werden bei Synapse entfernt. Lokale Uploads und + Profilbilder bleiben erhalten. + + + {isAggressive && ( + + + Der gewählte Stichtag liegt nur {ageInDays} Tag(e) zurück. Das entfernt auch sehr + frisch zwischengespeicherte Medien, die kurz darauf erneut geladen werden müssen. + + + )} + {error && ( + + {error} + + )} + + Abbrechen + { + // Keep the dialog open while the request runs so errors stay visible. + event.preventDefault(); + void onPurgeConfirmed(); + }} + disabled={purging} + > + {purging ? "Räumt auf…" : "Jetzt aufräumen"} + + + + +
+ ); +} diff --git a/frontend/src/pages/SystemAdminRoomsPage.tsx b/frontend/src/pages/SystemAdminRoomsPage.tsx index 8696069..8280f39 100644 --- a/frontend/src/pages/SystemAdminRoomsPage.tsx +++ b/frontend/src/pages/SystemAdminRoomsPage.tsx @@ -109,9 +109,15 @@ export function SystemAdminRoomsPage() { Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.

- +
+ {/* PROJ-15: second system-admin subpage. */} + + +