feat: annotations linked to file version (#55) #58

Merged
marcel merged 2 commits from feature/55-file-hash-annotations into main 2026-03-24 22:09:13 +01:00
11 changed files with 247 additions and 43 deletions
Showing only changes of commit 93f57477cd - Show all commits

View File

@@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
@@ -23,6 +25,7 @@ import java.util.UUID;
public class AnnotationController {
private final AnnotationService annotationService;
private final DocumentService documentService;
private final UserService userService;
@GetMapping
@@ -38,7 +41,8 @@ public class AnnotationController {
@RequestBody CreateAnnotationDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
return annotationService.createAnnotation(documentId, dto, userId);
Document doc = documentService.getDocumentById(documentId);
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
}
@DeleteMapping("/{annotationId}")

View File

@@ -39,6 +39,10 @@ public class Document {
@Column(name = "content_type")
private String contentType;
// SHA-256 hash of the uploaded file — used to link annotations to a file version
@Column(name = "file_hash", length = 64)
private String fileHash;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -49,6 +49,9 @@ public class DocumentAnnotation {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color;
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(name = "created_by")
private UUID createdBy;

View File

@@ -23,7 +23,7 @@ public class AnnotationService {
}
@Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
@@ -41,6 +41,7 @@ public class AnnotationService {
.width(dto.getWidth())
.height(dto.getHeight())
.color(dto.getColor())
.fileHash(fileHash)
.createdBy(userId)
.build();

View File

@@ -64,10 +64,11 @@ public class DocumentService {
}
// 2. Delegate Storage to FileService
String s3Key = fileService.uploadFile(file, originalFilename);
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
// 3. Update Database
document.setFilePath(s3Key);
document.setFilePath(upload.s3Key());
document.setFileHash(upload.fileHash());
document.setContentType(file.getContentType());
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
document.setStatus(DocumentStatus.UPLOADED);
@@ -120,8 +121,9 @@ public class DocumentService {
// Datei
if (file != null && !file.isEmpty()) {
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(s3Key);
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setContentType(file.getContentType());
doc.setStatus(DocumentStatus.UPLOADED);
}
@@ -170,12 +172,9 @@ public class DocumentService {
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) {
// Alte Datei könnte man hier theoretisch löschen (optional)
// Neue Datei hochladen
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(s3Key);
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setOriginalFilename(newFile.getOriginalFilename());
doc.setContentType(newFile.getContentType());
doc.setStatus(DocumentStatus.UPLOADED);

View File

@@ -13,6 +13,8 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.InputStreamResource;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
@Service
@@ -29,10 +31,14 @@ public class FileService {
}
/**
* Uploads a file to S3/MinIO and returns the generated object key.
* Uploads a file to S3/MinIO.
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
* hash of the file content. The hash is used to link annotations to the
* specific file version they were created against.
*/
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
// Generate secure unique path: "documents/UUID_filename"
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
byte[] bytes = file.getBytes();
String fileHash = sha256Hex(bytes);
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
try {
@@ -42,11 +48,10 @@ public class FileService {
.contentType(file.getContentType())
.build();
s3Client.putObject(putObjectRequest,
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
log.info("Uploaded file to S3: {}", s3Key);
return s3Key;
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
return new UploadResult(s3Key, fileHash);
} catch (S3Exception e) {
log.error("S3 Upload Error", e);
throw new IOException("Failed to upload file to storage", e);
@@ -58,32 +63,52 @@ public class FileService {
* Returns a wrapper containing the stream and content type.
*/
public S3FileDownload downloadFile(String s3Key) {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
// Use whatever content type S3 has stored (set at upload time)
String contentType = s3Object.response().contentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
String contentType = s3Object.response().contentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
}
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
}
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
}
}
// Helper Record to carry the stream and metadata back to the controller
// ─── private helpers ──────────────────────────────────────────────────────
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
// ─── result types ─────────────────────────────────────────────────────────
/** Carries the S3 object key and the content hash back to the caller. */
public record UploadResult(String s3Key, String fileHash) {}
/** Carries the download stream and content type. */
public record S3FileDownload(InputStreamResource resource, String contentType) {}
// Custom Exception
public static class StorageFileNotFoundException extends RuntimeException {
public StorageFileNotFoundException(String message) { super(message); }
}

View File

@@ -0,0 +1,7 @@
-- Add content-based file hash to documents for annotation versioning
ALTER TABLE documents
ADD COLUMN file_hash VARCHAR(64);
-- Each annotation remembers which file version it was created against
ALTER TABLE document_annotations
ADD COLUMN file_hash VARCHAR(64);

View File

@@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
@@ -36,6 +38,7 @@ class AnnotationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@@ -85,7 +88,8 @@ class AnnotationControllerTest {
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
@@ -97,7 +101,8 @@ class AnnotationControllerTest {
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception {
when(annotationService.createAnnotation(any(), any(), any()))
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")

View File

@@ -44,7 +44,7 @@ class AnnotationServiceTest {
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
@@ -63,7 +63,7 @@ class AnnotationServiceTest {
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any());
@@ -117,6 +117,35 @@ class AnnotationServiceTest {
verify(annotationRepository).delete(annotation);
}
@Test
void createAnnotation_setsFileHash_whenProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
String fileHash = "abc123";
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
assertThat(result.getFileHash()).isEqualTo(fileHash);
}
@Test
void createAnnotation_setsNullFileHash_whenNoneProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result.getFileHash()).isNull();
}
// ─── listAnnotations ──────────────────────────────────────────────────────
@Test

View File

@@ -135,6 +135,48 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
}
// ─── file hash propagation ───────────────────────────────────────────────
@Test
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(savedDoc);
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
documentService.createDocument(dto, file);
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
}
@Test
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Alt").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
org.springframework.mock.web.MockMultipartFile newFile =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
}
// ─── versioning ───────────────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,85 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockMultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FileServiceTest {
private S3Client s3Client;
private FileService fileService;
@BeforeEach
void setUp() {
s3Client = mock(S3Client.class);
fileService = new FileService(s3Client, "test-bucket");
}
@Test
void uploadFile_returnsS3Key() throws IOException {
MockMultipartFile file = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
assertThat(result.s3Key()).startsWith("documents/");
assertThat(result.s3Key()).endsWith("_test.pdf");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
byte[] content = "hello pdf content".getBytes();
MockMultipartFile file = new MockMultipartFile(
"file", "doc.pdf", "application/pdf", content);
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
// Compute expected hash independently
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content);
StringBuilder expected = new StringBuilder();
for (byte b : hashBytes) {
expected.append(String.format("%02x", b));
}
assertThat(result.fileHash()).isEqualTo(expected.toString());
}
@Test
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
MockMultipartFile file1 = new MockMultipartFile(
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
MockMultipartFile file2 = new MockMultipartFile(
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
}
@Test
void uploadFile_sameContents_produceSameHash() throws IOException {
byte[] content = new byte[]{10, 20, 30};
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
}
}