feat(backend): add GET /api/documents/{id}/thumbnail endpoint
Streams the JPEG thumbnail from S3 with Cache-Control: private, max-age=31536000, immutable — `private` (not `public`) prevents shared caches from leaking one user's thumbnail to another (CWE-525). `immutable` is safe because the URL carries ?v=<thumbnailGeneratedAt> as a cache-buster that changes whenever the file is replaced. Authentication falls back to the global .anyRequest().authenticated() rule, matching the existing /file endpoint's permission model. Refs #307 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user