feat(media): authenticated signed media gateway (C1, C2-sink, C3, H2, H10)

Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:

- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
  download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
  CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
  uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
  get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
  commit; soft_delete returns the paths; an hourly reaper reclaims disk for
  rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).

Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).

Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-27 15:30:30 +02:00
parent ab9f1d89b2
commit 8faf702208
16 changed files with 437 additions and 119 deletions

View File

@@ -24,7 +24,7 @@
function tileUrl(upload: FeedUpload): string {
if (upload.thumbnail_url) return upload.thumbnail_url;
if (upload.preview_url) return upload.preview_url;
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
return $dataMode === 'original' ? (upload.original_url ?? '') : '';
}
</script>

View File

@@ -1,5 +1,5 @@
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
// the full file via the auth-gated `/api/v1/upload/{id}/original` endpoint.
// the full file via the signed `original_url` from the media gateway.
//
// Stored per-device in localStorage (not per-user) because data plans are a property
// of the device the guest is currently holding, not their identity.
@@ -40,17 +40,24 @@ if (browser) {
* Build the URL for a feed upload given the current data mode and the URL variants
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
* follows the same fallback rule:
* Original mode → original API route. Falls back to preview if no upload id is
* available (defensive — shouldn't happen in practice).
* Original mode → signed original URL, falling back to preview/thumbnail.
* Saver mode → preview URL (compressed), falling back to thumbnail and then
* original.
* the signed original.
*
* All URLs are minted (and signed) by the backend; the client never constructs
* a media path itself.
*/
export function pickMediaUrl(
mode: DataMode,
upload: { id: string; preview_url: string | null; thumbnail_url: string | null }
upload: {
id: string;
preview_url: string | null;
thumbnail_url: string | null;
original_url: string | null;
}
): string {
if (mode === 'original') {
return `/api/v1/upload/${upload.id}/original`;
return upload.original_url ?? upload.preview_url ?? upload.thumbnail_url ?? '';
}
return upload.preview_url ?? upload.thumbnail_url ?? `/api/v1/upload/${upload.id}/original`;
return upload.preview_url ?? upload.thumbnail_url ?? upload.original_url ?? '';
}

View File

@@ -9,6 +9,7 @@ export interface FeedUpload {
uploader_name: string;
preview_url: string | null;
thumbnail_url: string | null;
original_url: string | null;
mime_type: string;
caption: string | null;
like_count: number;

View File

@@ -68,7 +68,7 @@
label: 'Original anzeigen',
icon: '⤓',
onClick: () => {
window.open(`/api/v1/upload/${target.id}/original`, '_blank');
if (target.original_url) window.open(target.original_url, '_blank');
}
}
];