diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PipelineConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PipelineConfigController.java new file mode 100644 index 0000000000..f74752645a --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PipelineConfigController.java @@ -0,0 +1,121 @@ +package stirling.software.SPDF.controller.api; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.api.PipelineApi; +import stirling.software.common.configuration.RuntimePathConfig; + +import tools.jackson.databind.ObjectMapper; + +@Slf4j +@PipelineApi +public class PipelineConfigController { + + private final RuntimePathConfig runtimePathConfig; + private final ObjectMapper objectMapper; + + public PipelineConfigController( + RuntimePathConfig runtimePathConfig, ObjectMapper objectMapper) { + this.runtimePathConfig = runtimePathConfig; + this.objectMapper = objectMapper; + } + + @PostMapping("/watched-folders/config") + @PreAuthorize("hasRole('ADMIN')") + @Operation( + summary = + "Save a folder-scanning pipeline config into pipeline/watchedFolders (optionally in a subfolder)") + public ResponseEntity saveConfigToWatchedFolder( + @RequestBody SaveWatchedFolderConfigRequest request) { + try { + if (request == null || request.getConfig() == null) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Missing request body or config payload")); + } + + Path watchedRoot = + Paths.get(runtimePathConfig.getPipelineWatchedFoldersPath()) + .toAbsolutePath() + .normalize(); + + String subfolder = request.getSubfolder() == null ? "" : request.getSubfolder().trim(); + Path targetDir = + subfolder.isEmpty() + ? watchedRoot + : watchedRoot.resolve(subfolder).toAbsolutePath().normalize(); + + if (!targetDir.startsWith(watchedRoot)) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid subfolder path")); + } + + Files.createDirectories(targetDir); + + String safeFileName = sanitizeFileName(request.getFileName()); + Path targetFile = targetDir.resolve(safeFileName).toAbsolutePath().normalize(); + + if (!targetFile.startsWith(watchedRoot)) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Invalid target file path")); + } + + String json = + objectMapper + .writerWithDefaultPrettyPrinter() + .writeValueAsString(request.getConfig()); + Files.writeString(targetFile, json, StandardCharsets.UTF_8); + + return ResponseEntity.ok( + Map.of( + "success", + true, + "savedPath", + targetFile.toString(), + "fileName", + safeFileName)); + } catch (IOException e) { + log.error("Failed to write pipeline config to watched folder", e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Failed to write config file")); + } + } + + private String sanitizeFileName(String fileName) { + String base = + fileName == null || fileName.isBlank() + ? "automation.folder-scan.json" + : fileName.trim(); + String sanitized = base.replaceAll("[\\\\/:*?\"<>|]", "_"); + if (!sanitized.toLowerCase().endsWith(".json")) { + sanitized = sanitized + ".json"; + } + if (sanitized.length() > 200) { + sanitized = sanitized.substring(0, 200); + if (!sanitized.toLowerCase().endsWith(".json")) { + sanitized = sanitized + ".json"; + } + } + return sanitized; + } + + @Data + public static class SaveWatchedFolderConfigRequest { + private String subfolder; + private String fileName; + private Object config; + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/PipelineConfigControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/PipelineConfigControllerTest.java new file mode 100644 index 0000000000..c0aa1d0a7b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/PipelineConfigControllerTest.java @@ -0,0 +1,131 @@ +package stirling.software.SPDF.controller.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.common.configuration.RuntimePathConfig; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; + +@ExtendWith(MockitoExtension.class) +class PipelineConfigControllerTest { + + @Mock private RuntimePathConfig runtimePathConfig; + @Mock private ObjectMapper objectMapper; + @Mock private ObjectWriter objectWriter; + + @TempDir Path tempDir; + + @Test + void saveConfigToWatchedFolder_returnsBadRequest_whenRequestIsNull() { + PipelineConfigController controller = + new PipelineConfigController(runtimePathConfig, objectMapper); + + ResponseEntity response = controller.saveConfigToWatchedFolder(null); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("Missing request body or config payload", body.get("error")); + } + + @Test + void saveConfigToWatchedFolder_returnsBadRequest_whenConfigIsNull() { + PipelineConfigController controller = + new PipelineConfigController(runtimePathConfig, objectMapper); + PipelineConfigController.SaveWatchedFolderConfigRequest request = + new PipelineConfigController.SaveWatchedFolderConfigRequest(); + request.setConfig(null); + + ResponseEntity response = controller.saveConfigToWatchedFolder(request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("Missing request body or config payload", body.get("error")); + } + + @Test + void saveConfigToWatchedFolder_returnsBadRequest_whenSubfolderEscapesWatchedRoot() { + when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString()); + PipelineConfigController controller = + new PipelineConfigController(runtimePathConfig, objectMapper); + + PipelineConfigController.SaveWatchedFolderConfigRequest request = + new PipelineConfigController.SaveWatchedFolderConfigRequest(); + request.setSubfolder("../outside"); + request.setFileName("config"); + request.setConfig(Map.of("enabled", true)); + + ResponseEntity response = controller.saveConfigToWatchedFolder(request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("Invalid subfolder path", body.get("error")); + } + + @Test + void saveConfigToWatchedFolder_savesFileAndReturnsSuccess() throws IOException { + when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString()); + when(objectMapper.writerWithDefaultPrettyPrinter()).thenReturn(objectWriter); + when(objectWriter.writeValueAsString(any())).thenReturn("{\"k\":\"v\"}"); + PipelineConfigController controller = + new PipelineConfigController(runtimePathConfig, objectMapper); + + PipelineConfigController.SaveWatchedFolderConfigRequest request = + new PipelineConfigController.SaveWatchedFolderConfigRequest(); + request.setSubfolder("incoming"); + request.setFileName("my:config"); + request.setConfig(Map.of("k", "v")); + + ResponseEntity response = controller.saveConfigToWatchedFolder(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals(true, body.get("success")); + assertEquals("my_config.json", body.get("fileName")); + + Path expectedFile = tempDir.resolve("incoming").resolve("my_config.json"); + assertTrue(Files.exists(expectedFile)); + assertEquals("{\"k\":\"v\"}", Files.readString(expectedFile)); + } + + @Test + void saveConfigToWatchedFolder_returnsInternalServerError_whenWriteFails() throws IOException { + Path watchedRootAsFile = tempDir.resolve("watched-root-file"); + Files.writeString(watchedRootAsFile, "not-a-directory"); + when(runtimePathConfig.getPipelineWatchedFoldersPath()) + .thenReturn(watchedRootAsFile.toString()); + PipelineConfigController controller = + new PipelineConfigController(runtimePathConfig, objectMapper); + + PipelineConfigController.SaveWatchedFolderConfigRequest request = + new PipelineConfigController.SaveWatchedFolderConfigRequest(); + request.setSubfolder("incoming"); + request.setFileName("config"); + request.setConfig(Map.of("k", "v")); + + ResponseEntity response = controller.saveConfigToWatchedFolder(request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("Failed to write config file", body.get("error")); + } +}