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.
+
+ {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. */}
+
+
+