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.
This commit is contained in:
Ludy87
2026-05-25 18:41:28 +02:00
parent 5f78083470
commit 63b00824bb
2 changed files with 252 additions and 0 deletions
@@ -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;
}
}
@@ -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"));
}
}