mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-06 13:48:58 +02:00
Merge branch 'main' into refactor-tests
This commit is contained in:
commit
79021b69f6
1
.github/labeler-config-srvaroa.yml
vendored
1
.github/labeler-config-srvaroa.yml
vendored
@ -78,6 +78,7 @@ labels:
|
|||||||
- 'app/core/src/main/resources/banner.txt'
|
- 'app/core/src/main/resources/banner.txt'
|
||||||
- 'app/core/src/main/resources/static/python/png_to_webp.py'
|
- 'app/core/src/main/resources/static/python/png_to_webp.py'
|
||||||
- 'app/core/src/main/resources/static/python/split_photos.py'
|
- 'app/core/src/main/resources/static/python/split_photos.py'
|
||||||
|
- 'app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**'
|
||||||
- 'application.properties'
|
- 'application.properties'
|
||||||
|
|
||||||
- label: 'Security'
|
- label: 'Security'
|
||||||
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -124,10 +124,10 @@ SwaggerDoc.json
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.rar
|
*.rar
|
||||||
*.db
|
*.db
|
||||||
/build
|
build
|
||||||
/app/core/build
|
app/core/build
|
||||||
/app/common/build
|
app/common/build
|
||||||
/app/proprietary/build
|
app/proprietary/build
|
||||||
common/build
|
common/build
|
||||||
proprietary/build
|
proprietary/build
|
||||||
stirling-pdf/build
|
stirling-pdf/build
|
||||||
|
@ -3,7 +3,6 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8
|
|||||||
|
|
||||||
# Copy necessary files
|
# Copy necessary files
|
||||||
COPY scripts /scripts
|
COPY scripts /scripts
|
||||||
COPY pipeline /pipeline
|
|
||||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||||
COPY app/core/build/libs/*.jar app.jar
|
COPY app/core/build/libs/*.jar app.jar
|
||||||
|
|
||||||
|
@ -26,7 +26,6 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8
|
|||||||
|
|
||||||
# Copy necessary files
|
# Copy necessary files
|
||||||
COPY scripts /scripts
|
COPY scripts /scripts
|
||||||
COPY pipeline /pipeline
|
|
||||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||||
# first /app directory is for the build stage, second is for the final image
|
# first /app directory is for the build stage, second is for the final image
|
||||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||||
|
@ -21,7 +21,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
|
|||||||
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
|
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
|
||||||
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||||
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||||
COPY pipeline /pipeline
|
|
||||||
COPY app/core/build/libs/*.jar app.jar
|
COPY app/core/build/libs/*.jar app.jar
|
||||||
|
|
||||||
# Set up necessary directories and permissions
|
# Set up necessary directories and permissions
|
||||||
@ -39,10 +38,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
|
|||||||
su-exec \
|
su-exec \
|
||||||
openjdk21-jre && \
|
openjdk21-jre && \
|
||||||
# User permissions
|
# User permissions
|
||||||
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \
|
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
|
||||||
chmod +x /scripts/*.sh && \
|
chmod +x /scripts/*.sh && \
|
||||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \
|
||||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
|
@ -15,6 +15,7 @@ public class InstallationPathConfig {
|
|||||||
private static final String CUSTOM_FILES_PATH;
|
private static final String CUSTOM_FILES_PATH;
|
||||||
private static final String CLIENT_WEBUI_PATH;
|
private static final String CLIENT_WEBUI_PATH;
|
||||||
private static final String SCRIPTS_PATH;
|
private static final String SCRIPTS_PATH;
|
||||||
|
private static final String PIPELINE_PATH;
|
||||||
|
|
||||||
// Config paths
|
// Config paths
|
||||||
private static final String SETTINGS_PATH;
|
private static final String SETTINGS_PATH;
|
||||||
@ -33,6 +34,7 @@ public class InstallationPathConfig {
|
|||||||
CONFIG_PATH = BASE_PATH + "configs" + File.separator;
|
CONFIG_PATH = BASE_PATH + "configs" + File.separator;
|
||||||
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
|
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
|
||||||
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
|
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
|
||||||
|
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
|
||||||
|
|
||||||
// Initialize config paths
|
// Initialize config paths
|
||||||
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
|
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
|
||||||
@ -95,6 +97,10 @@ public class InstallationPathConfig {
|
|||||||
return SCRIPTS_PATH;
|
return SCRIPTS_PATH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String getPipelinePath() {
|
||||||
|
return PIPELINE_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
public static String getSettingsPath() {
|
public static String getSettingsPath() {
|
||||||
return SETTINGS_PATH;
|
return SETTINGS_PATH;
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,9 @@ import org.springframework.core.io.Resource;
|
|||||||
import org.springframework.core.io.support.EncodedResource;
|
import org.springframework.core.io.support.EncodedResource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
@ -58,7 +61,10 @@ public class ApplicationProperties {
|
|||||||
private Mail mail = new Mail();
|
private Mail mail = new Mail();
|
||||||
|
|
||||||
private Premium premium = new Premium();
|
private Premium premium = new Premium();
|
||||||
|
|
||||||
|
@JsonIgnore // Deprecated - completely hidden from JSON serialization
|
||||||
private EnterpriseEdition enterpriseEdition = new EnterpriseEdition();
|
private EnterpriseEdition enterpriseEdition = new EnterpriseEdition();
|
||||||
|
|
||||||
private AutoPipeline autoPipeline = new AutoPipeline();
|
private AutoPipeline autoPipeline = new AutoPipeline();
|
||||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||||
|
|
||||||
@ -168,14 +174,27 @@ public class ApplicationProperties {
|
|||||||
private Boolean autoCreateUser = false;
|
private Boolean autoCreateUser = false;
|
||||||
private Boolean blockRegistration = false;
|
private Boolean blockRegistration = false;
|
||||||
private String registrationId = "stirling";
|
private String registrationId = "stirling";
|
||||||
@ToString.Exclude private String idpMetadataUri;
|
|
||||||
|
@ToString.Exclude
|
||||||
|
@JsonProperty("idpMetadataUri")
|
||||||
|
private String idpMetadataUri;
|
||||||
|
|
||||||
private String idpSingleLogoutUrl;
|
private String idpSingleLogoutUrl;
|
||||||
private String idpSingleLoginUrl;
|
private String idpSingleLoginUrl;
|
||||||
private String idpIssuer;
|
private String idpIssuer;
|
||||||
private String idpCert;
|
|
||||||
@ToString.Exclude private String privateKey;
|
|
||||||
@ToString.Exclude private String spCert;
|
|
||||||
|
|
||||||
|
@JsonProperty("idpCert")
|
||||||
|
private String idpCert;
|
||||||
|
|
||||||
|
@ToString.Exclude
|
||||||
|
@JsonProperty("privateKey")
|
||||||
|
private String privateKey;
|
||||||
|
|
||||||
|
@ToString.Exclude
|
||||||
|
@JsonProperty("spCert")
|
||||||
|
private String spCert;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
public InputStream getIdpMetadataUri() throws IOException {
|
public InputStream getIdpMetadataUri() throws IOException {
|
||||||
if (idpMetadataUri.startsWith("classpath:")) {
|
if (idpMetadataUri.startsWith("classpath:")) {
|
||||||
return new ClassPathResource(idpMetadataUri.substring("classpath".length()))
|
return new ClassPathResource(idpMetadataUri.substring("classpath".length()))
|
||||||
@ -192,6 +211,7 @@ public class ApplicationProperties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
public Resource getSpCert() {
|
public Resource getSpCert() {
|
||||||
if (spCert == null) return null;
|
if (spCert == null) return null;
|
||||||
if (spCert.startsWith("classpath:")) {
|
if (spCert.startsWith("classpath:")) {
|
||||||
@ -201,6 +221,7 @@ public class ApplicationProperties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
public Resource getIdpCert() {
|
public Resource getIdpCert() {
|
||||||
if (idpCert == null) return null;
|
if (idpCert == null) return null;
|
||||||
if (idpCert.startsWith("classpath:")) {
|
if (idpCert.startsWith("classpath:")) {
|
||||||
@ -210,6 +231,7 @@ public class ApplicationProperties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
public Resource getPrivateKey() {
|
public Resource getPrivateKey() {
|
||||||
if (privateKey.startsWith("classpath:")) {
|
if (privateKey.startsWith("classpath:")) {
|
||||||
return new ClassPathResource(privateKey.substring("classpath:".length()));
|
return new ClassPathResource(privateKey.substring("classpath:".length()));
|
||||||
@ -321,8 +343,12 @@ public class ApplicationProperties {
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class TempFileManagement {
|
public static class TempFileManagement {
|
||||||
|
@JsonProperty("baseTmpDir")
|
||||||
private String baseTmpDir = "";
|
private String baseTmpDir = "";
|
||||||
|
|
||||||
|
@JsonProperty("libreofficeDir")
|
||||||
private String libreofficeDir = "";
|
private String libreofficeDir = "";
|
||||||
|
|
||||||
private String systemTempDir = "";
|
private String systemTempDir = "";
|
||||||
private String prefix = "stirling-pdf-";
|
private String prefix = "stirling-pdf-";
|
||||||
private long maxAgeHours = 24;
|
private long maxAgeHours = 24;
|
||||||
@ -330,12 +356,14 @@ public class ApplicationProperties {
|
|||||||
private boolean startupCleanup = true;
|
private boolean startupCleanup = true;
|
||||||
private boolean cleanupSystemTemp = false;
|
private boolean cleanupSystemTemp = false;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
public String getBaseTmpDir() {
|
public String getBaseTmpDir() {
|
||||||
return baseTmpDir != null && !baseTmpDir.isEmpty()
|
return baseTmpDir != null && !baseTmpDir.isEmpty()
|
||||||
? baseTmpDir
|
? baseTmpDir
|
||||||
: java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf";
|
: java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
public String getLibreofficeDir() {
|
public String getLibreofficeDir() {
|
||||||
return libreofficeDir != null && !libreofficeDir.isEmpty()
|
return libreofficeDir != null && !libreofficeDir.isEmpty()
|
||||||
? libreofficeDir
|
? libreofficeDir
|
||||||
@ -611,12 +639,24 @@ public class ApplicationProperties {
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class TimeoutMinutes {
|
public static class TimeoutMinutes {
|
||||||
|
@JsonProperty("libreOfficetimeoutMinutes")
|
||||||
private long libreOfficeTimeoutMinutes;
|
private long libreOfficeTimeoutMinutes;
|
||||||
|
|
||||||
|
@JsonProperty("pdfToHtmltimeoutMinutes")
|
||||||
private long pdfToHtmlTimeoutMinutes;
|
private long pdfToHtmlTimeoutMinutes;
|
||||||
|
|
||||||
|
@JsonProperty("pythonOpenCvtimeoutMinutes")
|
||||||
private long pythonOpenCvTimeoutMinutes;
|
private long pythonOpenCvTimeoutMinutes;
|
||||||
|
|
||||||
|
@JsonProperty("weasyPrinttimeoutMinutes")
|
||||||
private long weasyPrintTimeoutMinutes;
|
private long weasyPrintTimeoutMinutes;
|
||||||
|
|
||||||
|
@JsonProperty("installApptimeoutMinutes")
|
||||||
private long installAppTimeoutMinutes;
|
private long installAppTimeoutMinutes;
|
||||||
|
|
||||||
|
@JsonProperty("calibretimeoutMinutes")
|
||||||
private long calibreTimeoutMinutes;
|
private long calibreTimeoutMinutes;
|
||||||
|
|
||||||
private long tesseractTimeoutMinutes;
|
private long tesseractTimeoutMinutes;
|
||||||
private long qpdfTimeoutMinutes;
|
private long qpdfTimeoutMinutes;
|
||||||
private long ghostscriptTimeoutMinutes;
|
private long ghostscriptTimeoutMinutes;
|
||||||
|
@ -14,6 +14,7 @@ import java.util.Arrays;
|
|||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
@ -34,8 +35,16 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class GeneralUtils {
|
public class GeneralUtils {
|
||||||
|
|
||||||
private static final List<String> DEFAULT_VALID_SCRIPTS =
|
private static final Set<String> DEFAULT_VALID_SCRIPTS =
|
||||||
List.of("png_to_webp.py", "split_photos.py");
|
Set.of("png_to_webp.py", "split_photos.py");
|
||||||
|
private static final Set<String> DEFAULT_VALID_PIPELINE =
|
||||||
|
Set.of(
|
||||||
|
"OCR images.json",
|
||||||
|
"Prepare-pdfs-for-email.json",
|
||||||
|
"split-rotate-auto-rename.json");
|
||||||
|
|
||||||
|
private static final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||||
|
private static final String PYTHON_SCRIPTS_DIR = "python";
|
||||||
|
|
||||||
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
||||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||||
@ -447,7 +456,46 @@ public class GeneralUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts a file from classpath:/static/python to a temporary directory and returns the path.
|
* Extracts the default pipeline configurations from the classpath to the installation path.
|
||||||
|
* Creates directories if needed and copies default JSON files.
|
||||||
|
*
|
||||||
|
* <p>Existing files will be overwritten atomically (when supported). In case of unsupported
|
||||||
|
* atomic moves, falls back to non-atomic replace.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs during file operations
|
||||||
|
*/
|
||||||
|
public static void extractPipeline() throws IOException {
|
||||||
|
Path pipelineDir =
|
||||||
|
Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
|
||||||
|
Files.createDirectories(pipelineDir);
|
||||||
|
|
||||||
|
for (String name : DEFAULT_VALID_PIPELINE) {
|
||||||
|
if (!Paths.get(name).getFileName().toString().equals(name)) {
|
||||||
|
log.error("Invalid pipeline file name: {}", name);
|
||||||
|
throw new IllegalArgumentException("Invalid pipeline file name: " + name);
|
||||||
|
}
|
||||||
|
Path target = pipelineDir.resolve(name);
|
||||||
|
ClassPathResource res =
|
||||||
|
new ClassPathResource(
|
||||||
|
"static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name);
|
||||||
|
if (!res.exists()) {
|
||||||
|
log.error("Resource not found: {}", res.getPath());
|
||||||
|
throw new IOException("Resource not found: " + res.getPath());
|
||||||
|
}
|
||||||
|
copyResourceToFile(res, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the specified Python script from the classpath to the installation path. Validates
|
||||||
|
* name and copies file atomically when possible, overwriting existing.
|
||||||
|
*
|
||||||
|
* <p>Existing files will be overwritten atomically (when supported).
|
||||||
|
*
|
||||||
|
* @param scriptName the name of the script to extract
|
||||||
|
* @return the path to the extracted script
|
||||||
|
* @throws IllegalArgumentException if the script name is invalid or not allowed
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
*/
|
*/
|
||||||
public static Path extractScript(String scriptName) throws IOException {
|
public static Path extractScript(String scriptName) throws IOException {
|
||||||
// Validate input
|
// Validate input
|
||||||
@ -458,26 +506,71 @@ public class GeneralUtils {
|
|||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"scriptName must not contain path traversal characters");
|
"scriptName must not contain path traversal characters");
|
||||||
}
|
}
|
||||||
|
if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"scriptName must not contain path traversal characters");
|
||||||
|
}
|
||||||
|
|
||||||
if (!DEFAULT_VALID_SCRIPTS.contains(scriptName)) {
|
if (!DEFAULT_VALID_SCRIPTS.contains(scriptName)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"scriptName must be either 'png_to_webp.py' or 'split_photos.py'");
|
"scriptName must be either 'png_to_webp.py' or 'split_photos.py'");
|
||||||
}
|
}
|
||||||
|
|
||||||
Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), "python");
|
Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
|
||||||
Files.createDirectories(scriptsDir);
|
Files.createDirectories(scriptsDir);
|
||||||
|
|
||||||
Path scriptFile = scriptsDir.resolve(scriptName);
|
Path target = scriptsDir.resolve(scriptName);
|
||||||
if (!Files.exists(scriptFile)) {
|
ClassPathResource res =
|
||||||
ClassPathResource resource = new ClassPathResource("static/python/" + scriptName);
|
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
|
||||||
try (InputStream in = resource.getInputStream()) {
|
if (!res.exists()) {
|
||||||
Files.copy(in, scriptFile, StandardCopyOption.REPLACE_EXISTING);
|
log.error("Resource not found: {}", res.getPath());
|
||||||
|
throw new IOException("Resource not found: " + res.getPath());
|
||||||
|
}
|
||||||
|
copyResourceToFile(res, target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a resource from the classpath to a specified target file.
|
||||||
|
*
|
||||||
|
* @param resource the ClassPathResource to copy
|
||||||
|
* @param target the target Path where the resource will be copied
|
||||||
|
* @throws IOException if an I/O error occurs during the copy operation
|
||||||
|
*/
|
||||||
|
private static void copyResourceToFile(ClassPathResource resource, Path target)
|
||||||
|
throws IOException {
|
||||||
|
Path dir = target.getParent();
|
||||||
|
Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp");
|
||||||
|
try (InputStream in = resource.getInputStream()) {
|
||||||
|
Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
try {
|
||||||
|
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (AtomicMoveNotSupportedException e) {
|
||||||
|
log.warn(
|
||||||
|
"Atomic move not supported, falling back to non-atomic move for {}",
|
||||||
|
target,
|
||||||
|
e);
|
||||||
|
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} catch (FileAlreadyExistsException e) {
|
||||||
|
log.debug("File already exists at {}, attempting to replace it.", target);
|
||||||
|
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AccessDeniedException e) {
|
||||||
|
log.error("Access denied while attempting to copy resource to {}", target, e);
|
||||||
|
throw e;
|
||||||
|
} catch (FileSystemException e) {
|
||||||
|
log.error("File system error occurred while copying resource to {}", target, e);
|
||||||
|
throw e;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to copy resource to {}", target, e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tmp);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to extract Python script", e);
|
log.warn("Failed to delete temporary file {}", tmp, e);
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return scriptFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isVersionHigher(String currentVersion, String compareVersion) {
|
public static boolean isVersionHigher(String currentVersion, String compareVersion) {
|
||||||
|
3
app/core/.gitignore
vendored
3
app/core/.gitignore
vendored
@ -16,8 +16,7 @@ local.properties
|
|||||||
version.properties
|
version.properties
|
||||||
|
|
||||||
#### Stirling-PDF Files ###
|
#### Stirling-PDF Files ###
|
||||||
pipeline/watchedFolders/
|
pipeline/*
|
||||||
pipeline/finishedFolders/
|
|
||||||
customFiles/
|
customFiles/
|
||||||
configs/
|
configs/
|
||||||
watchedFolders/
|
watchedFolders/
|
||||||
|
@ -36,9 +36,15 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
|||||||
public boolean preHandle(
|
public boolean preHandle(
|
||||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
String requestURI = request.getRequestURI();
|
||||||
|
|
||||||
|
// Skip URL cleaning for API endpoints - they need their own parameter handling
|
||||||
|
if (requestURI.contains("/api/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
String queryString = request.getQueryString();
|
String queryString = request.getQueryString();
|
||||||
if (queryString != null && !queryString.isEmpty()) {
|
if (queryString != null && !queryString.isEmpty()) {
|
||||||
String requestURI = request.getRequestURI();
|
|
||||||
Map<String, String> allowedParameters = new HashMap<>();
|
Map<String, String> allowedParameters = new HashMap<>();
|
||||||
|
|
||||||
// Keep only the allowed parameters
|
// Keep only the allowed parameters
|
||||||
|
@ -421,7 +421,6 @@ public class EndpointConfiguration {
|
|||||||
|
|
||||||
// file-to-pdf has multiple implementations
|
// file-to-pdf has multiple implementations
|
||||||
addEndpointAlternative("file-to-pdf", "LibreOffice");
|
addEndpointAlternative("file-to-pdf", "LibreOffice");
|
||||||
addEndpointAlternative("file-to-pdf", "Python");
|
|
||||||
addEndpointAlternative("file-to-pdf", "Unoconvert");
|
addEndpointAlternative("file-to-pdf", "Unoconvert");
|
||||||
|
|
||||||
// pdf-to-html and pdf-to-markdown can use either LibreOffice or Pdftohtml
|
// pdf-to-html and pdf-to-markdown can use either LibreOffice or Pdftohtml
|
||||||
|
@ -35,6 +35,7 @@ public class InitialSetup {
|
|||||||
initEnableCSRFSecurity();
|
initEnableCSRFSecurity();
|
||||||
initLegalUrls();
|
initLegalUrls();
|
||||||
initSetAppVersion();
|
initSetAppVersion();
|
||||||
|
GeneralUtils.extractPipeline();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void initUUIDKey() throws IOException {
|
public void initUUIDKey() throws IOException {
|
||||||
|
@ -82,55 +82,62 @@ document.querySelector("#navbarSearchInput").addEventListener("input", function
|
|||||||
resultsBox.style.width = window.navItemMaxWidth + "px";
|
resultsBox.style.width = window.navItemMaxWidth + "px";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const searchDropdown = document.getElementById('searchDropdown');
|
||||||
|
const searchInput = document.getElementById('navbarSearchInput');
|
||||||
|
|
||||||
const searchDropdown = document.getElementById('searchDropdown');
|
// Check if elements are missing and skip initialization if necessary
|
||||||
const searchInput = document.getElementById('navbarSearchInput');
|
if (!searchDropdown || !searchInput) {
|
||||||
|
console.warn('Search dropdown or input not found. Skipping initialization.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dropdownMenu = searchDropdown.querySelector('.dropdown-menu');
|
||||||
|
if (!dropdownMenu) {
|
||||||
|
console.warn('Dropdown menu not found within the search dropdown. Skipping initialization.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if elements exist before proceeding
|
// Create a single dropdown instance
|
||||||
if (searchDropdown && searchInput) {
|
const dropdownInstance = new bootstrap.Dropdown(searchDropdown);
|
||||||
const dropdownMenu = searchDropdown.querySelector('.dropdown-menu');
|
|
||||||
|
|
||||||
// Create a single dropdown instance
|
// Handle click for mobile
|
||||||
const dropdownInstance = new bootstrap.Dropdown(searchDropdown);
|
searchDropdown.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
// Handle click for mobile
|
const isOpen = dropdownMenu.classList.contains('show');
|
||||||
searchDropdown.addEventListener('click', function (e) {
|
// Close all other open dropdowns
|
||||||
e.preventDefault();
|
document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => {
|
||||||
const isOpen = dropdownMenu.classList.contains('show');
|
if (menu !== dropdownMenu) {
|
||||||
// Close all other open dropdowns
|
const parentDropdown = menu.closest('.dropdown');
|
||||||
document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => {
|
if (parentDropdown) {
|
||||||
if (menu !== dropdownMenu) {
|
const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]');
|
||||||
const parentDropdown = menu.closest('.dropdown');
|
if (parentToggle) {
|
||||||
if (parentDropdown) {
|
let instance = bootstrap.Dropdown.getInstance(parentToggle);
|
||||||
const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]');
|
if (!instance) {
|
||||||
if (parentToggle) {
|
instance = new bootstrap.Dropdown(parentToggle);
|
||||||
let instance = bootstrap.Dropdown.getInstance(parentToggle);
|
|
||||||
if (!instance) {
|
|
||||||
instance = new bootstrap.Dropdown(parentToggle);
|
|
||||||
}
|
|
||||||
instance.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
instance.hide();
|
||||||
if (!isOpen) {
|
}
|
||||||
dropdownInstance.show();
|
|
||||||
setTimeout(() => searchInput.focus(), 150);
|
|
||||||
} else {
|
|
||||||
dropdownInstance.hide();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
if (!isOpen) {
|
||||||
|
dropdownInstance.show();
|
||||||
|
setTimeout(() => searchInput.focus(), 150);
|
||||||
|
} else {
|
||||||
|
dropdownInstance.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Hide dropdown if it's open and user clicks outside
|
// Hide dropdown if it's open and user clicks outside
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) {
|
if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) {
|
||||||
dropdownInstance.hide();
|
dropdownInstance.hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep dropdown open if search input is clicked
|
// Keep dropdown open if search input is clicked
|
||||||
searchInput.addEventListener('click', function (e) {
|
searchInput.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
});
|
||||||
|
@ -0,0 +1,633 @@
|
|||||||
|
package stirling.software.proprietary.security.controller.api;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.util.GeneralUtils;
|
||||||
|
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
||||||
|
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
||||||
|
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
|
||||||
|
@RequestMapping("/api/v1/admin/settings")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
@Slf4j
|
||||||
|
public class AdminSettingsController {
|
||||||
|
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
// Track settings that have been modified but not yet applied (require restart)
|
||||||
|
private static final ConcurrentHashMap<String, Object> pendingChanges =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Define specific sensitive field names that contain secret values
|
||||||
|
private static final Set<String> SENSITIVE_FIELD_NAMES =
|
||||||
|
new HashSet<>(
|
||||||
|
Arrays.asList(
|
||||||
|
// Passwords
|
||||||
|
"password",
|
||||||
|
"dbpassword",
|
||||||
|
"mailpassword",
|
||||||
|
"smtppassword",
|
||||||
|
// OAuth/API secrets
|
||||||
|
"clientsecret",
|
||||||
|
"apisecret",
|
||||||
|
"secret",
|
||||||
|
// API tokens
|
||||||
|
"apikey",
|
||||||
|
"accesstoken",
|
||||||
|
"refreshtoken",
|
||||||
|
"token",
|
||||||
|
// Specific secret keys (not all keys, and excluding premium.key)
|
||||||
|
"key", // automaticallyGenerated.key
|
||||||
|
"enterprisekey",
|
||||||
|
"licensekey"));
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(
|
||||||
|
summary = "Get all application settings",
|
||||||
|
description =
|
||||||
|
"Retrieve all current application settings. Use includePending=true to include settings that will take effect after restart. Admin access required.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Settings retrieved successfully"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Access denied - Admin role required")
|
||||||
|
})
|
||||||
|
public ResponseEntity<?> getSettings(
|
||||||
|
@RequestParam(value = "includePending", defaultValue = "false")
|
||||||
|
boolean includePending) {
|
||||||
|
log.debug("Admin requested all application settings (includePending={})", includePending);
|
||||||
|
|
||||||
|
// Convert ApplicationProperties to Map
|
||||||
|
Map<String, Object> settings = objectMapper.convertValue(applicationProperties, Map.class);
|
||||||
|
|
||||||
|
if (includePending && !pendingChanges.isEmpty()) {
|
||||||
|
// Merge pending changes into the settings map
|
||||||
|
settings = mergePendingChanges(settings, pendingChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask sensitive fields after merging
|
||||||
|
Map<String, Object> maskedSettings = maskSensitiveFields(settings);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(maskedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/delta")
|
||||||
|
@Operation(
|
||||||
|
summary = "Get pending settings changes",
|
||||||
|
description =
|
||||||
|
"Retrieve settings that have been modified but not yet applied (require restart). Admin access required.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Pending changes retrieved successfully"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Access denied - Admin role required")
|
||||||
|
})
|
||||||
|
public ResponseEntity<?> getSettingsDelta() {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
// Mask sensitive fields in pending changes
|
||||||
|
response.put("pendingChanges", maskSensitiveFields(new HashMap<>(pendingChanges)));
|
||||||
|
response.put("hasPendingChanges", !pendingChanges.isEmpty());
|
||||||
|
response.put("count", pendingChanges.size());
|
||||||
|
|
||||||
|
log.debug("Admin requested pending changes - found {} settings", pendingChanges.size());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
@Operation(
|
||||||
|
summary = "Update application settings (delta updates)",
|
||||||
|
description =
|
||||||
|
"Update specific application settings using dot notation keys. Only sends changed values. Changes take effect on restart. Admin access required.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Settings updated successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid setting key or value"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Access denied - Admin role required"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "Failed to save settings to configuration file")
|
||||||
|
})
|
||||||
|
public ResponseEntity<String> updateSettings(
|
||||||
|
@Valid @RequestBody UpdateSettingsRequest request) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> settings = request.getSettings();
|
||||||
|
if (settings == null || settings.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body("No settings provided to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
int updatedCount = 0;
|
||||||
|
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
Object value = entry.getValue();
|
||||||
|
|
||||||
|
if (!isValidSettingKey(key)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Admin updating setting: {} = {}", key, value);
|
||||||
|
GeneralUtils.saveKeyToSettings(key, value);
|
||||||
|
|
||||||
|
// Track this as a pending change
|
||||||
|
pendingChanges.put(key, value);
|
||||||
|
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
String.format(
|
||||||
|
"Successfully updated %d setting(s). Changes will take effect on application restart.",
|
||||||
|
updatedCount));
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to save settings to file: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
|
||||||
|
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("Invalid setting key or value: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Unexpected error while updating settings: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(GENERIC_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/section/{sectionName}")
|
||||||
|
@Operation(
|
||||||
|
summary = "Get specific settings section",
|
||||||
|
description =
|
||||||
|
"Retrieve settings for a specific section (e.g., security, system, ui). Admin access required.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Section settings retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid section name"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Access denied - Admin role required")
|
||||||
|
})
|
||||||
|
public ResponseEntity<?> getSettingsSection(@PathVariable String sectionName) {
|
||||||
|
try {
|
||||||
|
Object sectionData = getSectionData(sectionName);
|
||||||
|
if (sectionData == null) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(
|
||||||
|
"Invalid section name: "
|
||||||
|
+ HtmlUtils.htmlEscape(sectionName)
|
||||||
|
+ ". Valid sections: "
|
||||||
|
+ String.join(", ", VALID_SECTION_NAMES));
|
||||||
|
}
|
||||||
|
log.debug("Admin requested settings section: {}", sectionName);
|
||||||
|
return ResponseEntity.ok(sectionData);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body("Invalid section name: " + HtmlUtils.htmlEscape(sectionName));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error retrieving section {}: {}", sectionName, e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Failed to retrieve section.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/section/{sectionName}")
|
||||||
|
@Operation(
|
||||||
|
summary = "Update specific settings section",
|
||||||
|
description = "Update all settings within a specific section. Admin access required.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Section settings updated successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid section name or data"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Access denied - Admin role required"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Failed to save settings")
|
||||||
|
})
|
||||||
|
public ResponseEntity<String> updateSettingsSection(
|
||||||
|
@PathVariable String sectionName, @Valid @RequestBody Map<String, Object> sectionData) {
|
||||||
|
try {
|
||||||
|
if (sectionData == null || sectionData.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body("No section data provided to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidSectionName(sectionName)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(
|
||||||
|
"Invalid section name: "
|
||||||
|
+ HtmlUtils.htmlEscape(sectionName)
|
||||||
|
+ ". Valid sections: "
|
||||||
|
+ String.join(", ", VALID_SECTION_NAMES));
|
||||||
|
}
|
||||||
|
|
||||||
|
int updatedCount = 0;
|
||||||
|
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
|
||||||
|
String propertyKey = entry.getKey();
|
||||||
|
String fullKey = sectionName + "." + propertyKey;
|
||||||
|
Object value = entry.getValue();
|
||||||
|
|
||||||
|
if (!isValidSettingKey(fullKey)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(fullKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Admin updating section setting: {} = {}", fullKey, value);
|
||||||
|
GeneralUtils.saveKeyToSettings(fullKey, value);
|
||||||
|
|
||||||
|
// Track this as a pending change
|
||||||
|
pendingChanges.put(fullKey, value);
|
||||||
|
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
String escapedSectionName = HtmlUtils.htmlEscape(sectionName);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
String.format(
|
||||||
|
"Successfully updated %d setting(s) in section '%s'. Changes will take effect on application restart.",
|
||||||
|
updatedCount, escapedSectionName));
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to save section settings to file: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("Invalid section data: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SECTION);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Unexpected error while updating section settings: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(GENERIC_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/key/{key}")
|
||||||
|
@Operation(
|
||||||
|
summary = "Get specific setting value",
|
||||||
|
description =
|
||||||
|
"Retrieve value for a specific setting key using dot notation. Admin access required.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Setting value retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid setting key"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Access denied - Admin role required")
|
||||||
|
})
|
||||||
|
public ResponseEntity<?> getSettingValue(@PathVariable String key) {
|
||||||
|
try {
|
||||||
|
if (!isValidSettingKey(key)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
Object value = getSettingByKey(key);
|
||||||
|
if (value == null) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
|
||||||
|
}
|
||||||
|
log.debug("Admin requested setting: {}", key);
|
||||||
|
return ResponseEntity.ok(new SettingValueResponse(key, value));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("Invalid setting key {}: {}", key, e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body("Invalid setting key: " + HtmlUtils.htmlEscape(key));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error retrieving setting {}: {}", key, e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Failed to retrieve setting.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/key/{key}")
|
||||||
|
@Operation(
|
||||||
|
summary = "Update specific setting value",
|
||||||
|
description =
|
||||||
|
"Update value for a specific setting key using dot notation. Admin access required.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Setting updated successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid setting key or value"),
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Access denied - Admin role required"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Failed to save setting")
|
||||||
|
})
|
||||||
|
public ResponseEntity<String> updateSettingValue(
|
||||||
|
@PathVariable String key, @Valid @RequestBody UpdateSettingValueRequest request) {
|
||||||
|
try {
|
||||||
|
if (!isValidSettingKey(key)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
Object value = request.getValue();
|
||||||
|
log.info("Admin updating single setting: {} = {}", key, value);
|
||||||
|
GeneralUtils.saveKeyToSettings(key, value);
|
||||||
|
|
||||||
|
// Track this as a pending change
|
||||||
|
pendingChanges.put(key, value);
|
||||||
|
|
||||||
|
String escapedKey = HtmlUtils.htmlEscape(key);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
String.format(
|
||||||
|
"Successfully updated setting '%s'. Changes will take effect on application restart.",
|
||||||
|
escapedKey));
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to save setting to file: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("Invalid setting key or value: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Unexpected error while updating setting: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(GENERIC_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object getSectionData(String sectionName) {
|
||||||
|
if (sectionName == null || sectionName.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (sectionName.toLowerCase()) {
|
||||||
|
case "security" -> applicationProperties.getSecurity();
|
||||||
|
case "system" -> applicationProperties.getSystem();
|
||||||
|
case "ui" -> applicationProperties.getUi();
|
||||||
|
case "endpoints" -> applicationProperties.getEndpoints();
|
||||||
|
case "metrics" -> applicationProperties.getMetrics();
|
||||||
|
case "mail" -> applicationProperties.getMail();
|
||||||
|
case "premium" -> applicationProperties.getPremium();
|
||||||
|
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
|
||||||
|
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
|
||||||
|
case "legal" -> applicationProperties.getLegal();
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValidSectionName(String sectionName) {
|
||||||
|
return getSectionData(sectionName) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final java.util.Set<String> VALID_SECTION_NAMES =
|
||||||
|
java.util.Set.of(
|
||||||
|
"security",
|
||||||
|
"system",
|
||||||
|
"ui",
|
||||||
|
"endpoints",
|
||||||
|
"metrics",
|
||||||
|
"mail",
|
||||||
|
"premium",
|
||||||
|
"processExecutor",
|
||||||
|
"processexecutor",
|
||||||
|
"autoPipeline",
|
||||||
|
"autopipeline",
|
||||||
|
"legal");
|
||||||
|
|
||||||
|
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
|
||||||
|
private static final Pattern SAFE_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9._]+$");
|
||||||
|
private static final int MAX_NESTING_DEPTH = 10;
|
||||||
|
|
||||||
|
// Security: Generic error messages to prevent information disclosure
|
||||||
|
private static final String GENERIC_INVALID_SETTING = "Invalid setting key or value.";
|
||||||
|
private static final String GENERIC_INVALID_SECTION = "Invalid section data provided.";
|
||||||
|
private static final String GENERIC_SERVER_ERROR = "Internal server error occurred.";
|
||||||
|
private static final String GENERIC_FILE_ERROR =
|
||||||
|
"Failed to save settings to configuration file.";
|
||||||
|
|
||||||
|
private boolean isValidSettingKey(String key) {
|
||||||
|
if (key == null || key.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against pattern to prevent injection attacks
|
||||||
|
if (!SAFE_KEY_PATTERN.matcher(key).matches()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent excessive nesting depth
|
||||||
|
String[] parts = key.split("\\.");
|
||||||
|
if (parts.length > MAX_NESTING_DEPTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure first part is a valid section name
|
||||||
|
if (parts.length > 0 && !VALID_SECTION_NAMES.contains(parts[0].toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object getSettingByKey(String key) {
|
||||||
|
if (key == null || key.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] parts = key.split("\\.", 2);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sectionName = parts[0];
|
||||||
|
String propertyPath = parts[1];
|
||||||
|
Object section = getSectionData(sectionName);
|
||||||
|
|
||||||
|
if (section == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return getNestedProperty(section, propertyPath, 0);
|
||||||
|
} catch (NoSuchFieldException | IllegalAccessException e) {
|
||||||
|
log.warn("Failed to get nested property {}: {}", key, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object getNestedProperty(Object obj, String propertyPath, int depth)
|
||||||
|
throws NoSuchFieldException, IllegalAccessException {
|
||||||
|
if (obj == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent excessive recursion depth
|
||||||
|
if (depth > MAX_NESTING_DEPTH) {
|
||||||
|
throw new IllegalAccessException("Maximum nesting depth exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use Jackson ObjectMapper for safer property access
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> objectMap = objectMapper.convertValue(obj, Map.class);
|
||||||
|
|
||||||
|
String[] parts = propertyPath.split("\\.", 2);
|
||||||
|
String currentProperty = parts[0];
|
||||||
|
|
||||||
|
if (!objectMap.containsKey(currentProperty)) {
|
||||||
|
throw new NoSuchFieldException("Property not found: " + currentProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object value = objectMap.get(currentProperty);
|
||||||
|
|
||||||
|
if (parts.length == 1) {
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
return getNestedProperty(value, parts[1], depth + 1);
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// If Jackson fails, the property doesn't exist or isn't accessible
|
||||||
|
throw new NoSuchFieldException("Property not accessible: " + propertyPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively mask sensitive fields in settings map. Sensitive fields are replaced with a
|
||||||
|
* status indicator showing if they're configured.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> maskSensitiveFields(Map<String, Object> settings) {
|
||||||
|
return maskSensitiveFieldsWithPath(settings, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> maskSensitiveFieldsWithPath(
|
||||||
|
Map<String, Object> settings, String path) {
|
||||||
|
Map<String, Object> masked = new HashMap<>();
|
||||||
|
|
||||||
|
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
Object value = entry.getValue();
|
||||||
|
String currentPath = path.isEmpty() ? key : path + "." + key;
|
||||||
|
|
||||||
|
if (value instanceof Map) {
|
||||||
|
// Recursively mask nested objects
|
||||||
|
masked.put(
|
||||||
|
key, maskSensitiveFieldsWithPath((Map<String, Object>) value, currentPath));
|
||||||
|
} else if (isSensitiveFieldWithPath(key, currentPath)) {
|
||||||
|
// Mask sensitive fields with status indicator
|
||||||
|
masked.put(key, createMaskedValue(value));
|
||||||
|
} else {
|
||||||
|
// Keep non-sensitive fields as-is
|
||||||
|
masked.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a field name indicates sensitive data with full path context */
|
||||||
|
private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) {
|
||||||
|
String lowerField = fieldName.toLowerCase();
|
||||||
|
String lowerPath = fullPath.toLowerCase();
|
||||||
|
|
||||||
|
// Don't mask premium.key specifically
|
||||||
|
if ("key".equals(lowerField) && "premium.key".equals(lowerPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct match with sensitive field names
|
||||||
|
if (SENSITIVE_FIELD_NAMES.contains(lowerField)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for fields containing 'password' or 'secret'
|
||||||
|
return lowerField.contains("password") || lowerField.contains("secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a masked representation for sensitive fields */
|
||||||
|
private Object createMaskedValue(Object originalValue) {
|
||||||
|
if (originalValue == null
|
||||||
|
|| (originalValue instanceof String && ((String) originalValue).trim().isEmpty())) {
|
||||||
|
return originalValue; // Keep empty/null values as-is
|
||||||
|
} else {
|
||||||
|
return "********";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge pending changes into the settings map using dot notation keys */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> mergePendingChanges(
|
||||||
|
Map<String, Object> settings, Map<String, Object> pendingChanges) {
|
||||||
|
// Create a deep copy of the settings to avoid modifying the original
|
||||||
|
Map<String, Object> mergedSettings = new HashMap<>(settings);
|
||||||
|
|
||||||
|
for (Map.Entry<String, Object> pendingEntry : pendingChanges.entrySet()) {
|
||||||
|
String dotNotationKey = pendingEntry.getKey();
|
||||||
|
Object pendingValue = pendingEntry.getValue();
|
||||||
|
|
||||||
|
// Split the dot notation key into parts
|
||||||
|
String[] keyParts = dotNotationKey.split("\\.");
|
||||||
|
|
||||||
|
// Navigate to the parent object and set the value
|
||||||
|
Map<String, Object> currentMap = mergedSettings;
|
||||||
|
|
||||||
|
// Navigate through all parts except the last one
|
||||||
|
for (int i = 0; i < keyParts.length - 1; i++) {
|
||||||
|
String keyPart = keyParts[i];
|
||||||
|
|
||||||
|
// Get or create the nested map
|
||||||
|
Object nested = currentMap.get(keyPart);
|
||||||
|
if (!(nested instanceof Map)) {
|
||||||
|
// Create a new nested map if it doesn't exist or isn't a map
|
||||||
|
nested = new HashMap<String, Object>();
|
||||||
|
currentMap.put(keyPart, nested);
|
||||||
|
}
|
||||||
|
currentMap = (Map<String, Object>) nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the final value
|
||||||
|
String finalKey = keyParts[keyParts.length - 1];
|
||||||
|
currentMap.put(finalKey, pendingValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedSettings;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package stirling.software.proprietary.security.model.api.admin;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "Response containing a setting key and its current value")
|
||||||
|
public class SettingValueResponse {
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
description = "The setting key in dot notation (e.g., 'system.enableAnalytics')",
|
||||||
|
example = "system.enableAnalytics")
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
@Schema(description = "The current value of the setting", example = "true")
|
||||||
|
private Object value;
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package stirling.software.proprietary.security.model.api.admin;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "Request to update a single setting value")
|
||||||
|
public class UpdateSettingValueRequest {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Schema(description = "The new value for the setting", example = "false")
|
||||||
|
private Object value;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package stirling.software.proprietary.security.model.api.admin;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "Request to update multiple application settings using delta updates")
|
||||||
|
public class UpdateSettingsRequest {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@NotEmpty
|
||||||
|
@Schema(
|
||||||
|
description =
|
||||||
|
"Map of setting keys to their new values using dot notation. Only changed values need to be included for delta updates.",
|
||||||
|
example = "{\"system.enableAnalytics\": false, \"ui.appName\": \"My PDF Tool\"}")
|
||||||
|
private Map<String, Object> settings;
|
||||||
|
}
|
@ -57,7 +57,7 @@ repositories {
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = 'stirling.software'
|
group = 'stirling.software'
|
||||||
version = '1.1.0'
|
version = '1.1.1'
|
||||||
|
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
exclude group: 'commons-logging', module: 'commons-logging'
|
exclude group: 'commons-logging', module: 'commons-logging'
|
||||||
|
Loading…
Reference in New Issue
Block a user