diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java index 054ea91b..0e5203b2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -82,7 +82,7 @@ public class DashboardService { .toList(); return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt, - totalBlocks, pct, null, collaborators); + totalBlocks, pct, doc.getThumbnailUrl(), collaborators); } public DashboardPulseDTO getPulse(UUID userId) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index dc66fb5d..e5526294 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -6,8 +6,11 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.HashSet; @@ -131,4 +134,19 @@ public class Document { @Enumerated(EnumType.STRING) @Builder.Default private Set trainingLabels = new HashSet<>(); + + // The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail + // endpoint sends `Cache-Control: private, max-age=31536000, immutable` + // (DocumentController.getDocumentThumbnail). `immutable` is only safe because + // this URL changes whenever the underlying file does. Dropping the query param + // would let browsers serve a stale thumbnail for a year after the file is + // replaced, and shared caches could leak one user's thumbnail to another + // (CWE-525). + @JsonProperty("thumbnailUrl") + public String getThumbnailUrl() { + if (thumbnailKey == null) return null; + String base = "/api/documents/" + id + "/thumbnail"; + if (thumbnailGeneratedAt == null) return base; + return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java index 19a20e51..d9df35cc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.service.TranscriptionService; import org.raddatz.familienarchiv.service.UserService; import java.time.Instant; +import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.util.HashSet; import java.util.List; @@ -45,6 +46,31 @@ class DashboardServiceTest { @InjectMocks DashboardService dashboardService; + // ─── getResume wires thumbnailUrl from Document ─────────────────────────── + + @Test + void getResume_populatesThumbnailUrl_fromDocument() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab"); + + Document doc = Document.builder() + .id(docId).title("Brief").originalFilename("brief.pdf") + .thumbnailKey("thumbnails/" + docId + ".jpg") + .thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0)) + .receivers(new HashSet<>()) + .build(); + + when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + when(transcriptionService.listBlocks(docId)).thenReturn(List.of()); + + DashboardResumeDTO result = dashboardService.getResume(userId); + + assertThat(result).isNotNull(); + assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl()); + assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v="); + } + // ─── toActorDTO (via getResume collaborators) ───────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java new file mode 100644 index 00000000..fbf48954 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java @@ -0,0 +1,85 @@ +package org.raddatz.familienarchiv.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DocumentTest { + + @Test + void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() { + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Brief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailKey(null) + .build(); + + assertThat(doc.getThumbnailUrl()).isNull(); + } + + @Test + void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() { + UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555"); + Document doc = Document.builder() + .id(id) + .title("Brief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailKey("thumbnails/" + id + ".jpg") + .thumbnailGeneratedAt(null) + .build(); + + assertThat(doc.getThumbnailUrl()) + .isEqualTo("/api/documents/" + id + "/thumbnail"); + } + + @Test + void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() { + UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45); + Document doc = Document.builder() + .id(id) + .title("Brief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailKey("thumbnails/" + id + ".jpg") + .thumbnailGeneratedAt(generatedAt) + .build(); + + // frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}` + // where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises. + // LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent + // turns ":" into "%3A" but leaves "T" and digits alone. + String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45"; + assertThat(doc.getThumbnailUrl()).isEqualTo(expected); + } + + @Test + void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception { + UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333"); + Document doc = Document.builder() + .id(id) + .title("Brief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailKey("thumbnails/" + id + ".jpg") + .thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0)) + .build(); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + String json = mapper.writeValueAsString(doc); + + // Locks the wire contract, not just the Java API: every Document JSON must carry + // `thumbnailUrl`. Protects against silent breakage if the getter gets renamed, + // hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the + // frontend rendering the fallback icon on every surface. + assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\""); + } +} diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte b/frontend/src/lib/components/DashboardResumeStrip.svelte index 76eade81..ade60831 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte @@ -44,27 +44,41 @@ function safeColor(color: string): string { {:else}
- + {#if resumeDoc.thumbnailUrl} + + {:else} + + {/if} +

diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts index 2250ff2e..0c1f9b48 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts @@ -21,6 +21,11 @@ const mockResume: DashboardResumeDTO = { collaborators: [] }; +const mockResumeWithThumbnail: DashboardResumeDTO = { + ...mockResume, + thumbnailUrl: '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00' +}; + describe('DashboardResumeStrip', () => { it('renders empty state heading when resumeDoc is null', async () => { render(DashboardResumeStrip, { resumeDoc: null }); @@ -52,4 +57,23 @@ describe('DashboardResumeStrip', () => { const label = page.getByText(/4 Abschnitte/i); await expect.element(label).toBeInTheDocument(); }); + + it('renders thumbnail img with expected attrs when thumbnailUrl is set', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResumeWithThumbnail }); + const img = page.getByTestId('resume-thumbnail-img'); + await expect.element(img).toBeInTheDocument(); + await expect + .element(img) + .toHaveAttribute('src', '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00'); + await expect.element(img).toHaveAttribute('alt', ''); + await expect.element(img).toHaveAttribute('loading', 'lazy'); + await expect.element(img).toHaveAttribute('decoding', 'async'); + }); + + it('renders fallback icon when thumbnailUrl is null', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const fallback = page.getByTestId('resume-thumbnail-fallback'); + await expect.element(fallback).toBeInTheDocument(); + await expect.element(page.getByTestId('resume-thumbnail-img')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/components/DocumentThumbnail.svelte b/frontend/src/lib/components/DocumentThumbnail.svelte index ee6023e2..a77b1ce1 100644 --- a/frontend/src/lib/components/DocumentThumbnail.svelte +++ b/frontend/src/lib/components/DocumentThumbnail.svelte @@ -1,14 +1,10 @@