Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions CLIENT_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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<UploadDto>`:

| 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
`<img src>`, `<video src>`, any `srcset`, download links, and share/copy-link actions.

### 2. Those URLs expire — do not cache them

The signature is valid for `cloudflare.r2.url-expiration-minutes` (default **15 minutes**). A new
field, `urlExpiresAt` (ISO-8601 instant, nullable), tells you exactly when.

Consequences for the PWA:

- **Do not persist `fileUrl` in localStorage / IndexedDB / a service-worker cache / a Redux store that
outlives the session.** A rehydrated URL past `urlExpiresAt` returns 403 and renders a broken image.
- **Do not put the URL in a React `key`, a cache key, or a dedup key** — it changes on every fetch of
the same upload. Use `uuid` for identity.
- Refetch the upload (or the page of uploads) when `urlExpiresAt` passes and the media is still on
screen. A long-lived gallery tab is the realistic failure case; a simple approach is to refetch the
current page when `Date.now() > urlExpiresAt - 60_000` on tab focus.
- Image `onError` handlers should retry once *after refetching the DTO*, not by retrying the same URL.

### 3. `fileUrl` / `thumbnailUrl` can be `null`

`null` means "you may not view these bytes" — the upload is unapproved and you are neither its
uploader nor a moderator — or the storage backend failed to sign. Previously the key was always
present regardless of approval state.

**Frontend change required:** render a placeholder / "Pending review" state when `fileUrl` is null.
`upload.approved === false` is the expected companion signal. Do not `String(upload.fileUrl)` or feed
`null` into an `<img src>`.

`thumbnailUrl` still falls back to the full-size URL when an upload has no thumbnail, so a non-null
`fileUrl` implies a non-null `thumbnailUrl`.

### 4. `uploadedBy` and `instagramHandle` are `null` on anonymous uploads

When `anon === true`, both fields are nulled unless the caller is the uploader themselves or holds
the `media:upload:read` permission. Previously `uploadedBy` was serialized unconditionally, which
silently voided the anonymity the uploader was promised at submit time — a `ROLE_USER` listing an
event's uploads could deanonymise every anonymous submission in it.

**Frontend change required:** anywhere the PWA reads `upload.uploadedBy` or `upload.instagramHandle`
for display, attribution, "is this mine?" checks, or grouping, guard for `null`. Note that on
`/api/user/uploads` the caller *is* the uploader, so those fields stay populated there — the change
bites on `/api/upload/event/{eventId}`.

## Type / generated-client updates

`UploadDto` gains one optional field:

```ts
urlExpiresAt?: string; // ISO-8601 instant; null when no URLs were issued
```

and `fileUrl`, `thumbnailUrl`, `uploadedBy`, `instagramHandle` must all be treated as nullable.
Regenerate the client from the updated `openapi.yaml` at the repo root.

## Suggested rollout

1. Ship the frontend change that *tolerates* absolute URLs (`fileUrl.startsWith('http') ? fileUrl :
join(base, fileUrl)`) and null media — this is compatible with both old and new backends.
2. Deploy the backend.
3. Delete the compatibility branch and the media base-URL constant.

Step 1 is optional if backend and PWA deploy together; if they do not, skip it and you will serve
`https://media.lensbridge.tech/https://media.lensbridge.tech/...` for the duration of the skew.
Original file line number Diff line number Diff line change
@@ -1,29 +1,62 @@
package com.ibrasoft.lensbridge.dto.upload.response;

import com.ibrasoft.lensbridge.model.upload.UploadType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.Instant;
import java.util.UUID;

/**
* The user-facing view of an upload.
* <p>
* Two things about this DTO are security boundaries, not cosmetics:
* <ul>
* <li>{@link #fileUrl} and {@link #thumbnailUrl} are <strong>absolute, expiring, presigned
* URLs</strong>, never the raw R2 object key. The bucket is fronted by a public custom
* domain, so a leaked key is an unauthenticated fetch of the media; and the presign step
* is where the approval gate is enforced. Both are {@code null} when the caller is not
* allowed to see the bytes (unapproved content, or a storage failure).</li>
* <li>{@link #uploadedBy} and {@link #instagramHandle} are {@code null} on anonymous uploads
* unless the caller is the uploader or holds {@code media:upload:read}. Anonymity was
* promised to the uploader at submit time; serializing the author id revokes it.</li>
* </ul>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UploadDto {
private UUID uuid;
private String fileName;

@Schema(description = "Absolute, expiring presigned URL for the media. Use verbatim -- do NOT "
+ "concatenate it with a media base URL. Null when the caller may not view the bytes.",
example = "https://media.lensbridge.tech/images/e5c6f37c-...?X-Amz-Signature=...")
private String fileUrl;

@Schema(description = "Absolute, expiring presigned URL for the thumbnail. Falls back to the "
+ "full-size URL when no thumbnail exists. Null when the caller may not view the bytes.")
private String thumbnailUrl;

private String uploadDescription;

@Schema(description = "Null on anonymous uploads unless the caller is the uploader or holds media:upload:read.")
private String instagramHandle;

@Schema(description = "Null on anonymous uploads unless the caller is the uploader or holds media:upload:read.")
private UUID uploadedBy;

private UUID eventId;
private String eventName;
private Instant createdDate;
private boolean approved;
private boolean featured;
private boolean isAnon;
private UploadType contentType;

@Schema(description = "When fileUrl/thumbnailUrl stop working. Re-fetch the upload after this "
+ "instant rather than caching the URLs. Null when no URLs were issued.")
private Instant urlExpiresAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,16 @@ public String calculateSha256Hash(String objectKey) throws Exception {
return HexFormat.of().formatHex(digest.digest(bytes));
}

/**
* How long a presigned download URL stays valid.
* <p>
* Exposed so callers that hand a presigned URL to a client can also tell the client
* when it dies, instead of leaving it to discover the expiry via a 403.
*/
public Duration getDownloadUrlExpiration() {
return Duration.ofMinutes(urlExpirationMinutes);
}

/**
* Generate a presigned download URL for an object, with access control.
* Throws SecurityException if the content is unapproved and the caller is not admin.
Expand Down
141 changes: 122 additions & 19 deletions src/main/java/com/ibrasoft/lensbridge/service/UploadService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.ibrasoft.lensbridge.dto.upload.response.UploadDto;
import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse;
import com.ibrasoft.lensbridge.exception.FileProcessingException;
import com.ibrasoft.lensbridge.model.auth.Permission;
import com.ibrasoft.lensbridge.model.auth.User;
import com.ibrasoft.lensbridge.model.minbar.BoardEvent;
import com.ibrasoft.lensbridge.model.upload.Upload;
Expand All @@ -16,6 +17,9 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import java.time.Instant;
Expand Down Expand Up @@ -154,7 +158,8 @@ public Optional<Upload> getUploadById(UUID id) {
}

public Optional<UploadDto> getUploadByIdAsDto(UUID id) {
return uploadRepository.findById(id).map(this::toUploadDto);
UploadViewer viewer = currentViewer();
return uploadRepository.findById(id).map(upload -> toUploadDto(upload, viewer));
}

public Page<Upload> getAllUploads(Pageable pageable) {
Expand All @@ -170,13 +175,17 @@ public Page<Upload> getUploadsByEvent(UUID eventId, Pageable pageable) {
public Page<UploadDto> getUploadsByEventAsDto(UUID eventId, Pageable pageable) {
BoardEvent boardEvent = boardEventRepository.findById(eventId)
.orElseThrow(() -> new IllegalArgumentException("Event not found"));
return uploadRepository.findByBoardEventAndDeletedAtIsNull(boardEvent, pageable).map(this::toUploadDto);
UploadViewer viewer = currentViewer();
return uploadRepository.findByBoardEventAndDeletedAtIsNull(boardEvent, pageable)
.map(upload -> toUploadDto(upload, viewer));
}

public Page<UploadDto> getUserUploads(UUID userId, Pageable pageable) {
User user = userService.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable).map(this::toUploadDto);
UploadViewer viewer = currentViewer();
return uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable)
.map(upload -> toUploadDto(upload, viewer));
}

public Page<AdminUploadDto> getAllUploadsForAdmin(Pageable pageable) {
Expand Down Expand Up @@ -222,23 +231,117 @@ private Upload findRequiredById(UUID id) {
.orElseThrow(() -> new IllegalArgumentException("Upload not found: " + id));
}

/**
* Who is asking for a {@link UploadDto}, and what they are allowed to see.
* <p>
* Resolved once per query rather than once per row: a page of 50 uploads should cost one
* principal lookup, not 50.
*
* @param userId the caller's account id, or {@code null} for an unauthenticated caller
* @param canReadAllUploads whether the caller holds {@code media:upload:read}
*/
record UploadViewer(UUID userId, boolean canReadAllUploads) {

static UploadViewer anonymous() {
return new UploadViewer(null, false);
}

/** True when this viewer is the account that submitted the upload. */
boolean isUploader(Upload upload) {
return userId != null
&& upload.getUploadedBy() != null
&& userId.equals(upload.getUploadedBy().getId());
}

/**
* Whether the uploader's identity may be disclosed on an anonymous upload, and whether
* unapproved bytes may be presigned. Moderators need both to do review; the uploader
* needs both to see their own pending submission.
*/
boolean isPrivilegedFor(Upload upload) {
return canReadAllUploads || isUploader(upload);
}
}

/**
* Resolve the caller from the security context. Never throws: an unresolvable principal
* degrades to the least-privileged viewer rather than failing the request.
*/
UploadViewer currentViewer() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
return UploadViewer.anonymous();
}

boolean canReadAll = authentication.getAuthorities().stream()
.anyMatch(a -> Permission.Authority.MEDIA_UPLOAD_READ.equals(a.getAuthority()));

UUID userId = userService.findByEmail(authentication.getName())
.map(User::getId)
.orElse(null);

return new UploadViewer(userId, canReadAll);
}

// TODO: Consolidate this in UploadDTO class as from(Upload)
private UploadDto toUploadDto(Upload upload) {
return new UploadDto(
upload.getUuid(),
upload.getFileName(),
upload.getFileUrl(),
upload.getThumbnailUrl(),
upload.getUploadDescription(),
upload.getInstagramHandle(),
upload.getUploadedBy() != null ? upload.getUploadedBy().getId() : null,
upload.getBoardEvent() != null ? upload.getBoardEvent().getId() : null,
upload.getBoardEvent() != null ? upload.getBoardEvent().getName() : null,
upload.getCreatedDate(),
upload.isApproved(),
upload.isFeatured(),
upload.isAnon(),
upload.getContentType());
UploadDto toUploadDto(Upload upload, UploadViewer viewer) {
boolean privileged = viewer.isPrivilegedFor(upload);

UploadDto dto = new UploadDto();
dto.setUuid(upload.getUuid());
dto.setFileName(upload.getFileName());
dto.setUploadDescription(upload.getUploadDescription());
dto.setEventId(upload.getBoardEvent() != null ? upload.getBoardEvent().getId() : null);
dto.setEventName(upload.getBoardEvent() != null ? upload.getBoardEvent().getName() : null);
dto.setCreatedDate(upload.getCreatedDate());
dto.setApproved(upload.isApproved());
dto.setFeatured(upload.isFeatured());
dto.setAnon(upload.isAnon());
dto.setContentType(upload.getContentType());

// Anonymity is a promise made to the uploader at submit time. Only the uploader
// themselves and moderators (media:upload:read) get to see behind it.
boolean masked = upload.isAnon() && !privileged;
dto.setUploadedBy(masked || upload.getUploadedBy() == null
? null
: upload.getUploadedBy().getId());
dto.setInstagramHandle(masked ? null : upload.getInstagramHandle());

applySecureUrls(dto, upload, privileged);
return dto;
}

/**
* Replace the stored R2 object keys with scoped, expiring presigned URLs.
* <p>
* The keys themselves never leave the server: the bucket is fronted by a public custom
* domain, so handing out a key is handing out the media. {@code getSecureUrl} is also where
* the approval gate lives -- it refuses unapproved content to unprivileged callers. A refusal
* (or any storage failure) nulls the URLs instead of failing the whole listing.
*/
private void applySecureUrls(UploadDto dto, Upload upload, boolean privileged) {
try {
String fileKey = r2StorageService.extractObjectKey(upload.getFileUrl());
if (fileKey == null) {
return;
}

String fileUrl = r2StorageService.getSecureUrl(fileKey, upload.isApproved(), privileged);

String thumbKey = r2StorageService.extractObjectKey(upload.getThumbnailUrl());
String thumbUrl = thumbKey != null
? r2StorageService.getSecureThumbnailUrl(thumbKey, upload.isApproved(), privileged)
: fileUrl;

dto.setFileUrl(fileUrl);
dto.setThumbnailUrl(thumbUrl);
dto.setUrlExpiresAt(Instant.now().plus(r2StorageService.getDownloadUrlExpiration()));
} catch (SecurityException e) {
log.debug("Withholding media URLs for upload {}: {}", upload.getUuid(), e.getMessage());
} catch (Exception e) {
log.error("Failed to generate secure URLs for upload {}: {}", upload.getUuid(), e.getMessage());
}
}

private AdminUploadDto toAdminDto(Upload upload) {
Expand Down
Loading
Loading