feature: PDF-Thumbnails für Dokumente (Upload + Admin-Backfill) #308

Merged
marcel merged 24 commits from feat/issue-307-pdf-thumbnails into main 2026-04-23 07:11:23 +02:00
2 changed files with 82 additions and 0 deletions
Showing only changes of commit f11a29504a - Show all commits

View File

@@ -94,6 +94,31 @@ public class DocumentController {
}
}
// --- THUMBNAIL ---
@GetMapping("/{id}/thumbnail")
public ResponseEntity<InputStreamResource> 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=<thumbnailGeneratedAt> 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) {

View File

@@ -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