From ead2332bf207f78bdaf8b0b952a87dd167859dcf Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:06:51 +0200 Subject: [PATCH 01/24] Add method to generate presigned GET URL in MinioStorageService --- .../services/storage/MinioStorageService.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java index a051f81..f751f23 100644 --- a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java +++ b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java @@ -2,9 +2,11 @@ import io.minio.GetObjectArgs; import io.minio.GetObjectResponse; +import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; +import io.minio.http.Method; import org.example.alfs.config.S3Properties; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -65,6 +67,26 @@ public void delete(String objectKey) throws Exception { ); } + /** + * Skapar en tidsbegränsad (pre-signerad) GET-URL för ett objekt i S3/MinIO. + * + * @param objectKey S3/MinIO-objektets key + * @param ttlSeconds Giltighetstid i sekunder (MinIO/S3 begränsar maxvärden, t.ex. upp till 7 dagar) + * @return Publik URL som kan användas för direkt nedladdning under giltighetstiden + */ + public String generatePresignedGetUrl(String objectKey, int ttlSeconds) throws Exception { + if (ttlSeconds <= 0) { + ttlSeconds = 60; // defensivt standardvärde + } + GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(props.getBucket()) + .object(objectKey) + .expiry(ttlSeconds) + .build(); + return minioClient.getPresignedObjectUrl(args); + } + private String sanitize(String name) { return name.replace("\\", "_").replace("/", "_"); } From 87b179d28072fe83de909e5d78b2ab41c994d73e Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:07:30 +0200 Subject: [PATCH 02/24] Add PresignedUrlResponseDTO to encapsulate presigned URL and TTL --- .../attachment/PresignedUrlResponseDTO.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/org/example/alfs/dto/attachment/PresignedUrlResponseDTO.java diff --git a/src/main/java/org/example/alfs/dto/attachment/PresignedUrlResponseDTO.java b/src/main/java/org/example/alfs/dto/attachment/PresignedUrlResponseDTO.java new file mode 100644 index 0000000..cf813f8 --- /dev/null +++ b/src/main/java/org/example/alfs/dto/attachment/PresignedUrlResponseDTO.java @@ -0,0 +1,23 @@ +package org.example.alfs.dto.attachment; + +/** + * Litet svar-DTO för att returnera en presignerad URL och dess TTL i sekunder. + * Används av kommande presign-endpoint. + */ +public class PresignedUrlResponseDTO { + private final String url; + private final int expiresInSeconds; + + public PresignedUrlResponseDTO(String url, int expiresInSeconds) { + this.url = url; + this.expiresInSeconds = expiresInSeconds; + } + + public String getUrl() { + return url; + } + + public int getExpiresInSeconds() { + return expiresInSeconds; + } +} From 3955fb650616943439ea0add87bcc84d794db98b Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:08:15 +0200 Subject: [PATCH 03/24] Add presign endpoint to AttachmentDownloadController for generating time-limited URLs --- .../AttachmentDownloadController.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index f2a82a7..30ae66e 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -2,6 +2,7 @@ import io.minio.GetObjectResponse; import org.example.alfs.entities.Attachment; +import org.example.alfs.dto.attachment.PresignedUrlResponseDTO; import org.example.alfs.repositories.AttachmentRepository; import org.example.alfs.services.storage.MinioStorageService; import org.springframework.core.io.InputStreamResource; @@ -14,6 +15,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -68,4 +71,31 @@ public ResponseEntity download(@PathVariable Long id) { return builder.body(resource); } + + /** + * Minimal presign-endpoint (ingen auth ännu). Returnerar en tidsbegränsad URL + * för direkt nedladdning frÃ¥n MinIO. + * + * Steg 1 för vecka 2: endast 200/404 och enkel TTL-hantering. + */ + @PostMapping("/{id}/presign") + public ResponseEntity presign(@PathVariable Long id, + @RequestParam(name = "ttl", required = false) Integer ttlSeconds) { + Attachment att = attachmentRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id)); + + int ttl = (ttlSeconds == null ? 120 : ttlSeconds); + if (ttl <= 0) ttl = 120; + // Skydda mot extremt lÃ¥nga tider (t.ex. > 1 dag) i detta tidiga steg + if (ttl > 86400) ttl = 86400; // 24h + + final String url; + try { + url = storageService.generatePresignedGetUrl(att.getS3Key(), ttl); + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Failed to create presigned URL", ex); + } + + return ResponseEntity.ok(new PresignedUrlResponseDTO(url, ttl)); + } } From abba8ae4cf2dc49094bc1ed8860f23e0ff2b153d Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:23:26 +0200 Subject: [PATCH 04/24] Require authentication for presign POST endpoint in SecurityConfig --- src/main/java/org/example/alfs/config/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/example/alfs/config/SecurityConfig.java b/src/main/java/org/example/alfs/config/SecurityConfig.java index b2e7a36..e74208f 100644 --- a/src/main/java/org/example/alfs/config/SecurityConfig.java +++ b/src/main/java/org/example/alfs/config/SecurityConfig.java @@ -56,6 +56,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti //allow access to endpoints during development .requestMatchers("/tickets/previewTicket").permitAll() .requestMatchers(HttpMethod.POST, "/api/files/upload").permitAll() + // presign ska kräva inloggning (ingen finmaskig ägarskap ännu) + .requestMatchers(HttpMethod.POST, "/api/files/*/presign").authenticated() .requestMatchers("/css/**", "/js/**", "/images/**", "/static/**").permitAll() .requestMatchers("/login", "/login-form").permitAll() .requestMatchers("/signup", "/signup-form").permitAll() From b39d0a0263addc83eda1005e656a0a861f2cfe83 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:26:28 +0200 Subject: [PATCH 05/24] Add role-based access control for presign endpoint in AttachmentDownloadController --- .../AttachmentDownloadController.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index 30ae66e..8229d5d 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -4,6 +4,9 @@ import org.example.alfs.entities.Attachment; import org.example.alfs.dto.attachment.PresignedUrlResponseDTO; import org.example.alfs.repositories.AttachmentRepository; +import org.example.alfs.security.SecurityUtils; +import org.example.alfs.entities.User; +import org.example.alfs.enums.Role; import org.example.alfs.services.storage.MinioStorageService; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; @@ -28,11 +31,14 @@ public class AttachmentDownloadController { private final AttachmentRepository attachmentRepository; private final MinioStorageService storageService; + private final SecurityUtils securityUtils; public AttachmentDownloadController(AttachmentRepository attachmentRepository, - MinioStorageService storageService) { + MinioStorageService storageService, + SecurityUtils securityUtils) { this.attachmentRepository = attachmentRepository; this.storageService = storageService; + this.securityUtils = securityUtils; } @GetMapping("/{id}/download") @@ -84,6 +90,14 @@ public ResponseEntity presign(@PathVariable Long id, Attachment att = attachmentRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id)); + // Enkel behörighetskontroll (steg 2, vecka 2): + // - ADMIN och INVESTIGATOR fÃ¥r alltid presigna + // - REPORTER fÃ¥r endast presigna om hen äger ärendet (ticket.reporter) + User current = securityUtils.getCurrentUser(); + if (!canPresign(current, att)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment"); + } + int ttl = (ttlSeconds == null ? 120 : ttlSeconds); if (ttl <= 0) ttl = 120; // Skydda mot extremt lÃ¥nga tider (t.ex. > 1 dag) i detta tidiga steg @@ -98,4 +112,15 @@ public ResponseEntity presign(@PathVariable Long id, return ResponseEntity.ok(new PresignedUrlResponseDTO(url, ttl)); } + + private boolean canPresign(User user, Attachment att) { + if (user == null) return false; + Role role = user.getRole(); + if (role == Role.ADMIN || role == Role.INVESTIGATOR) return true; + if (role == Role.REPORTER) { + var ticket = att.getTicket(); + return ticket != null && ticket.getReporter() != null && ticket.getReporter().getId().equals(user.getId()); + } + return false; + } } From 514a100d4a268dc20610505da9e26f6ca54c007e Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:34:10 +0200 Subject: [PATCH 06/24] Log audit events for presign access and implement content-disposition in presigned URLs --- .../AttachmentDownloadController.java | 29 +++++++++++++++++-- .../org/example/alfs/enums/AuditAction.java | 4 ++- .../services/storage/MinioStorageService.java | 27 +++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index 8229d5d..640b15a 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -8,6 +8,8 @@ import org.example.alfs.entities.User; import org.example.alfs.enums.Role; import org.example.alfs.services.storage.MinioStorageService; +import org.example.alfs.services.AuditService; +import org.example.alfs.enums.AuditAction; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; @@ -32,13 +34,16 @@ public class AttachmentDownloadController { private final AttachmentRepository attachmentRepository; private final MinioStorageService storageService; private final SecurityUtils securityUtils; + private final AuditService auditService; public AttachmentDownloadController(AttachmentRepository attachmentRepository, MinioStorageService storageService, - SecurityUtils securityUtils) { + SecurityUtils securityUtils, + AuditService auditService) { this.attachmentRepository = attachmentRepository; this.storageService = storageService; this.securityUtils = securityUtils; + this.auditService = auditService; } @GetMapping("/{id}/download") @@ -95,6 +100,15 @@ public ResponseEntity presign(@PathVariable Long id, // - REPORTER fÃ¥r endast presigna om hen äger ärendet (ticket.reporter) User current = securityUtils.getCurrentUser(); if (!canPresign(current, att)) { + // Audit: access denied to presign for this attachment + auditService.log( + AuditAction.ACCESS_DENIED, + "attachment", + null, + "presign denied: attachmentId=" + att.getId(), + att.getTicket(), + current + ); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment"); } @@ -105,11 +119,22 @@ public ResponseEntity presign(@PathVariable Long id, final String url; try { - url = storageService.generatePresignedGetUrl(att.getS3Key(), ttl); + // Använd variant som sätter Content-Disposition till originalfilnamnet + url = storageService.generatePresignedGetUrlWithContentDisposition(att.getS3Key(), ttl, att.getFileName()); } catch (Exception ex) { throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Failed to create presigned URL", ex); } + // Audit: presigned URL issued + auditService.log( + AuditAction.FILE_PRESIGNED, + "attachment", + null, + "presign issued: attachmentId=" + att.getId() + ", ttl=" + ttl, + att.getTicket(), + current + ); + return ResponseEntity.ok(new PresignedUrlResponseDTO(url, ttl)); } diff --git a/src/main/java/org/example/alfs/enums/AuditAction.java b/src/main/java/org/example/alfs/enums/AuditAction.java index 44f2fd3..bf18ccc 100644 --- a/src/main/java/org/example/alfs/enums/AuditAction.java +++ b/src/main/java/org/example/alfs/enums/AuditAction.java @@ -6,5 +6,7 @@ public enum AuditAction { ASSIGNED, UNASSIGNED, COMMENT_ADDED, - ATTACHMENT_ADDED + ATTACHMENT_ADDED, + FILE_PRESIGNED, + ACCESS_DENIED } \ No newline at end of file diff --git a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java index f751f23..03ba5c3 100644 --- a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java +++ b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java @@ -12,7 +12,11 @@ import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.UUID; +import java.util.HashMap; +import java.util.Map; @Service public class MinioStorageService { @@ -87,6 +91,29 @@ public String generatePresignedGetUrl(String objectKey, int ttlSeconds) throws E return minioClient.getPresignedObjectUrl(args); } + /** + * Variant som inkluderar response-content-disposition sÃ¥ att webbläsaren föreslÃ¥r originalfilnamn. + */ + public String generatePresignedGetUrlWithContentDisposition(String objectKey, int ttlSeconds, String fileName) throws Exception { + if (ttlSeconds <= 0) { + ttlSeconds = 60; + } + String safeName = (fileName == null || fileName.isBlank()) ? "file" : fileName; + // S3-kompatibla tjänster accepterar "response-content-disposition" som query-param + String disposition = "attachment; filename=\"" + safeName + "\"; filename*=UTF-8''" + URLEncoder.encode(safeName, StandardCharsets.UTF_8); + Map extra = new HashMap<>(); + extra.put("response-content-disposition", disposition); + + GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(props.getBucket()) + .object(objectKey) + .expiry(ttlSeconds) + .extraQueryParams(extra) + .build(); + return minioClient.getPresignedObjectUrl(args); + } + private String sanitize(String name) { return name.replace("\\", "_").replace("/", "_"); } From 42769fcc1a3b0c9e2fcb14c79d7f862868802124 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:37:21 +0200 Subject: [PATCH 07/24] Add AuthorizationService to handle role-based access checks for attachments --- .../alfs/services/AuthorizationService.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/main/java/org/example/alfs/services/AuthorizationService.java diff --git a/src/main/java/org/example/alfs/services/AuthorizationService.java b/src/main/java/org/example/alfs/services/AuthorizationService.java new file mode 100644 index 0000000..9debf1b --- /dev/null +++ b/src/main/java/org/example/alfs/services/AuthorizationService.java @@ -0,0 +1,32 @@ +package org.example.alfs.services; + +import org.example.alfs.entities.Attachment; +import org.example.alfs.entities.User; +import org.example.alfs.enums.Role; +import org.springframework.stereotype.Service; + +@Service +public class AuthorizationService { + + /** + * Kontrollerar om användaren fÃ¥r Ã¥tkomst till en bilaga kopplad till ett ärende. + * Regler (vecka 2): + * - ADMIN och INVESTIGATOR: alltid tillÃ¥tna + * - REPORTER: endast om användaren är reporter för ticketen + */ + public boolean canAccessAttachment(User user, Attachment attachment) { + if (user == null || attachment == null) return false; + + Role role = user.getRole(); + if (role == Role.ADMIN || role == Role.INVESTIGATOR) return true; + + if (role == Role.REPORTER) { + var ticket = attachment.getTicket(); + return ticket != null + && ticket.getReporter() != null + && ticket.getReporter().getId().equals(user.getId()); + } + + return false; + } +} From 1db07d1b174539398ad95233abf9118742fa9875 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:37:42 +0200 Subject: [PATCH 08/24] Add audit logging for file download requests and enforce role-based access checks in AttachmentDownloadController --- .../AttachmentDownloadController.java | 44 +++++++++++++------ .../org/example/alfs/enums/AuditAction.java | 1 + 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index 640b15a..90ef93d 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -6,10 +6,10 @@ import org.example.alfs.repositories.AttachmentRepository; import org.example.alfs.security.SecurityUtils; import org.example.alfs.entities.User; -import org.example.alfs.enums.Role; import org.example.alfs.services.storage.MinioStorageService; import org.example.alfs.services.AuditService; import org.example.alfs.enums.AuditAction; +import org.example.alfs.services.AuthorizationService; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; @@ -35,15 +35,18 @@ public class AttachmentDownloadController { private final MinioStorageService storageService; private final SecurityUtils securityUtils; private final AuditService auditService; + private final AuthorizationService authorizationService; public AttachmentDownloadController(AttachmentRepository attachmentRepository, MinioStorageService storageService, SecurityUtils securityUtils, - AuditService auditService) { + AuditService auditService, + AuthorizationService authorizationService) { this.attachmentRepository = attachmentRepository; this.storageService = storageService; this.securityUtils = securityUtils; this.auditService = auditService; + this.authorizationService = authorizationService; } @GetMapping("/{id}/download") @@ -51,6 +54,30 @@ public ResponseEntity download(@PathVariable Long id) { Attachment att = attachmentRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id)); + // Säkerställ att den som laddar ner har rättigheter (samma regler som för presign) + User current = securityUtils.getCurrentUser(); + if (!authorizationService.canAccessAttachment(current, att)) { + auditService.log( + AuditAction.ACCESS_DENIED, + "attachment", + null, + "download denied: attachmentId=" + att.getId(), + att.getTicket(), + current + ); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment"); + } + + // Audit: registrera nedladdningsförsök + auditService.log( + AuditAction.FILE_DOWNLOAD_REQUESTED, + "attachment", + null, + "download requested: attachmentId=" + att.getId(), + att.getTicket(), + current + ); + final GetObjectResponse object; try { object = storageService.download(att.getS3Key()); @@ -99,7 +126,7 @@ public ResponseEntity presign(@PathVariable Long id, // - ADMIN och INVESTIGATOR fÃ¥r alltid presigna // - REPORTER fÃ¥r endast presigna om hen äger ärendet (ticket.reporter) User current = securityUtils.getCurrentUser(); - if (!canPresign(current, att)) { + if (!authorizationService.canAccessAttachment(current, att)) { // Audit: access denied to presign for this attachment auditService.log( AuditAction.ACCESS_DENIED, @@ -137,15 +164,4 @@ public ResponseEntity presign(@PathVariable Long id, return ResponseEntity.ok(new PresignedUrlResponseDTO(url, ttl)); } - - private boolean canPresign(User user, Attachment att) { - if (user == null) return false; - Role role = user.getRole(); - if (role == Role.ADMIN || role == Role.INVESTIGATOR) return true; - if (role == Role.REPORTER) { - var ticket = att.getTicket(); - return ticket != null && ticket.getReporter() != null && ticket.getReporter().getId().equals(user.getId()); - } - return false; - } } diff --git a/src/main/java/org/example/alfs/enums/AuditAction.java b/src/main/java/org/example/alfs/enums/AuditAction.java index bf18ccc..c1df625 100644 --- a/src/main/java/org/example/alfs/enums/AuditAction.java +++ b/src/main/java/org/example/alfs/enums/AuditAction.java @@ -7,6 +7,7 @@ public enum AuditAction { UNASSIGNED, COMMENT_ADDED, ATTACHMENT_ADDED, + FILE_DOWNLOAD_REQUESTED, FILE_PRESIGNED, ACCESS_DENIED } \ No newline at end of file From 43f48c11fc6d8de9a5050635abd261c490fe1e1d Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:37:58 +0200 Subject: [PATCH 09/24] Add audit logging for file download requests and enforce role-based access checks in AttachmentDownloadController --- .../services/AuthorizationServiceTest.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/test/java/org/example/alfs/services/AuthorizationServiceTest.java diff --git a/src/test/java/org/example/alfs/services/AuthorizationServiceTest.java b/src/test/java/org/example/alfs/services/AuthorizationServiceTest.java new file mode 100644 index 0000000..4771134 --- /dev/null +++ b/src/test/java/org/example/alfs/services/AuthorizationServiceTest.java @@ -0,0 +1,89 @@ +package org.example.alfs.services; + +import org.example.alfs.entities.Attachment; +import org.example.alfs.entities.Ticket; +import org.example.alfs.entities.User; +import org.example.alfs.enums.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class AuthorizationServiceTest { + + private AuthorizationService authorizationService; + + @BeforeEach + void setUp() { + authorizationService = new AuthorizationService(); + } + + private static User userWithRole(Role role, Long id) { + User u = new User(); + u.setRole(role); + u.setId(id); + return u; + } + + private static Attachment attachmentWithReporter(Long reporterId) { + Ticket t = new Ticket(); + if (reporterId != null) { + User reporter = new User(); + reporter.setId(reporterId); + reporter.setRole(Role.REPORTER); + t.setReporter(reporter); + } else { + t.setReporter(null); + } + Attachment a = new Attachment(); + a.setTicket(t); + return a; + } + + @Test + void admin_should_have_access() { + User admin = userWithRole(Role.ADMIN, 1L); + Attachment att = attachmentWithReporter(2L); + assertTrue(authorizationService.canAccessAttachment(admin, att)); + } + + @Test + void investigator_should_have_access() { + User inv = userWithRole(Role.INVESTIGATOR, 10L); + Attachment att = attachmentWithReporter(2L); + assertTrue(authorizationService.canAccessAttachment(inv, att)); + } + + @Test + void reporter_owner_should_have_access() { + User rep = userWithRole(Role.REPORTER, 5L); + Attachment att = attachmentWithReporter(5L); + assertTrue(authorizationService.canAccessAttachment(rep, att)); + } + + @Test + void reporter_non_owner_should_be_denied() { + User rep = userWithRole(Role.REPORTER, 5L); + Attachment att = attachmentWithReporter(6L); + assertFalse(authorizationService.canAccessAttachment(rep, att)); + } + + @Test + void reporter_when_ticket_has_no_reporter_should_be_denied() { + User rep = userWithRole(Role.REPORTER, 5L); + Attachment att = attachmentWithReporter(null); + assertFalse(authorizationService.canAccessAttachment(rep, att)); + } + + @Test + void null_user_should_be_denied() { + Attachment att = attachmentWithReporter(1L); + assertFalse(authorizationService.canAccessAttachment(null, att)); + } + + @Test + void null_attachment_should_be_denied() { + User admin = userWithRole(Role.ADMIN, 1L); + assertFalse(authorizationService.canAccessAttachment(admin, null)); + } +} From 453843c1e7ef9467fc41a77c35fdf4a9fc383fa8 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:39:55 +0200 Subject: [PATCH 10/24] Enforce TTL validation and configuration for presigned URLs in AttachmentDownloadController, update S3Properties and README with presign-max-ttl-seconds --- README.md | 27 +++++++++++++++---- .../org/example/alfs/config/S3Properties.java | 11 ++++++++ .../AttachmentDownloadController.java | 22 +++++++++++---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 17a65b0..278fb0c 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,32 @@ createdAt = 2026-03-27 ``` - Check MinIO Console → your bucket → object is present. -5) Test file download (GET) -- Take the `id` from the upload response above and request: +5) Secure download via presigned URL (Week 2) + +- Hämta en presignerad URL (kräver JWT och behörighet): + ```bash + curl -H "Authorization: Bearer " \ + -X POST "http://localhost:8080/api/files/42/presign?ttl=120" + ``` +- Svar: + ```json + { "url": "http://localhost:9000/alfs-attachments/?X-Amz-...", "expiresInSeconds": 120 } + ``` +- Öppna URL:en i webbläsare eller via curl för direktnedladdning frÃ¥n MinIO. +- Content-Disposition sätts sÃ¥ att webbläsaren föreslÃ¥r originalfilnamnet. + +6) Direkt download-endpoint (GET) +- Finns kvar för utveckling/test: `GET /api/files/{id}/download`. +- Kräver nu inloggning och samma behörighetsregler som presign. +- Exempel: ```bash - curl -v -o downloaded.pdf "http://localhost:8080/api/files/42/download" + curl -H "Authorization: Bearer " -v -o downloaded.pdf "http://localhost:8080/api/files/42/download" ``` -- The file should be downloaded as `downloaded.pdf`. +- Rekommenderad väg för klienter är presigned URL-flödet ovan. Notes -- No authentication is enforced on these endpoints yet (Week 1 scope). +- Upload (POST /api/files/upload) är öppen i dev enligt Week 1 scope; presign och download kräver JWT och behörighet. +- Behörighetsregler (Week 2): ADMIN/INVESTIGATOR alltid; REPORTER endast om ägare av ärendet. - Ensure a Ticket with the provided `ticketId` exists in the database before uploading. diff --git a/src/main/java/org/example/alfs/config/S3Properties.java b/src/main/java/org/example/alfs/config/S3Properties.java index 4e4ed87..360a50f 100644 --- a/src/main/java/org/example/alfs/config/S3Properties.java +++ b/src/main/java/org/example/alfs/config/S3Properties.java @@ -19,6 +19,9 @@ public class S3Properties { private String region; private boolean secure; + // Maximal giltighetstid (sekunder) för presignerade URL:er. Standard: 24h. + private int presignMaxTtlSeconds = 86400; + public String getEndpoint() { return endpoint; } @@ -66,4 +69,12 @@ public boolean isSecure() { public void setSecure(boolean secure) { this.secure = secure; } + + public int getPresignMaxTtlSeconds() { + return presignMaxTtlSeconds; + } + + public void setPresignMaxTtlSeconds(int presignMaxTtlSeconds) { + this.presignMaxTtlSeconds = presignMaxTtlSeconds; + } } diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index 90ef93d..43afe72 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -10,6 +10,7 @@ import org.example.alfs.services.AuditService; import org.example.alfs.enums.AuditAction; import org.example.alfs.services.AuthorizationService; +import org.example.alfs.config.S3Properties; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; @@ -36,17 +37,20 @@ public class AttachmentDownloadController { private final SecurityUtils securityUtils; private final AuditService auditService; private final AuthorizationService authorizationService; + private final S3Properties s3Properties; public AttachmentDownloadController(AttachmentRepository attachmentRepository, MinioStorageService storageService, SecurityUtils securityUtils, AuditService auditService, - AuthorizationService authorizationService) { + AuthorizationService authorizationService, + S3Properties s3Properties) { this.attachmentRepository = attachmentRepository; this.storageService = storageService; this.securityUtils = securityUtils; this.auditService = auditService; this.authorizationService = authorizationService; + this.s3Properties = s3Properties; } @GetMapping("/{id}/download") @@ -139,10 +143,18 @@ public ResponseEntity presign(@PathVariable Long id, throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment"); } - int ttl = (ttlSeconds == null ? 120 : ttlSeconds); - if (ttl <= 0) ttl = 120; - // Skydda mot extremt lÃ¥nga tider (t.ex. > 1 dag) i detta tidiga steg - if (ttl > 86400) ttl = 86400; // 24h + // Validera och begränsa TTL enligt konfiguration + int maxTtl = Math.max(1, s3Properties.getPresignMaxTtlSeconds()); + int defaultTtl = Math.min(120, maxTtl); + + int ttl = (ttlSeconds == null ? defaultTtl : ttlSeconds); + if (ttl <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ttl must be a positive integer (seconds)"); + } + if (ttl > maxTtl) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "ttl exceeds max allowed (" + maxTtl + " seconds). Configure storage.s3.presign-max-ttl-seconds if needed."); + } final String url; try { From ab0c3ce6fdd0e37568c958443fc6b8c3a9c69664 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:54:31 +0200 Subject: [PATCH 11/24] Add unit test for TTL validation in AttachmentDownloadController and update test dependencies in pom.xml --- pom.xml | 11 +-- .../AttachmentDownloadControllerTest.java | 97 +++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java diff --git a/pom.xml b/pom.xml index e10e23c..842a149 100644 --- a/pom.xml +++ b/pom.xml @@ -80,17 +80,12 @@ org.springframework.boot - spring-boot-starter-security-test + spring-boot-starter-test test - org.springframework.boot - spring-boot-starter-validation-test - test - - - org.springframework.boot - spring-boot-starter-webmvc-test + org.springframework.security + spring-security-test test diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java new file mode 100644 index 0000000..eb3e3ea --- /dev/null +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -0,0 +1,97 @@ +package org.example.alfs.controllers; + +import org.example.alfs.config.S3Properties; +import org.example.alfs.entities.Attachment; +import org.example.alfs.entities.Ticket; +import org.example.alfs.entities.User; +import org.example.alfs.enums.Role; +import org.example.alfs.repositories.AttachmentRepository; +import org.example.alfs.security.SecurityUtils; +import org.example.alfs.services.AuditService; +import org.example.alfs.services.AuthorizationService; +import org.example.alfs.services.storage.MinioStorageService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AttachmentDownloadController.class) +@AutoConfigureMockMvc(addFilters = false) +class AttachmentDownloadControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AttachmentRepository attachmentRepository; + + @MockBean + private MinioStorageService storageService; + + @MockBean + private SecurityUtils securityUtils; + + @MockBean + private AuditService auditService; + + @MockBean + private AuthorizationService authorizationService; + + @MockBean + private S3Properties s3Properties; + + private static Attachment sampleAttachment() { + Ticket t = new Ticket(); + User reporter = new User(); + reporter.setId(10L); + reporter.setRole(Role.REPORTER); + t.setReporter(reporter); + + Attachment a = new Attachment(); + a.setId(1L); + a.setTicket(t); + a.setFileName("test.pdf"); + a.setS3Key("some/key"); + return a; + } + + private static User sampleUser() { + User u = new User(); + u.setId(99L); + u.setRole(Role.ADMIN); + u.setUsername("admin"); + return u; + } + + @Test + void presign_ttl_exceeds_max_returns_400() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + + // Configure max TTL to 60 seconds + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(60); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + // Verify no presign call attempted due to validation failure + Mockito.verify(storageService, Mockito.never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } +} From 8cbeba3a2afcf73aeec811eb2d8c90d4dfc5f51f Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:56:40 +0200 Subject: [PATCH 12/24] Refactor AttachmentDownloadControllerTest to use standalone setup and add explicit dependency for spring-boot-test-autoconfigure in pom.xml --- pom.xml | 6 +++ .../AttachmentDownloadControllerTest.java | 44 +++++++++++-------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/pom.xml b/pom.xml index 842a149..43aeeb4 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,12 @@ spring-security-test test + + + org.springframework.boot + spring-boot-test-autoconfigure + test + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java index eb3e3ea..5ab5e57 100644 --- a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -10,47 +10,55 @@ import org.example.alfs.services.AuditService; import org.example.alfs.services.AuthorizationService; import org.example.alfs.services.storage.MinioStorageService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.util.Optional; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = AttachmentDownloadController.class) -@AutoConfigureMockMvc(addFilters = false) class AttachmentDownloadControllerTest { - @Autowired private MockMvc mockMvc; - @MockBean private AttachmentRepository attachmentRepository; - - @MockBean private MinioStorageService storageService; - - @MockBean private SecurityUtils securityUtils; - - @MockBean private AuditService auditService; - - @MockBean private AuthorizationService authorizationService; - - @MockBean private S3Properties s3Properties; + @BeforeEach + void setup() { + attachmentRepository = mock(AttachmentRepository.class); + storageService = mock(MinioStorageService.class); + securityUtils = mock(SecurityUtils.class); + auditService = mock(AuditService.class); + authorizationService = mock(AuthorizationService.class); + s3Properties = mock(S3Properties.class); + + AttachmentDownloadController controller = new AttachmentDownloadController( + attachmentRepository, + storageService, + securityUtils, + auditService, + authorizationService, + s3Properties + ); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice() + .build(); + } + private static Attachment sampleAttachment() { Ticket t = new Ticket(); User reporter = new User(); From 83dfbea7196c6fcbd69fa458b2448fb4d32c42a6 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:57:18 +0200 Subject: [PATCH 13/24] Add comprehensive unit tests for presign endpoint covering success, access denial, and validation scenarios --- .../AttachmentDownloadControllerTest.java | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java index 5ab5e57..dd8b103 100644 --- a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -20,10 +20,14 @@ import java.util.Optional; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.hamcrest.Matchers.containsString; class AttachmentDownloadControllerTest { @@ -100,6 +104,51 @@ void presign_ttl_exceeds_max_returns_400() throws Exception { .andExpect(status().isBadRequest()); // Verify no presign call attempted due to validation failure - Mockito.verify(storageService, Mockito.never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_access_denied_returns_403_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(false); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + // Verify audit logged ACCESS_DENIED (we don't assert exact params to keep test resilient) + verify(auditService).log(any(), any(), any(), any(), any(), any()); + // Ensure storage was never called + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_success_returns_200_with_url_and_ttl_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any())) + .thenReturn("http://presigned.example/object?sig=abc"); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"expiresInSeconds\":120"))) + .andExpect(content().string(containsString("http://presigned.example/object?sig=abc"))); + + // Verify audit logged (FILE_PRESIGNED) and storage called + verify(auditService).log(any(), any(), any(), any(), any(), any()); + verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); } } From 738f15d663dabd5e567a8bfb91f4cc7dd0952312 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 22:59:38 +0200 Subject: [PATCH 14/24] Add unit tests for AuditService and extend AttachmentDownloadControllerTest to cover additional download scenarios --- .../AttachmentDownloadControllerTest.java | 58 +++++++++++++++++++ .../services/AuditServiceDataJpaTest.java | 55 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/test/java/org/example/alfs/services/AuditServiceDataJpaTest.java diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java index dd8b103..b9a8d71 100644 --- a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -25,8 +25,10 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.hamcrest.Matchers.containsString; class AttachmentDownloadControllerTest { @@ -151,4 +153,60 @@ void presign_success_returns_200_with_url_and_ttl_and_audit_logged() throws Exce verify(auditService).log(any(), any(), any(), any(), any(), any()); verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); } + + @Test + void download_access_denied_returns_403_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(false); + + // Act & Assert + mockMvc.perform(get("/api/files/1/download")) + .andExpect(status().isForbidden()); + + verify(auditService).log(any(), any(), any(), any(), any(), any()); + verify(storageService, never()).download(any()); + } + + @Test + void download_success_returns_200_and_sets_content_disposition_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + + // Mock GetObjectResponse as an InputStream + io.minio.GetObjectResponse objectResponse = Mockito.mock(io.minio.GetObjectResponse.class); + when(objectResponse.read()).thenReturn(-1); // no content read during response build + // headers() returns okhttp3.Headers in newer MinIO; we can just return null-safe behavior by returning null for get("Content-Length") + var headersMock = new okhttp3.Headers.Builder().build(); + when(objectResponse.headers()).thenReturn(headersMock); + + when(storageService.download(any())).thenReturn(objectResponse); + + // Act & Assert + mockMvc.perform(get("/api/files/1/download")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", containsString("filename=\"test.pdf\""))); + + verify(auditService).log(any(), any(), any(), any(), any(), any()); + verify(storageService).download(any()); + } + + @Test + void download_storage_failure_returns_502() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(storageService.download(any())).thenThrow(new RuntimeException("boom")); + + // Act & Assert + mockMvc.perform(get("/api/files/1/download")) + .andExpect(status().isBadGateway()); + } } diff --git a/src/test/java/org/example/alfs/services/AuditServiceDataJpaTest.java b/src/test/java/org/example/alfs/services/AuditServiceDataJpaTest.java new file mode 100644 index 0000000..308fa6c --- /dev/null +++ b/src/test/java/org/example/alfs/services/AuditServiceDataJpaTest.java @@ -0,0 +1,55 @@ +package org.example.alfs.services; + +import org.example.alfs.entities.AuditLog; +import org.example.alfs.entities.Ticket; +import org.example.alfs.entities.User; +import org.example.alfs.enums.AuditAction; +import org.example.alfs.enums.Role; +import org.example.alfs.repositories.AuditLogRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class AuditServiceDataJpaTest { + + private AuditLogRepository auditLogRepository; + private AuditService auditService; + + @BeforeEach + void setup() { + auditLogRepository = mock(AuditLogRepository.class); + // echo back the entity when save is called + when(auditLogRepository.save(any(AuditLog.class))).thenAnswer(inv -> inv.getArgument(0)); + auditService = new AuditService(auditLogRepository); + } + + @Test + void log_should_populate_and_call_repository_save() { + // Arrange + User reporter = new User(); + reporter.setId(5L); + reporter.setUsername("reporter1"); + reporter.setPasswordHash("x"); + reporter.setRole(Role.REPORTER); + + Ticket t = new Ticket(); + t.setId(11L); + t.setTitle("Test case"); + t.setDescription("Desc"); + t.setReporter(reporter); + + // Act + auditService.log(AuditAction.FILE_PRESIGNED, + "attachment", + null, + "presign issued: attachmentId=1, ttl=120", + t, + reporter); + + // Assert + verify(auditLogRepository, times(1)).save(any(AuditLog.class)); + } +} From 91a0b5674a2fba4214d958ce19ebff56b51cd110 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Wed, 22 Apr 2026 23:09:33 +0200 Subject: [PATCH 15/24] Fix typo in application.properties comment --- src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 76e45ae..0591919 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,7 +4,7 @@ spring.application.name=alfs #Disable whitelabel spring.web.error.whitelabel.enabled=false -# Storage (MinIO/S3) ? default local dev values; override via env in andra milj�er +# Storage (MinIO/S3) ? default local dev values; override via env in andra miljöer storage.s3.endpoint=http://localhost:9000 storage.s3.accessKey=minio storage.s3.secretKey=minio123 From 7dc1bd3041a07a9fbdc90bfc901c1ad4cf1faa44 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 00:07:54 +0200 Subject: [PATCH 16/24] Add unit tests for AttachmentController upload scenarios covering success and validation cases --- .../AttachmentControllerUploadTest.java | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java diff --git a/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java b/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java new file mode 100644 index 0000000..339e135 --- /dev/null +++ b/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java @@ -0,0 +1,88 @@ +package org.example.alfs.controllers; + +import org.example.alfs.repositories.AttachmentRepository; +import org.example.alfs.security.SecurityUtils; +import org.example.alfs.services.AttachmentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AttachmentControllerUploadTest { + + private MockMvc mockMvc; + + private AttachmentService attachmentService; + private AttachmentRepository attachmentRepository; + private SecurityUtils securityUtils; + + @BeforeEach + void setup() { + attachmentService = mock(AttachmentService.class); + attachmentRepository = mock(AttachmentRepository.class); + securityUtils = mock(SecurityUtils.class); + + // For upload in dev we allow anonymous (controller handles null user gracefully) + when(securityUtils.getCurrentUser()).thenThrow(new RuntimeException("No authenticated user in security context")); + + AttachmentController controller = new AttachmentController( + attachmentService, + attachmentRepository, + securityUtils + ); + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void upload_with_ticketId_zero_should_return_400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.pdf", MediaType.APPLICATION_PDF_VALUE, "hello".getBytes() + ); + + mockMvc.perform(multipart("/api/files/upload") + .file(file) + .param("ticketId", "0")) + .andExpect(status().isBadRequest()); + + verify(attachmentService, never()).uploadToTicket(any(), any(), any(), any()); + } + + @Test + void upload_with_empty_file_should_return_400() throws Exception { + MockMultipartFile empty = new MockMultipartFile( + "file", "empty.txt", MediaType.TEXT_PLAIN_VALUE, new byte[0] + ); + + mockMvc.perform(multipart("/api/files/upload") + .file(empty) + .param("ticketId", "1")) + .andExpect(status().isBadRequest()); + + verify(attachmentService, never()).uploadToTicket(any(), any(), any(), any()); + } + + @Test + void upload_success_should_redirect_to_ticket_page_and_call_service() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.pdf", MediaType.APPLICATION_PDF_VALUE, "hello".getBytes() + ); + + mockMvc.perform(multipart("/api/files/upload") + .file(file) + .param("ticketId", "1")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/1")); + + verify(attachmentService, times(1)).uploadToTicket(eq(1L), any(), isNull(), isNull()); + } +} From 92d21402f8e34e1d8b9b5994944d72e8d3884164 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 00:15:15 +0200 Subject: [PATCH 17/24] Expand unit tests for AttachmentController and AttachmentDownloadController to cover new upload and presign scenarios, enhance validation coverage, and refine Content-Disposition checks. --- .../AttachmentControllerUploadTest.java | 19 ++++++ .../AttachmentDownloadControllerTest.java | 61 ++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java b/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java index 339e135..08ca26c 100644 --- a/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java +++ b/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java @@ -85,4 +85,23 @@ void upload_success_should_redirect_to_ticket_page_and_call_service() throws Exc verify(attachmentService, times(1)).uploadToTicket(eq(1L), any(), isNull(), isNull()); } + + @Test + void upload_with_token_should_redirect_to_token_url_and_call_service_with_token() throws Exception { + // Arrange + MockMultipartFile file = new MockMultipartFile( + "file", "tokenfile.pdf", MediaType.APPLICATION_PDF_VALUE, "hello token".getBytes() + ); + + // Act & Assert + mockMvc.perform(multipart("/api/files/upload") + .file(file) + .param("ticketId", "1") + .param("token", "abc123")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/token/abc123")); + + // Verify the service is called with the provided token and null user (dev flow allows anonymous) + verify(attachmentService, times(1)).uploadToTicket(eq(1L), any(), isNull(), eq("abc123")); + } } diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java index b9a8d71..821b14d 100644 --- a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -190,7 +190,8 @@ void download_success_returns_200_and_sets_content_disposition_and_audit_logged( // Act & Assert mockMvc.perform(get("/api/files/1/download")) .andExpect(status().isOk()) - .andExpect(header().string("Content-Disposition", containsString("filename=\"test.pdf\""))); + // Relaxed assertion to support RFC 5987 filename* and encoded variants used by Spring + .andExpect(header().string("Content-Disposition", containsString("test.pdf"))); verify(auditService).log(any(), any(), any(), any(), any(), any()); verify(storageService).download(any()); @@ -209,4 +210,62 @@ void download_storage_failure_returns_502() throws Exception { mockMvc.perform(get("/api/files/1/download")) .andExpect(status().isBadGateway()); } + + @Test + void presign_when_attachment_not_found_returns_404() throws Exception { + // Arrange + when(attachmentRepository.findById(404L)).thenReturn(Optional.empty()); + + // Act & Assert + mockMvc.perform(post("/api/files/404/presign") + .param("ttl", "60") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void download_when_attachment_not_found_returns_404() throws Exception { + // Arrange + when(attachmentRepository.findById(404L)).thenReturn(Optional.empty()); + + // Act & Assert + mockMvc.perform(get("/api/files/404/download")) + .andExpect(status().isNotFound()); + } + + @Test + void presign_with_zero_ttl_returns_400() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "0") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_with_negative_ttl_returns_400() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "-5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } } From c478f3ebc024070df0a27cfd864cc44f7a11d6ed Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 01:46:09 +0200 Subject: [PATCH 18/24] Add unit test for presign endpoint to validate storage failure scenario with 502 response --- .../AttachmentDownloadControllerTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java index 821b14d..c5f4c8e 100644 --- a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -268,4 +268,23 @@ void presign_with_negative_ttl_returns_400() throws Exception { verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); } + + @Test + void presign_storage_failure_returns_502() throws Exception { + // Arrange: happy path until storage throws + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + // Storage fails when generating presigned URL + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any())) + .thenThrow(new RuntimeException("minio down")); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadGateway()); + } } From 3c972abaf3baf30dd6c642867132b07a13c40eeb Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 01:50:52 +0200 Subject: [PATCH 19/24] Add unit tests for presign endpoint to validate default TTL behavior and enforce max TTL configuration --- src/main/resources/application.properties | 2 + .../AttachmentDownloadControllerTest.java | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0591919..436e11a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,6 +11,8 @@ storage.s3.secretKey=minio123 storage.s3.bucket=alfs-attachments storage.s3.region=us-east-1 storage.s3.secure=false +# Max giltighetstid i sekunder för presignerade URL:er (kan överskrivas via env) +storage.s3.presign-max-ttl-seconds=86400 # Multipart upload limits (kan justeras vid behov) spring.servlet.multipart.max-file-size=50MB diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java index c5f4c8e..395a329 100644 --- a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -20,6 +20,7 @@ import java.util.Optional; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.never; import static org.mockito.Mockito.mock; @@ -154,6 +155,50 @@ void presign_success_returns_200_with_url_and_ttl_and_audit_logged() throws Exce verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); } + @Test + void presign_without_ttl_uses_default_120_when_max_is_higher() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); // max > 120, so default should be 120 + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), eq(120), any())) + .thenReturn("http://presigned.example/object?sig=default120"); + + // Act & Assert (no ttl param) + mockMvc.perform(post("/api/files/1/presign") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"expiresInSeconds\":120"))) + .andExpect(content().string(containsString("http://presigned.example/object?sig=default120"))); + + // Verify storage called with ttl=120 + verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), eq(120), any()); + } + + @Test + void presign_without_ttl_uses_max_when_max_less_than_120() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(60); // max < 120, so default should be 60 + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), eq(60), any())) + .thenReturn("http://presigned.example/object?sig=default60"); + + // Act & Assert (no ttl param) + mockMvc.perform(post("/api/files/1/presign") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"expiresInSeconds\":60"))) + .andExpect(content().string(containsString("http://presigned.example/object?sig=default60"))); + + // Verify storage called with ttl=60 + verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), eq(60), any()); + } + @Test void download_access_denied_returns_403_and_audit_logged() throws Exception { // Arrange From 2b36dd6399c9abffeebbfef0c5ec57fb1f337e9c Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 02:02:15 +0200 Subject: [PATCH 20/24] Fix typo in application.properties comment --- src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 436e11a..c1d16c2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,7 +11,7 @@ storage.s3.secretKey=minio123 storage.s3.bucket=alfs-attachments storage.s3.region=us-east-1 storage.s3.secure=false -# Max giltighetstid i sekunder för presignerade URL:er (kan överskrivas via env) +# Max giltighetstid i sekunder för presignerade URL:er (kan overridas via env) storage.s3.presign-max-ttl-seconds=86400 # Multipart upload limits (kan justeras vid behov) From 9d1fc55a3fb59dfe84e4ee423ff0747fb706a085 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 02:03:14 +0200 Subject: [PATCH 21/24] Fix typo in application.properties comment --- src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c1d16c2..1c19517 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,7 +4,7 @@ spring.application.name=alfs #Disable whitelabel spring.web.error.whitelabel.enabled=false -# Storage (MinIO/S3) ? default local dev values; override via env in andra miljöer +# Storage (MinIO/S3) ? default local dev values; override via env in andra miljoer storage.s3.endpoint=http://localhost:9000 storage.s3.accessKey=minio storage.s3.secretKey=minio123 From 74c16f4d232efbf5dcb596602cc56ddd1ce06c66 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 02:04:59 +0200 Subject: [PATCH 22/24] Fix typo in application.properties comment --- src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1c19517..7bce467 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,7 +11,7 @@ storage.s3.secretKey=minio123 storage.s3.bucket=alfs-attachments storage.s3.region=us-east-1 storage.s3.secure=false -# Max giltighetstid i sekunder för presignerade URL:er (kan overridas via env) +# Max giltighetstid i sekunder for presignerade URL:er (kan overridas via env) storage.s3.presign-max-ttl-seconds=86400 # Multipart upload limits (kan justeras vid behov) From e9a390c11f48a198b63b55141a9c49b112a2bf92 Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 02:06:59 +0200 Subject: [PATCH 23/24] Remove deprecated presign endpoint JavaDoc in AttachmentDownloadController --- .../alfs/controllers/AttachmentDownloadController.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index 43afe72..a44dc9e 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -114,12 +114,6 @@ public ResponseEntity download(@PathVariable Long id) { return builder.body(resource); } - /** - * Minimal presign-endpoint (ingen auth ännu). Returnerar en tidsbegränsad URL - * för direkt nedladdning frÃ¥n MinIO. - * - * Steg 1 för vecka 2: endast 200/404 och enkel TTL-hantering. - */ @PostMapping("/{id}/presign") public ResponseEntity presign(@PathVariable Long id, @RequestParam(name = "ttl", required = false) Integer ttlSeconds) { From a832ec959fbaa92a573c2ff7c6388528fbac80fb Mon Sep 17 00:00:00 2001 From: Linus Samuelsson Date: Thu, 23 Apr 2026 02:08:56 +0200 Subject: [PATCH 24/24] Refactor `MinioStorageService` to enhance filename sanitization and improve Content-Disposition handling --- .../services/storage/MinioStorageService.java | 80 +++++++++++++++++-- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java index 03ba5c3..d0a94d3 100644 --- a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java +++ b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java @@ -12,7 +12,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.UUID; import java.util.HashMap; @@ -37,7 +36,7 @@ public String upload(MultipartFile file) throws Exception { if (fileName == null || fileName.isBlank()) { fileName = "file"; } - String objectKey = UUID.randomUUID() + "/" + sanitize(fileName); + String objectKey = UUID.randomUUID() + "/" + sanitizeFileName(fileName); try (InputStream is = file.getInputStream()) { String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream"; @@ -98,9 +97,12 @@ public String generatePresignedGetUrlWithContentDisposition(String objectKey, in if (ttlSeconds <= 0) { ttlSeconds = 60; } - String safeName = (fileName == null || fileName.isBlank()) ? "file" : fileName; + // Sanitize the supplied filename for safe use in headers and URLs + String safeName = sanitizeFileName(fileName); + String quoted = escapeForQuotedString(safeName); + String rfc5987 = rfc5987Encode(safeName); // S3-kompatibla tjänster accepterar "response-content-disposition" som query-param - String disposition = "attachment; filename=\"" + safeName + "\"; filename*=UTF-8''" + URLEncoder.encode(safeName, StandardCharsets.UTF_8); + String disposition = "attachment; filename=\"" + quoted + "\"; filename*=UTF-8''" + rfc5987; Map extra = new HashMap<>(); extra.put("response-content-disposition", disposition); @@ -114,7 +116,73 @@ public String generatePresignedGetUrlWithContentDisposition(String objectKey, in return minioClient.getPresignedObjectUrl(args); } - private String sanitize(String name) { - return name.replace("\\", "_").replace("/", "_"); + /** + * Sanitizes a file name for safe use in headers/paths: + * - Trim whitespace + * - Remove CR/LF and other control characters + * - Replace path separators and dangerous characters with underscore + * - Fallback to "file" if empty after sanitization + */ + private String sanitizeFileName(String name) { + if (name == null) return "file"; + String trimmed = name.trim(); + // remove control chars including CR(\r), LF(\n), tab, etc. + StringBuilder sb = new StringBuilder(trimmed.length()); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (c < 0x20 || c == 0x7F) { + continue; // skip control chars + } + // Disallow path separators and reserved header-breaking chars + if (c == '/' || c == '\\' || c == '"' || c == '\'' || c == ':' || c == ';' || c == ',' || c == '\n' || c == '\r') { + sb.append('_'); + } else { + sb.append(c); + } + } + String result = sb.toString(); + if (result.isBlank()) return "file"; + return result; + } + + /** + * Escapes a value for use inside a quoted-string (RFC 2616/7230 style): + * backslash-escape backslashes and double quotes. + */ + private String escapeForQuotedString(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\'); + } + sb.append(c); + } + return sb.toString(); + } + + /** + * RFC 5987 percent-encoding for HTTP header parameters using UTF-8. + * Spaces are encoded as %20 (not '+'), and non-ASCII bytes are percent-encoded. + */ + private String rfc5987Encode(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + StringBuilder sb = new StringBuilder(bytes.length * 3); + for (byte b : bytes) { + int v = b & 0xFF; + // unreserved according to RFC 3986: ALPHA / DIGIT / "-" / "." / "_" / "~" + if ((v >= 'a' && v <= 'z') || (v >= 'A' && v <= 'Z') || (v >= '0' && v <= '9') + || v == '-' || v == '.' || v == '_' || v == '~') { + sb.append((char) v); + } else if (v == ' ') { + sb.append("%20"); + } else { + sb.append('%'); + String hex = Integer.toHexString(v).toUpperCase(); + if (hex.length() == 1) sb.append('0'); + sb.append(hex); + } + } + return sb.toString(); } }