feat(PROJ-15): media cache cleanup for SYSTEM_ADMIN

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-13 22:17:00 +02:00
1 parent af0562e524
commit 509194d3e6
7 files changed
+380 -3

No files matched your search

+86 -1
View File
@@ -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(),
};
},
);
}
+37
View File
@@ -291,6 +291,43 @@ export async function getRoomDeleteStatus(deleteId: string): Promise<RoomDeleteS
return { status: result.status, error: result.error ?? null };
}
export interface PurgeMediaCacheResult {
/**
* Number of cached media files Synapse reports having removed, or null when
* the deployed version answers without a usable count.
*
* PROJ-15 open question from the tech design ("does the purge report a
* count?"): could not be verified against the live instance from the dev
* environment, so this deliberately stays nullable rather than defaulting to
* 0 — reporting "0 files removed" when the server simply didn't say would be
* a wrong number, and the spec's edge case explicitly prefers "no figure"
* over "a figure that might be false".
*/
deleted: number | null;
}
/**
* PROJ-15: purges the *remote* (federated) media cache older than `beforeTs`.
*
* Scope caveat, repeated here because it is easy to misread at the call site:
* this does NOT touch locally uploaded media (e.g. the profile pictures members
* set via Element). It only drops copies of files fetched from other
* homeservers. With federation disabled it will usually report 0.
*
* Unlike the v2 room delete, this endpoint is synchronous — it returns once the
* purge is done, so there is no delete_id to poll. `before_ts` is a query
* parameter (milliseconds since epoch), not a JSON body field.
*/
export async function purgeMediaCache(beforeTs: number): Promise<PurgeMediaCacheResult> {
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<void> {
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
method: "POST",
+1 -1
View File
@@ -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 |
+13 -1
View File
@@ -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.**
+3
View File
@@ -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() {
<Route element={<SystemAdminRoute />}>
<Route path="/system-admin" element={<Navigate to="/system-admin/rooms" replace />} />
<Route path="/system-admin/rooms" element={<SystemAdminRoomsPage />} />
{/* PROJ-15 */}
<Route path="/system-admin/media" element={<SystemAdminMediaPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+234
View File
@@ -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 <input type="date">, `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<string | null>(null);
const [result, setResult] = useState<PurgeResult | null>(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 (
<main className="min-h-screen p-4 sm:p-6 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Medien</h1>
<p className="text-sm text-muted-foreground">
Wartung des Synapse-Medien-Zwischenspeichers.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link to="/system-admin/rooms">Matrix-Räume</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/">Zurück zum Dashboard</Link>
</Button>
</div>
</div>
{/* 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. */}
<Alert>
<AlertDescription className="space-y-2">
<p>
Diese Aktion leert ausschließlich den Zwischenspeicher für Medien{" "}
<strong>anderer, föderierter Matrix-Server</strong>.
</p>
<p>
<strong>Nicht</strong> 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.
</p>
<p className="text-muted-foreground">
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.
</p>
</AlertDescription>
</Alert>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Föderations-Medien-Cache aufräumen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="before">Medien-Cache älter als</Label>
<Input
id="before"
type="date"
value={before}
max={today}
onChange={(e) => {
setBefore(e.target.value);
setResult(null);
setError(null);
}}
className="max-w-xs"
/>
<p className="text-sm text-muted-foreground">
{isFuture
? "Der Stichtag darf nicht in der Zukunft liegen."
: `Alles, was vor diesem Datum zwischengespeichert wurde, wird entfernt (${ageInDays} Tage).`}
</p>
</div>
{error && !confirmOpen && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{result && (
<Alert>
<AlertDescription>
{/* 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.`}
</AlertDescription>
</Alert>
)}
<Button
onClick={() => {
setError(null);
setConfirmOpen(true);
}}
disabled={isFuture || !token}
>
Aufräumen
</Button>
</CardContent>
</Card>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open && !purging) {
setConfirmOpen(false);
setError(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Medien-Cache aufräumen?</AlertDialogTitle>
<AlertDialogDescription>
Alle föderierten Medien, die vor dem{" "}
<strong>{new Date(`${before}T00:00:00`).toLocaleDateString("de-DE")}</strong>{" "}
zwischengespeichert wurden, werden bei Synapse entfernt. Lokale Uploads und
Profilbilder bleiben erhalten.
</AlertDialogDescription>
</AlertDialogHeader>
{isAggressive && (
<Alert variant="destructive">
<AlertDescription>
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.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={purging}>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
// 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"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}
@@ -109,10 +109,16 @@ export function SystemAdminRoomsPage() {
Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.
</p>
</div>
<div className="flex gap-2">
{/* PROJ-15: second system-admin subpage. */}
<Button variant="outline" asChild>
<Link to="/system-admin/media">Medien</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/">Zurück zum Dashboard</Link>
</Button>
</div>
</div>
<form onSubmit={onSearchSubmit} className="flex gap-2" role="search">
<Input