diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index e5f0bb17..1abee3ad 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -94,6 +94,31 @@ public class DocumentController { } } + // --- THUMBNAIL --- + @GetMapping("/{id}/thumbnail") + public ResponseEntity getDocumentThumbnail(@PathVariable UUID id) { + Document doc = documentService.getDocumentById(id); + + if (doc.getThumbnailKey() == null) { + throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id); + } + + try { + FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey()); + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + // `private` (not `public`) prevents shared caches from serving one user's + // thumbnail to another (CWE-525). `immutable` is safe because the URL + // carries a ?v= cache-buster that changes whenever + // the underlying file is replaced. + .header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable") + .body(download.resource()); + } catch (FileService.StorageFileNotFoundException e) { + throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, + "Thumbnail missing in storage: " + doc.getThumbnailKey()); + } + } + // --- METADATA --- @GetMapping("/{id}") public Document getDocument(@PathVariable UUID id) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 9976a83f..e1282989 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -42,6 +42,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -361,6 +362,62 @@ class DocumentControllerTest { .andExpect(status().isNotFound()); } + // ─── GET /api/documents/{id}/thumbnail ─────────────────────────────────── + + @Test + void getDocumentThumbnail_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/thumbnail")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getDocumentThumbnail_returns404_whenDocHasNoThumbnail() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf").build(); + when(documentService.getDocumentById(id)).thenReturn(doc); + + mockMvc.perform(get("/api/documents/" + id + "/thumbnail")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser + void getDocumentThumbnail_returns200_withPrivateCacheHeader() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf") + .thumbnailKey("thumbnails/" + id + ".jpg").build(); + when(documentService.getDocumentById(id)).thenReturn(doc); + java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + when(fileService.downloadFile("thumbnails/" + id + ".jpg")) + .thenReturn(new FileService.S3FileDownload( + new org.springframework.core.io.InputStreamResource(stream), "image/jpeg")); + + mockMvc.perform(get("/api/documents/" + id + "/thumbnail")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "image/jpeg")) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("private"))) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("public")))) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("immutable"))); + } + + @Test + @WithMockUser + void getDocumentThumbnail_returns404_whenStorageObjectMissing() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf") + .thumbnailKey("thumbnails/" + id + ".jpg").build(); + when(documentService.getDocumentById(id)).thenReturn(doc); + when(fileService.downloadFile("thumbnails/" + id + ".jpg")) + .thenThrow(new FileService.StorageFileNotFoundException("not found")); + + mockMvc.perform(get("/api/documents/" + id + "/thumbnail")) + .andExpect(status().isNotFound()); + } + // ─── POST /api/documents/quick-upload — null/empty files ───────────────── @Test