Compare commits

...
Author SHA1 Message Date
LudyandGitHub 4351318f89 Merge branch 'main' into pipeline_controller_20260525 2026-07-09 11:06:03 +02:00
LudyandGitHub b27c46d72b Merge branch 'main' into pipeline_controller_20260525 2026-07-06 10:08:45 +02:00
LudyandGitHub b0ecc22260 Merge branch 'main' into pipeline_controller_20260525 2026-06-18 09:39:27 +02:00
LudyandGitHub 91279e85fa Merge branch 'main' into pipeline_controller_20260525 2026-06-12 18:25:23 +02:00
LudyandGitHub 55fb5cf6c4 Merge branch 'main' into pipeline_controller_20260525 2026-06-09 19:27:59 +02:00
LudyandGitHub 9c3d1eeca6 Merge branch 'main' into pipeline_controller_20260525 2026-06-07 09:58:59 +02:00
LudyandGitHub a79efd8d39 Merge branch 'main' into pipeline_controller_20260525 2026-06-05 19:14:58 +02:00
LudyandGitHub ade36f7d86 Merge branch 'main' into pipeline_controller_20260525 2026-05-31 09:12:50 +02:00
LudyandGitHub 8b69a5fc05 Merge branch 'main' into pipeline_controller_20260525 2026-05-30 23:39:28 +02:00
Ludy87 ae8c2ec293 Move PipelineConfigController to proprietary
Move PipelineConfigController and its test from the core SPDF package into the proprietary module. Update file paths and package declarations from stirling.software.SPDF.controller.api to stirling.software.proprietary.controller.api for both the main class and its test to reflect the module reorganization.
2026-05-25 18:59:09 +02:00
Ludy87 63b00824bb Add PipelineConfigController for watched configs
Introduce a new controller to persist folder-scanning pipeline configs under the pipeline watched-folders path. Adds POST /watched-folders/config (ADMIN only) which validates the request, enforces path traversal protections, creates target directories, sanitizes filenames, pretty-prints the provided config to a .json file, and returns the saved path or error details. Includes a small DTO (SaveWatchedFolderConfigRequest) and a sanitizeFileName helper; IO errors are logged and returned as 500 responses.
2026-05-25 18:41:28 +02:00
2 changed files with 252 additions and 0 deletions
@@ -0,0 +1,121 @@
package stirling.software.proprietary.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;
}
}
@@ -0,0 +1,131 @@
package stirling.software.proprietary.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"));
}
}