From 5eee1e4a6911c4f7b3ac4eed7b4f71551e75ef95 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 25 Aug 2026 21:27:52 -0400 Subject: [PATCH] Return presigned URLs from UploadDto and honour the anon flag UploadDto.fileUrl was the verbatim R2 object key. Verified live as an ordinary ROLE_USER, GET /api/user/uploads returned: {"fileUrl":"images/e5c6f37c-...","uploadedBy":"f7b1d402-...", "approved":true,"anon":true} Two defects in one response. The bucket is fronted by a public custom domain (PosterService serves publicUrl + "/" + objectKey unsigned), so a leaked key is a direct unauthenticated fetch of the media -- and AdminUploadDto deliberately routes through R2StorageService.getSecureUrl, which throws SecurityException for unapproved content. The user-facing path skipped that boundary entirely. Separately, uploadedBy was serialized even when anon=true, revoking the anonymity the uploader was promised at submit time. fileUrl and thumbnailUrl are now absolute expiring presigned URLs issued through the approval gate, with urlExpiresAt so clients re-fetch rather than cache. uploadedBy and instagramHandle are null on anonymous uploads unless the caller is the uploader or holds media:upload:read. Breaking change for utmmsa-pwa; see CLIENT_MIGRATION.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm --- CLIENT_MIGRATION.md | 121 ++++++++ .../dto/upload/response/UploadDto.java | 33 ++ .../lensbridge/service/R2StorageService.java | 10 + .../lensbridge/service/UploadService.java | 141 +++++++-- .../R2StorageServiceSecureUrlTest.java | 113 +++++++ .../service/UploadServiceDtoMappingTest.java | 282 ++++++++++++++++++ 6 files changed, 681 insertions(+), 19 deletions(-) create mode 100644 CLIENT_MIGRATION.md create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/R2StorageServiceSecureUrlTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/UploadServiceDtoMappingTest.java diff --git a/CLIENT_MIGRATION.md b/CLIENT_MIGRATION.md new file mode 100644 index 0000000..3e85ace --- /dev/null +++ b/CLIENT_MIGRATION.md @@ -0,0 +1,121 @@ +# Client migration — `UploadDto` no longer returns raw storage keys + +**Affects:** `C:\Code\utmmsa-pwa` (and any other consumer of the generated client) +**Breaking:** yes — the *values* of `fileUrl` and `thumbnailUrl` change meaning, and `uploadedBy` / +`instagramHandle` can now be `null` where they previously were not. + +## Endpoints affected + +Every endpoint returning `UploadDto` or `Page`: + +| Method | Path | Handler | +| --- | --- | --- | +| GET | `/api/user/uploads` | `UserController.getUserUploads` | +| GET | `/api/upload/{uploadId}` | `FileUploadController.getUploadById` | +| GET | `/api/upload/event/{eventId}` | `FileUploadController.getUploadsByEvent` | + +`AdminUploadDto` (the `/api/admin/uploads/**` family) is **unchanged** — it already returned presigned +URLs in `secureUrl` / `thumbnailUrl`. + +## What changed + +### 1. `fileUrl` and `thumbnailUrl` are now absolute presigned URLs + +Before — the verbatim R2 object key: + +```json +{ "fileUrl": "images/e5c6f37c-845d-4252-9a2f-16e5e68eee96", + "thumbnailUrl": "thumbnails/e5c6f37c-845d-4252-9a2f-16e5e68eee96" } +``` + +After — a scoped, signed, expiring URL on the media domain: + +```json +{ "fileUrl": "https://media.lensbridge.tech/images/e5c6f37c-…?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=…", + "thumbnailUrl": "https://media.lensbridge.tech/thumbnails/e5c6f37c-…?…&X-Amz-Signature=…", + "urlExpiresAt": "2026-08-25T18:45:00Z" } +``` + +**Why:** `media.lensbridge.tech` is a public custom domain in front of the bucket (see +`PosterService.java:100,181`, which serves `publicUrl + "/" + objectKey` unsigned). Returning the key +was therefore equivalent to returning the media itself, to anyone who obtained the JSON, forever, with +no approval check. The presign step is also where the approval gate lives — +`R2StorageService.getSecureUrl` refuses unapproved content to unprivileged callers. + +**Frontend change required:** stop concatenating. Anywhere the PWA does + +```ts +const src = `${MEDIA_BASE_URL}/${upload.fileUrl}`; // ← delete this +``` + +use the value directly: + +```ts +const src = upload.fileUrl; // already absolute +``` + +Grep the PWA for the media base-URL constant (`VITE_MEDIA_URL` / `media.lensbridge.tech` / +any `${...}/${upload.fileUrl}` template) and remove the join at every call site, including +``, `