feat(backend): hash uploaded files and store hash on documents and annotations

- Flyway V13: add file_hash column to documents and document_annotations
- FileService.uploadFile() now returns UploadResult(s3Key, fileHash) with SHA-256 hash computed from raw bytes
- Document and DocumentAnnotation models gain a fileHash field
- DocumentService propagates the hash at all three upload sites (storeDocument, createDocument, updateDocument)
- AnnotationService.createAnnotation() accepts and persists a fileHash
- AnnotationController resolves the document's hash and passes it through

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-24 17:08:55 +01:00
parent 34c66f80fc
commit 93f57477cd
11 changed files with 247 additions and 43 deletions

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);