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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user