From bbf5d5f6d48e295ad2043a2a5575132fa180007e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:15:55 +0100 Subject: [PATCH] Support multi-file async job results and ZIP extraction (#3922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes This PR introduces multi-file support for asynchronous jobs in the Stirling PDF backend, enabling jobs to return and manage multiple result files. Previously, job results were limited to a single file represented by fileId, originalFileName, and contentType. This change replaces that legacy structure with a new ResultFile abstraction and expands the functionality throughout the core system. ZIP File Support If a job result is a ZIP file: It is automatically unpacked using buffered streaming. Each contained file is stored individually and recorded as a ResultFile. The original ZIP is deleted after successful extraction. If ZIP extraction fails, the job result is treated as a single file. New and Updated API Endpoints 1. GET /api/v1/general/job/{jobId}/result If the job has multiple files → returns a JSON metadata list. If the job has a single file → streams the file directly. Includes UTF-8-safe Content-Disposition headers for filename support. 2. GET /api/v1/general/job/{jobId}/result/files New endpoint that returns: ```json { "jobId": "123", "fileCount": 2, "files": [ { "fileId": "abc", "fileName": "page1.pdf", "contentType": "application/pdf", "fileSize": 12345 }, ... ] } ``` 3. GET /api/v1/general/files/{fileId}/metadata Returns metadata for a specific file: 4. GET /api/v1/general/files/{fileId} Downloads a file by fileId, using metadata to determine filename and content type. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com> --- .../common/configuration/AppConfig.java | 6 +- .../software/common/model/job/JobResult.java | 87 ++++++-- .../software/common/model/job/ResultFile.java | 26 +++ .../software/common/service/FileStorage.java | 34 ++- .../software/common/service/TaskManager.java | 201 ++++++++++++++++-- .../common/service/TaskManagerTest.java | 38 +++- .../common/controller/JobController.java | 155 +++++++++++++- .../common/controller/JobControllerTest.java | 5 +- 8 files changed, 493 insertions(+), 59 deletions(-) create mode 100644 common/src/main/java/stirling/software/common/model/job/ResultFile.java diff --git a/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/common/src/main/java/stirling/software/common/configuration/AppConfig.java index b983769a8..f611f42ca 100644 --- a/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -10,7 +10,6 @@ import java.util.Properties; import java.util.function.Predicate; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -151,9 +150,8 @@ public class AppConfig { @Bean(name = "activeSecurity") public boolean missingActiveSecurity() { return ClassUtils.isPresent( - "stirling.software.proprietary.security.configuration.SecurityConfiguration", - this.getClass().getClassLoader() - ); + "stirling.software.proprietary.security.configuration.SecurityConfiguration", + this.getClass().getClassLoader()); } @Bean(name = "directoryFilter") diff --git a/common/src/main/java/stirling/software/common/model/job/JobResult.java b/common/src/main/java/stirling/software/common/model/job/JobResult.java index a621f2db2..1aa66d1a8 100644 --- a/common/src/main/java/stirling/software/common/model/job/JobResult.java +++ b/common/src/main/java/stirling/software/common/model/job/JobResult.java @@ -1,6 +1,7 @@ package stirling.software.common.model.job; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -26,14 +27,8 @@ public class JobResult { /** Error message if the job failed */ private String error; - /** The file ID of the result file, if applicable */ - private String fileId; - - /** Original file name, if applicable */ - private String originalFileName; - - /** MIME type of the result, if applicable */ - private String contentType; + /** List of result files for jobs that produce files */ + private List resultFiles; /** Time when the job was created */ private LocalDateTime createdAt; @@ -64,21 +59,6 @@ public class JobResult { .build(); } - /** - * Mark this job as complete with a file result - * - * @param fileId The file ID of the result - * @param originalFileName The original file name - * @param contentType The content type of the file - */ - public void completeWithFile(String fileId, String originalFileName, String contentType) { - this.complete = true; - this.fileId = fileId; - this.originalFileName = originalFileName; - this.contentType = contentType; - this.completedAt = LocalDateTime.now(); - } - /** * Mark this job as complete with a general result * @@ -101,6 +81,67 @@ public class JobResult { this.completedAt = LocalDateTime.now(); } + /** + * Mark this job as complete with multiple file results + * + * @param resultFiles The list of result files + */ + public void completeWithFiles(List resultFiles) { + this.complete = true; + this.resultFiles = new ArrayList<>(resultFiles); + this.completedAt = LocalDateTime.now(); + } + + /** + * Mark this job as complete with a single file result (convenience method) + * + * @param fileId The file ID of the result + * @param fileName The file name + * @param contentType The content type of the file + * @param fileSize The size of the file in bytes + */ + public void completeWithSingleFile( + String fileId, String fileName, String contentType, long fileSize) { + ResultFile resultFile = + ResultFile.builder() + .fileId(fileId) + .fileName(fileName) + .contentType(contentType) + .fileSize(fileSize) + .build(); + completeWithFiles(List.of(resultFile)); + } + + /** + * Check if this job has file results + * + * @return true if this job has file results, false otherwise + */ + public boolean hasFiles() { + return resultFiles != null && !resultFiles.isEmpty(); + } + + /** + * Check if this job has multiple file results + * + * @return true if this job has multiple file results, false otherwise + */ + public boolean hasMultipleFiles() { + return resultFiles != null && resultFiles.size() > 1; + } + + /** + * Get all result files + * + * @return List of result files + */ + public List getAllResultFiles() { + if (resultFiles != null && !resultFiles.isEmpty()) { + return Collections.unmodifiableList(resultFiles); + } + return Collections.emptyList(); + } + /** * Add a note to this job * diff --git a/common/src/main/java/stirling/software/common/model/job/ResultFile.java b/common/src/main/java/stirling/software/common/model/job/ResultFile.java new file mode 100644 index 000000000..da51b1d6c --- /dev/null +++ b/common/src/main/java/stirling/software/common/model/job/ResultFile.java @@ -0,0 +1,26 @@ +package stirling.software.common.model.job; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Represents a single file result from a job execution */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResultFile { + + /** The file ID for accessing the file */ + private String fileId; + + /** The original file name */ + private String fileName; + + /** MIME type of the file */ + private String contentType; + + /** Size of the file in bytes */ + private long fileSize; +} diff --git a/common/src/main/java/stirling/software/common/service/FileStorage.java b/common/src/main/java/stirling/software/common/service/FileStorage.java index e200ded8a..320b97865 100644 --- a/common/src/main/java/stirling/software/common/service/FileStorage.java +++ b/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -131,14 +131,46 @@ public class FileStorage { return Files.exists(filePath); } + /** + * Get the size of a file by its ID without loading the content into memory + * + * @param fileId The ID of the file + * @return The size of the file in bytes + * @throws IOException If the file doesn't exist or can't be read + */ + public long getFileSize(String fileId) throws IOException { + Path filePath = getFilePath(fileId); + + if (!Files.exists(filePath)) { + throw new IOException("File not found with ID: " + fileId); + } + + return Files.size(filePath); + } + /** * Get the path for a file ID * * @param fileId The ID of the file * @return The path to the file + * @throws IllegalArgumentException if fileId contains path traversal characters or resolves + * outside base directory */ private Path getFilePath(String fileId) { - return Path.of(tempDirPath).resolve(fileId); + // Validate fileId to prevent path traversal + if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { + throw new IllegalArgumentException("Invalid file ID"); + } + + Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath(); + Path resolvedPath = basePath.resolve(fileId).normalize(); + + // Ensure resolved path is within the base directory + if (!resolvedPath.startsWith(basePath)) { + throw new IllegalArgumentException("File ID resolves to an invalid path"); + } + + return resolvedPath; } /** diff --git a/common/src/main/java/stirling/software/common/service/TaskManager.java b/common/src/main/java/stirling/software/common/service/TaskManager.java index c2b3ba8a8..219ae4ac4 100644 --- a/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -1,15 +1,25 @@ package stirling.software.common.service; +import io.github.pixee.security.ZipSecurity; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import jakarta.annotation.PreDestroy; @@ -17,6 +27,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; +import stirling.software.common.model.job.ResultFile; /** Manages async tasks and their results */ @Service @@ -80,8 +91,53 @@ public class TaskManager { public void setFileResult( String jobId, String fileId, String originalFileName, String contentType) { JobResult jobResult = getOrCreateJobResult(jobId); - jobResult.completeWithFile(fileId, originalFileName, contentType); - log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId); + + // Check if this is a ZIP file that should be extracted + if (isZipFile(contentType, originalFileName)) { + try { + List extractedFiles = + extractZipToIndividualFiles(fileId, originalFileName); + if (!extractedFiles.isEmpty()) { + jobResult.completeWithFiles(extractedFiles); + log.debug( + "Set multiple file results for job ID: {} with {} files extracted from ZIP", + jobId, + extractedFiles.size()); + return; + } + } catch (Exception e) { + log.warn( + "Failed to extract ZIP file for job {}: {}. Falling back to single file result.", + jobId, + e.getMessage()); + } + } + + // Handle as single file using new ResultFile approach + try { + long fileSize = fileStorage.getFileSize(fileId); + jobResult.completeWithSingleFile(fileId, originalFileName, contentType, fileSize); + log.debug("Set single file result for job ID: {} with file ID: {}", jobId, fileId); + } catch (Exception e) { + log.warn( + "Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage()); + jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0); + } + } + + /** + * Set the result of a task as multiple files + * + * @param jobId The job ID + * @param resultFiles The list of result files + */ + public void setMultipleFileResults(String jobId, List resultFiles) { + JobResult jobResult = getOrCreateJobResult(jobId); + jobResult.completeWithFiles(resultFiles); + log.debug( + "Set multiple file results for job ID: {} with {} files", + jobId, + resultFiles.size()); } /** @@ -104,7 +160,7 @@ public class TaskManager { public void setComplete(String jobId) { JobResult jobResult = getOrCreateJobResult(jobId); if (jobResult.getResult() == null - && jobResult.getFileId() == null + && !jobResult.hasFiles() && jobResult.getError() == null) { // If no result or error has been set, mark it as complete with an empty result jobResult.completeWithResult("Task completed successfully"); @@ -186,7 +242,7 @@ public class TaskManager { failedJobs++; } else { successfulJobs++; - if (result.getFileId() != null) { + if (result.hasFiles()) { fileResultJobs++; } } @@ -250,17 +306,8 @@ public class TaskManager { && result.getCompletedAt() != null && result.getCompletedAt().isBefore(expiryThreshold)) { - // If the job has a file result, delete the file - if (result.getFileId() != null) { - try { - fileStorage.deleteFile(result.getFileId()); - } catch (Exception e) { - log.warn( - "Failed to delete file for job {}: {}", - entry.getKey(), - e.getMessage()); - } - } + // Clean up file results + cleanupJobFiles(result, entry.getKey()); // Remove the job result jobResults.remove(entry.getKey()); @@ -290,4 +337,128 @@ public class TaskManager { cleanupExecutor.shutdownNow(); } } + + /** Check if a file is a ZIP file based on content type and filename */ + private boolean isZipFile(String contentType, String fileName) { + if (contentType != null + && (contentType.equals("application/zip") + || contentType.equals("application/x-zip-compressed"))) { + return true; + } + + if (fileName != null && fileName.toLowerCase().endsWith(".zip")) { + return true; + } + + return false; + } + + /** Extract a ZIP file into individual files and store them */ + private List extractZipToIndividualFiles( + String zipFileId, String originalZipFileName) throws IOException { + List extractedFiles = new ArrayList<>(); + + MultipartFile zipFile = fileStorage.retrieveFile(zipFileId); + + try (ZipInputStream zipIn = + ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) { + ZipEntry entry; + while ((entry = zipIn.getNextEntry()) != null) { + if (!entry.isDirectory()) { + // Use buffered reading for memory safety + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = zipIn.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + byte[] fileContent = out.toByteArray(); + + String contentType = determineContentType(entry.getName()); + String individualFileId = fileStorage.storeBytes(fileContent, entry.getName()); + + ResultFile resultFile = + ResultFile.builder() + .fileId(individualFileId) + .fileName(entry.getName()) + .contentType(contentType) + .fileSize(fileContent.length) + .build(); + + extractedFiles.add(resultFile); + log.debug( + "Extracted file: {} (size: {} bytes)", + entry.getName(), + fileContent.length); + } + zipIn.closeEntry(); + } + } + + // Clean up the original ZIP file after extraction + try { + fileStorage.deleteFile(zipFileId); + log.debug("Cleaned up original ZIP file: {}", zipFileId); + } catch (Exception e) { + log.warn("Failed to clean up original ZIP file {}: {}", zipFileId, e.getMessage()); + } + + return extractedFiles; + } + + /** Determine content type based on file extension */ + private String determineContentType(String fileName) { + if (fileName == null) { + return MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + + String lowerName = fileName.toLowerCase(); + if (lowerName.endsWith(".pdf")) { + return MediaType.APPLICATION_PDF_VALUE; + } else if (lowerName.endsWith(".txt")) { + return MediaType.TEXT_PLAIN_VALUE; + } else if (lowerName.endsWith(".json")) { + return MediaType.APPLICATION_JSON_VALUE; + } else if (lowerName.endsWith(".xml")) { + return MediaType.APPLICATION_XML_VALUE; + } else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) { + return MediaType.IMAGE_JPEG_VALUE; + } else if (lowerName.endsWith(".png")) { + return MediaType.IMAGE_PNG_VALUE; + } else { + return MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + } + + /** Clean up files associated with a job result */ + private void cleanupJobFiles(JobResult result, String jobId) { + // Clean up all result files + if (result.hasFiles()) { + for (ResultFile resultFile : result.getAllResultFiles()) { + try { + fileStorage.deleteFile(resultFile.getFileId()); + } catch (Exception e) { + log.warn( + "Failed to delete file {} for job {}: {}", + resultFile.getFileId(), + jobId, + e.getMessage()); + } + } + } + } + + /** Find the ResultFile metadata for a given file ID by searching through all job results */ + public ResultFile findResultFileByFileId(String fileId) { + for (JobResult jobResult : jobResults.values()) { + if (jobResult.hasFiles()) { + for (ResultFile resultFile : jobResult.getAllResultFiles()) { + if (fileId.equals(resultFile.getFileId())) { + return resultFile; + } + } + } + } + return null; + } } diff --git a/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java index 4bed01143..b2cb26dd8 100644 --- a/common/src/test/java/stirling/software/common/service/TaskManagerTest.java +++ b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -18,6 +18,7 @@ import org.springframework.test.util.ReflectionTestUtils; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; +import stirling.software.common.model.job.ResultFile; class TaskManagerTest { @@ -73,13 +74,17 @@ class TaskManagerTest { } @Test - void testSetFileResult() { + void testSetFileResult() throws Exception { // Arrange String jobId = UUID.randomUUID().toString(); taskManager.createTask(jobId); String fileId = "file-id"; String originalFileName = "test.pdf"; String contentType = "application/pdf"; + long fileSize = 1024L; + + // Mock the fileStorage.getFileSize() call + when(fileStorage.getFileSize(fileId)).thenReturn(fileSize); // Act taskManager.setFileResult(jobId, fileId, originalFileName, contentType); @@ -88,9 +93,17 @@ class TaskManagerTest { JobResult result = taskManager.getJobResult(jobId); assertNotNull(result); assertTrue(result.isComplete()); - assertEquals(fileId, result.getFileId()); - assertEquals(originalFileName, result.getOriginalFileName()); - assertEquals(contentType, result.getContentType()); + assertTrue(result.hasFiles()); + assertFalse(result.hasMultipleFiles()); + + var resultFiles = result.getAllResultFiles(); + assertEquals(1, resultFiles.size()); + + ResultFile resultFile = resultFiles.get(0); + assertEquals(fileId, resultFile.getFileId()); + assertEquals(originalFileName, resultFile.getFileName()); + assertEquals(contentType, resultFile.getContentType()); + assertEquals(fileSize, resultFile.getFileSize()); assertNotNull(result.getCompletedAt()); } @@ -163,8 +176,11 @@ class TaskManagerTest { } @Test - void testGetJobStats() { + void testGetJobStats() throws Exception { // Arrange + // Mock fileStorage.getFileSize for file operations + when(fileStorage.getFileSize("file-id")).thenReturn(1024L); + // 1. Create active job String activeJobId = "active-job"; taskManager.createTask(activeJobId); @@ -216,9 +232,15 @@ class TaskManagerTest { LocalDateTime oldTime = LocalDateTime.now().minusHours(1); ReflectionTestUtils.setField(oldJob, "completedAt", oldTime); ReflectionTestUtils.setField(oldJob, "complete", true); - ReflectionTestUtils.setField(oldJob, "fileId", "file-id"); - ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf"); - ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf"); + + // Create a ResultFile and set it using the new approach + ResultFile resultFile = ResultFile.builder() + .fileId("file-id") + .fileName("test.pdf") + .contentType("application/pdf") + .fileSize(1024L) + .build(); + ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile)); when(fileStorage.deleteFile("file-id")).thenReturn(true); diff --git a/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java b/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java index 510488a64..44b15265b 100644 --- a/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java +++ b/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java @@ -1,7 +1,11 @@ package stirling.software.common.controller; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -14,6 +18,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.FileStorage; import stirling.software.common.service.JobQueue; import stirling.software.common.service.TaskManager; @@ -78,16 +83,31 @@ public class JobController { return ResponseEntity.badRequest().body("Job failed: " + result.getError()); } - if (result.getFileId() != null) { + // Handle multiple files - return metadata for client to download individually + if (result.hasMultipleFiles()) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body( + Map.of( + "jobId", + jobId, + "hasMultipleFiles", + true, + "files", + result.getAllResultFiles())); + } + + // Handle single file (download directly) + if (result.hasFiles() && !result.hasMultipleFiles()) { try { - byte[] fileContent = fileStorage.retrieveBytes(result.getFileId()); + List files = result.getAllResultFiles(); + ResultFile singleFile = files.get(0); + byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId()); return ResponseEntity.ok() - .header("Content-Type", result.getContentType()) + .header("Content-Type", singleFile.getContentType()) .header( "Content-Disposition", - "form-data; name=\"attachment\"; filename=\"" - + result.getOriginalFileName() - + "\"") + createContentDispositionHeader(singleFile.getFileName())) .body(fileContent); } catch (Exception e) { log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e); @@ -170,4 +190,127 @@ public class JobController { } } } + + /** + * Get the list of files for a job + * + * @param jobId The job ID + * @return List of files for the job + */ + @GetMapping("/api/v1/general/job/{jobId}/result/files") + public ResponseEntity getJobFiles(@PathVariable("jobId") String jobId) { + JobResult result = taskManager.getJobResult(jobId); + if (result == null) { + return ResponseEntity.notFound().build(); + } + + if (!result.isComplete()) { + return ResponseEntity.badRequest().body("Job is not complete yet"); + } + + if (result.getError() != null) { + return ResponseEntity.badRequest().body("Job failed: " + result.getError()); + } + + List files = result.getAllResultFiles(); + return ResponseEntity.ok( + Map.of( + "jobId", jobId, + "fileCount", files.size(), + "files", files)); + } + + /** + * Get metadata for an individual file by its file ID + * + * @param fileId The file ID + * @return The file metadata + */ + @GetMapping("/api/v1/general/files/{fileId}/metadata") + public ResponseEntity getFileMetadata(@PathVariable("fileId") String fileId) { + try { + // Verify file exists + if (!fileStorage.fileExists(fileId)) { + return ResponseEntity.notFound().build(); + } + + // Find the file metadata from any job that contains this file + ResultFile resultFile = taskManager.findResultFileByFileId(fileId); + + if (resultFile != null) { + return ResponseEntity.ok(resultFile); + } else { + // File exists but no metadata found, get basic info efficiently + long fileSize = fileStorage.getFileSize(fileId); + return ResponseEntity.ok( + Map.of( + "fileId", + fileId, + "fileName", + "unknown", + "contentType", + "application/octet-stream", + "fileSize", + fileSize)); + } + } catch (Exception e) { + log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e); + return ResponseEntity.internalServerError() + .body("Error retrieving file metadata: " + e.getMessage()); + } + } + + /** + * Download an individual file by its file ID + * + * @param fileId The file ID + * @return The file content + */ + @GetMapping("/api/v1/general/files/{fileId}") + public ResponseEntity downloadFile(@PathVariable("fileId") String fileId) { + try { + // Verify file exists + if (!fileStorage.fileExists(fileId)) { + return ResponseEntity.notFound().build(); + } + + // Retrieve file content + byte[] fileContent = fileStorage.retrieveBytes(fileId); + + // Find the file metadata from any job that contains this file + // This is for getting the original filename and content type + ResultFile resultFile = taskManager.findResultFileByFileId(fileId); + + String fileName = resultFile != null ? resultFile.getFileName() : "download"; + String contentType = + resultFile != null ? resultFile.getContentType() : "application/octet-stream"; + + return ResponseEntity.ok() + .header("Content-Type", contentType) + .header("Content-Disposition", createContentDispositionHeader(fileName)) + .body(fileContent); + } catch (Exception e) { + log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e); + return ResponseEntity.internalServerError() + .body("Error retrieving file: " + e.getMessage()); + } + } + + /** + * Create Content-Disposition header with UTF-8 filename support + * + * @param fileName The filename to encode + * @return Content-Disposition header value + */ + private String createContentDispositionHeader(String fileName) { + try { + String encodedFileName = + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + .replace("+", "%20"); // URLEncoder uses + for spaces, but we want %20 + return "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + encodedFileName; + } catch (Exception e) { + // Fallback to basic filename if encoding fails + return "attachment; filename=\"" + fileName + "\""; + } + } } diff --git a/stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java b/stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java index c183113d5..3ec041eb5 100644 --- a/stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java +++ b/stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java @@ -19,6 +19,7 @@ import jakarta.servlet.http.HttpSession; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; +import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.FileStorage; import stirling.software.common.service.JobQueue; import stirling.software.common.service.TaskManager; @@ -138,7 +139,7 @@ class JobControllerTest { JobResult mockResult = new JobResult(); mockResult.setJobId(jobId); - mockResult.completeWithFile(fileId, originalFileName, contentType); + mockResult.completeWithSingleFile(fileId, originalFileName, contentType, fileContent.length); when(taskManager.getJobResult(jobId)).thenReturn(mockResult); when(fileStorage.retrieveBytes(fileId)).thenReturn(fileContent); @@ -215,7 +216,7 @@ class JobControllerTest { JobResult mockResult = new JobResult(); mockResult.setJobId(jobId); - mockResult.completeWithFile(fileId, originalFileName, contentType); + mockResult.completeWithSingleFile(fileId, originalFileName, contentType, 1024L); when(taskManager.getJobResult(jobId)).thenReturn(mockResult); when(fileStorage.retrieveBytes(fileId)).thenThrow(new RuntimeException("File not found"));