mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 02:25:21 +00:00
V2 settings api (Added to V1) (#4015)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: a <a> Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
This commit is contained in:
parent
043db37dfb
commit
85f5cccf04
8
.gitignore
vendored
8
.gitignore
vendored
@ -124,10 +124,10 @@ SwaggerDoc.json
|
||||
*.tar.gz
|
||||
*.rar
|
||||
*.db
|
||||
/build
|
||||
/app/core/build
|
||||
/app/common/build
|
||||
/app/proprietary/build
|
||||
build
|
||||
app/core/build
|
||||
app/common/build
|
||||
app/proprietary/build
|
||||
common/build
|
||||
proprietary/build
|
||||
stirling-pdf/build
|
||||
|
@ -25,6 +25,9 @@ import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.EncodedResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
@ -58,7 +61,10 @@ public class ApplicationProperties {
|
||||
private Mail mail = new Mail();
|
||||
|
||||
private Premium premium = new Premium();
|
||||
|
||||
@JsonIgnore // Deprecated - completely hidden from JSON serialization
|
||||
private EnterpriseEdition enterpriseEdition = new EnterpriseEdition();
|
||||
|
||||
private AutoPipeline autoPipeline = new AutoPipeline();
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
|
||||
@ -168,14 +174,27 @@ public class ApplicationProperties {
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean blockRegistration = false;
|
||||
private String registrationId = "stirling";
|
||||
@ToString.Exclude private String idpMetadataUri;
|
||||
|
||||
@ToString.Exclude
|
||||
@JsonProperty("idpMetadataUri")
|
||||
private String idpMetadataUri;
|
||||
|
||||
private String idpSingleLogoutUrl;
|
||||
private String idpSingleLoginUrl;
|
||||
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 {
|
||||
if (idpMetadataUri.startsWith("classpath:")) {
|
||||
return new ClassPathResource(idpMetadataUri.substring("classpath".length()))
|
||||
@ -192,6 +211,7 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getSpCert() {
|
||||
if (spCert == null) return null;
|
||||
if (spCert.startsWith("classpath:")) {
|
||||
@ -201,6 +221,7 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getIdpCert() {
|
||||
if (idpCert == null) return null;
|
||||
if (idpCert.startsWith("classpath:")) {
|
||||
@ -210,6 +231,7 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getPrivateKey() {
|
||||
if (privateKey.startsWith("classpath:")) {
|
||||
return new ClassPathResource(privateKey.substring("classpath:".length()));
|
||||
@ -321,8 +343,12 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class TempFileManagement {
|
||||
@JsonProperty("baseTmpDir")
|
||||
private String baseTmpDir = "";
|
||||
|
||||
@JsonProperty("libreofficeDir")
|
||||
private String libreofficeDir = "";
|
||||
|
||||
private String systemTempDir = "";
|
||||
private String prefix = "stirling-pdf-";
|
||||
private long maxAgeHours = 24;
|
||||
@ -330,12 +356,14 @@ public class ApplicationProperties {
|
||||
private boolean startupCleanup = true;
|
||||
private boolean cleanupSystemTemp = false;
|
||||
|
||||
@JsonIgnore
|
||||
public String getBaseTmpDir() {
|
||||
return baseTmpDir != null && !baseTmpDir.isEmpty()
|
||||
? baseTmpDir
|
||||
: java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf";
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getLibreofficeDir() {
|
||||
return libreofficeDir != null && !libreofficeDir.isEmpty()
|
||||
? libreofficeDir
|
||||
@ -611,12 +639,24 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class TimeoutMinutes {
|
||||
@JsonProperty("libreOfficetimeoutMinutes")
|
||||
private long libreOfficeTimeoutMinutes;
|
||||
|
||||
@JsonProperty("pdfToHtmltimeoutMinutes")
|
||||
private long pdfToHtmlTimeoutMinutes;
|
||||
|
||||
@JsonProperty("pythonOpenCvtimeoutMinutes")
|
||||
private long pythonOpenCvTimeoutMinutes;
|
||||
|
||||
@JsonProperty("weasyPrinttimeoutMinutes")
|
||||
private long weasyPrintTimeoutMinutes;
|
||||
|
||||
@JsonProperty("installApptimeoutMinutes")
|
||||
private long installAppTimeoutMinutes;
|
||||
|
||||
@JsonProperty("calibretimeoutMinutes")
|
||||
private long calibreTimeoutMinutes;
|
||||
|
||||
private long tesseractTimeoutMinutes;
|
||||
private long qpdfTimeoutMinutes;
|
||||
private long ghostscriptTimeoutMinutes;
|
||||
|
@ -4,7 +4,11 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.net.*;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
@ -15,6 +19,9 @@ import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@ -37,6 +44,33 @@ public class GeneralUtils {
|
||||
private static final List<String> DEFAULT_VALID_SCRIPTS =
|
||||
List.of("png_to_webp.py", "split_photos.py");
|
||||
|
||||
// Concurrency control for settings file operations
|
||||
private static final ReentrantReadWriteLock settingsLock =
|
||||
new ReentrantReadWriteLock(true); // fair locking
|
||||
private static volatile String lastSettingsHash = null;
|
||||
|
||||
// Lock timeout configuration
|
||||
private static final long LOCK_TIMEOUT_SECONDS = 30; // Maximum time to wait for locks
|
||||
private static final long FILE_LOCK_TIMEOUT_MS = 1000; // File lock timeout
|
||||
|
||||
// Track active file locks for diagnostics
|
||||
private static final ConcurrentHashMap<String, String> activeLocks = new ConcurrentHashMap<>();
|
||||
|
||||
// Configuration flag for force bypass mode
|
||||
private static final boolean FORCE_BYPASS_EXTERNAL_LOCKS =
|
||||
Boolean.parseBoolean(
|
||||
System.getProperty("stirling.settings.force-bypass-locks", "true"));
|
||||
|
||||
// Initialize settings hash on first access
|
||||
static {
|
||||
try {
|
||||
lastSettingsHash = calculateSettingsHash();
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not initialize settings hash: {}", e.getMessage());
|
||||
lastSettingsHash = "";
|
||||
}
|
||||
}
|
||||
|
||||
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
@ -397,12 +431,447 @@ public class GeneralUtils {
|
||||
* Internal Implementation Details *
|
||||
*------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Thread-safe method to save a key-value pair to settings file with concurrency control.
|
||||
* Prevents race conditions and data corruption when multiple threads/admins modify settings.
|
||||
*
|
||||
* @param key The setting key in dot notation (e.g., "security.enableCSRF")
|
||||
* @param newValue The new value to set
|
||||
* @throws IOException If file operations fail
|
||||
* @throws IllegalStateException If settings file was modified by another process
|
||||
*/
|
||||
public static void saveKeyToSettings(String key, Object newValue) throws IOException {
|
||||
String[] keyArray = key.split("\\.");
|
||||
// Use timeout to prevent infinite blocking
|
||||
boolean lockAcquired = false;
|
||||
try {
|
||||
lockAcquired = settingsLock.writeLock().tryLock(LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!lockAcquired) {
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Could not acquire write lock for setting '%s' within %d seconds. "
|
||||
+ "Another admin operation may be in progress or the system may be under heavy load.",
|
||||
key, LOCK_TIMEOUT_SECONDS));
|
||||
}
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
|
||||
// Get current thread and process info for diagnostics
|
||||
String currentThread = Thread.currentThread().getName();
|
||||
String currentProcess = ManagementFactory.getRuntimeMXBean().getName();
|
||||
String lockAttemptInfo =
|
||||
String.format(
|
||||
"Thread:%s Process:%s Setting:%s", currentThread, currentProcess, key);
|
||||
|
||||
log.debug(
|
||||
"Attempting to acquire file lock for setting '{}' from {}",
|
||||
key,
|
||||
lockAttemptInfo);
|
||||
|
||||
// Check what locks are currently active
|
||||
if (!activeLocks.isEmpty()) {
|
||||
log.debug("Active locks detected: {}", activeLocks);
|
||||
}
|
||||
|
||||
// Attempt file locking with proper retry logic
|
||||
long startTime = System.currentTimeMillis();
|
||||
int retryCount = 0;
|
||||
final int maxRetries = (int) (FILE_LOCK_TIMEOUT_MS / 100); // 100ms intervals
|
||||
|
||||
while ((System.currentTimeMillis() - startTime) < FILE_LOCK_TIMEOUT_MS) {
|
||||
FileChannel channel = null;
|
||||
FileLock fileLock = null;
|
||||
|
||||
try {
|
||||
// Open channel and keep it open during lock attempts
|
||||
channel =
|
||||
FileChannel.open(
|
||||
settingsPath,
|
||||
StandardOpenOption.READ,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.CREATE);
|
||||
|
||||
// Try to acquire exclusive lock
|
||||
fileLock = channel.tryLock();
|
||||
|
||||
if (fileLock != null) {
|
||||
// Successfully acquired lock - track it for diagnostics
|
||||
String lockInfo =
|
||||
String.format(
|
||||
"%s acquired at %d",
|
||||
lockAttemptInfo, System.currentTimeMillis());
|
||||
activeLocks.put(settingsPath.toString(), lockInfo);
|
||||
|
||||
// Successfully acquired lock - perform the update
|
||||
try {
|
||||
// Validate that we can actually read/write to detect stale locks
|
||||
if (!Files.isWritable(settingsPath)) {
|
||||
throw new IOException(
|
||||
"Settings file is not writable - permissions issue");
|
||||
}
|
||||
|
||||
// Perform the actual update
|
||||
String[] keyArray = key.split("\\.");
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
|
||||
// Update hash after successful write
|
||||
lastSettingsHash = null; // Will be recalculated on next access
|
||||
|
||||
log.debug(
|
||||
"Successfully updated setting: {} = {} (attempt {}) by {}",
|
||||
key,
|
||||
newValue,
|
||||
retryCount + 1,
|
||||
lockAttemptInfo);
|
||||
return; // Success - exit method
|
||||
|
||||
} finally {
|
||||
// Remove from active locks tracking
|
||||
activeLocks.remove(settingsPath.toString());
|
||||
|
||||
// Ensure file lock is always released
|
||||
if (fileLock.isValid()) {
|
||||
fileLock.release();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Lock not available - log diagnostic info
|
||||
retryCount++;
|
||||
log.debug(
|
||||
"File lock not available for setting '{}', attempt {} of {} by {}. Active locks: {}",
|
||||
key,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
lockAttemptInfo,
|
||||
activeLocks.isEmpty() ? "none" : activeLocks);
|
||||
|
||||
// Wait before retry with exponential backoff (100ms, 150ms, 200ms, etc.)
|
||||
long waitTime = Math.min(100 + (retryCount * 50), 500);
|
||||
Thread.sleep(waitTime);
|
||||
}
|
||||
|
||||
} catch (OverlappingFileLockException e) {
|
||||
// This specific exception means another thread in the same JVM has the lock
|
||||
retryCount++;
|
||||
log.debug(
|
||||
"Overlapping file lock detected for setting '{}', attempt {} of {} by {}. Another thread in this JVM has the lock. Active locks: {}",
|
||||
key,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
lockAttemptInfo,
|
||||
activeLocks);
|
||||
|
||||
try {
|
||||
// Wait before retry with exponential backoff
|
||||
long waitTime = Math.min(100 + (retryCount * 50), 500);
|
||||
Thread.sleep(waitTime);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for file lock retry", ie);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// If this is a specific lock contention error, continue retrying
|
||||
if (e.getMessage() != null
|
||||
&& (e.getMessage().contains("locked")
|
||||
|| e.getMessage().contains("another process")
|
||||
|| e.getMessage().contains("sharing violation"))) {
|
||||
|
||||
retryCount++;
|
||||
log.debug(
|
||||
"File lock contention for setting '{}', attempt {} of {} by {}. IOException: {}. Active locks: {}",
|
||||
key,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
lockAttemptInfo,
|
||||
e.getMessage(),
|
||||
activeLocks);
|
||||
|
||||
try {
|
||||
// Wait before retry with exponential backoff
|
||||
long waitTime = Math.min(100 + (retryCount * 50), 500);
|
||||
Thread.sleep(waitTime);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException(
|
||||
"Interrupted while waiting for file lock retry", ie);
|
||||
}
|
||||
} else {
|
||||
// Different type of IOException - don't retry
|
||||
throw e;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for file lock", e);
|
||||
} finally {
|
||||
// Clean up resources
|
||||
if (fileLock != null && fileLock.isValid()) {
|
||||
try {
|
||||
fileLock.release();
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to release file lock for setting {}: {}",
|
||||
key,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
if (channel != null && channel.isOpen()) {
|
||||
try {
|
||||
channel.close();
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to close file channel for setting {}: {}",
|
||||
key,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we couldn't acquire the file lock within timeout
|
||||
// If no internal locks are active, this is likely an external system lock
|
||||
// Try one final force write attempt if bypass is enabled
|
||||
if (FORCE_BYPASS_EXTERNAL_LOCKS && activeLocks.isEmpty()) {
|
||||
log.debug(
|
||||
"No internal locks detected - attempting force write bypass for setting '{}' due to external system lock (bypass enabled)",
|
||||
key);
|
||||
try {
|
||||
// Attempt direct file write without locking
|
||||
String[] keyArray = key.split("\\.");
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
|
||||
// Update hash after successful write
|
||||
lastSettingsHash = null;
|
||||
|
||||
log.debug(
|
||||
"Force write bypass successful for setting '{}' = {} (external system lock detected)",
|
||||
key,
|
||||
newValue);
|
||||
return; // Success
|
||||
|
||||
} catch (Exception forceWriteException) {
|
||||
log.error(
|
||||
"Force write bypass failed for setting '{}': {}",
|
||||
key,
|
||||
forceWriteException.getMessage());
|
||||
// Fall through to original exception
|
||||
}
|
||||
} else if (!FORCE_BYPASS_EXTERNAL_LOCKS && activeLocks.isEmpty()) {
|
||||
log.debug(
|
||||
"External system lock detected for setting '{}' but force bypass is disabled (use -Dstirling.settings.force-bypass-locks=true to enable)",
|
||||
key);
|
||||
}
|
||||
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Could not acquire file lock for setting '%s' within %d ms by %s. "
|
||||
+ "The settings file may be locked by another process or there may be file system issues. "
|
||||
+ "Final active locks: %s. Force bypass %s",
|
||||
key,
|
||||
FILE_LOCK_TIMEOUT_MS,
|
||||
lockAttemptInfo,
|
||||
activeLocks,
|
||||
activeLocks.isEmpty()
|
||||
? "attempted but failed"
|
||||
: "not attempted (internal locks detected)"));
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for settings lock", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error updating setting {}: {}", key, e.getMessage(), e);
|
||||
if (e instanceof IOException) {
|
||||
throw (IOException) e;
|
||||
}
|
||||
throw new IOException("Failed to update settings: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
settingsLock.writeLock().unlock();
|
||||
|
||||
// Recalculate hash after releasing the write lock
|
||||
try {
|
||||
if (lastSettingsHash == null) {
|
||||
lastSettingsHash = calculateSettingsHash();
|
||||
log.debug("Updated settings hash after write operation");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to update settings hash after write: {}", e.getMessage());
|
||||
lastSettingsHash = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates MD5 hash of the settings file for change detection
|
||||
*
|
||||
* @return Hash string, or empty string if file doesn't exist
|
||||
*/
|
||||
private static String calculateSettingsHash() throws Exception {
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
if (!Files.exists(settingsPath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
boolean lockAcquired = false;
|
||||
try {
|
||||
lockAcquired = settingsLock.readLock().tryLock(LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!lockAcquired) {
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Could not acquire read lock for settings hash calculation within %d seconds. "
|
||||
+ "System may be under heavy load or there may be a deadlock.",
|
||||
LOCK_TIMEOUT_SECONDS));
|
||||
}
|
||||
|
||||
// Use OS-level file locking with proper retry logic
|
||||
long startTime = System.currentTimeMillis();
|
||||
int retryCount = 0;
|
||||
final int maxRetries = (int) (FILE_LOCK_TIMEOUT_MS / 100); // 100ms intervals
|
||||
|
||||
while ((System.currentTimeMillis() - startTime) < FILE_LOCK_TIMEOUT_MS) {
|
||||
FileChannel channel = null;
|
||||
FileLock fileLock = null;
|
||||
|
||||
try {
|
||||
// Open channel and keep it open during lock attempts
|
||||
channel = FileChannel.open(settingsPath, StandardOpenOption.READ);
|
||||
|
||||
// Try to acquire shared lock for reading
|
||||
fileLock = channel.tryLock(0L, Long.MAX_VALUE, true);
|
||||
|
||||
if (fileLock != null) {
|
||||
// Successfully acquired lock - calculate hash
|
||||
try {
|
||||
byte[] fileBytes = Files.readAllBytes(settingsPath);
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] hashBytes = md.digest(fileBytes);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
log.debug(
|
||||
"Successfully calculated settings hash (attempt {})",
|
||||
retryCount + 1);
|
||||
return sb.toString();
|
||||
} finally {
|
||||
fileLock.release();
|
||||
}
|
||||
} else {
|
||||
// Lock not available - log and retry
|
||||
retryCount++;
|
||||
log.debug(
|
||||
"File lock not available for hash calculation, attempt {} of {}",
|
||||
retryCount,
|
||||
maxRetries);
|
||||
|
||||
// Wait before retry with exponential backoff
|
||||
long waitTime = Math.min(100 + (retryCount * 50), 500);
|
||||
Thread.sleep(waitTime);
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
// If this is a specific lock contention error, continue retrying
|
||||
if (e.getMessage() != null
|
||||
&& (e.getMessage().contains("locked")
|
||||
|| e.getMessage().contains("another process")
|
||||
|| e.getMessage().contains("sharing violation"))) {
|
||||
|
||||
retryCount++;
|
||||
log.debug(
|
||||
"File lock contention for hash calculation, attempt {} of {}: {}",
|
||||
retryCount,
|
||||
maxRetries,
|
||||
e.getMessage());
|
||||
|
||||
try {
|
||||
// Wait before retry with exponential backoff
|
||||
long waitTime = Math.min(100 + (retryCount * 50), 500);
|
||||
Thread.sleep(waitTime);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException(
|
||||
"Interrupted while waiting for file lock retry", ie);
|
||||
}
|
||||
} else {
|
||||
// Different type of IOException - don't retry
|
||||
throw e;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for file lock", e);
|
||||
} finally {
|
||||
// Clean up resources
|
||||
if (fileLock != null && fileLock.isValid()) {
|
||||
try {
|
||||
fileLock.release();
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to release file lock for hash calculation: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
if (channel != null && channel.isOpen()) {
|
||||
try {
|
||||
channel.close();
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to close file channel for hash calculation: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't get the file lock, throw an exception
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Could not acquire file lock for settings hash calculation within %d ms. "
|
||||
+ "The settings file may be locked by another process.",
|
||||
FILE_LOCK_TIMEOUT_MS));
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException(
|
||||
"Interrupted while waiting for settings read lock for hash calculation", e);
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
settingsLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread-safe method to read settings values with proper locking and timeout
|
||||
*
|
||||
* @return YamlHelper instance for reading
|
||||
* @throws IOException If timeout occurs or file operations fail
|
||||
*/
|
||||
public static YamlHelper getSettingsReader() throws IOException {
|
||||
boolean lockAcquired = false;
|
||||
try {
|
||||
lockAcquired = settingsLock.readLock().tryLock(LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!lockAcquired) {
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Could not acquire read lock for settings within %d seconds. "
|
||||
+ "System may be under heavy load or there may be a deadlock.",
|
||||
LOCK_TIMEOUT_SECONDS));
|
||||
}
|
||||
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
return new YamlHelper(settingsPath);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for settings read lock", e);
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
settingsLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String generateMachineFingerprint() {
|
||||
|
@ -36,9 +36,15 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
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();
|
||||
if (queryString != null && !queryString.isEmpty()) {
|
||||
String requestURI = request.getRequestURI();
|
||||
Map<String, String> allowedParameters = new HashMap<>();
|
||||
|
||||
// Keep only the allowed parameters
|
||||
|
@ -421,7 +421,6 @@ public class EndpointConfiguration {
|
||||
|
||||
// file-to-pdf has multiple implementations
|
||||
addEndpointAlternative("file-to-pdf", "LibreOffice");
|
||||
addEndpointAlternative("file-to-pdf", "Python");
|
||||
addEndpointAlternative("file-to-pdf", "Unoconvert");
|
||||
|
||||
// pdf-to-html and pdf-to-markdown can use either LibreOffice or Pdftohtml
|
||||
|
@ -0,0 +1,625 @@
|
||||
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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user