diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index c128a1c1c..802a55831 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -61,10 +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(); diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index e96e4e5cf..b132c0540 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -4,11 +4,7 @@ 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; @@ -19,9 +15,6 @@ 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; @@ -44,33 +37,6 @@ public class GeneralUtils { private static final List 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 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()) { @@ -431,447 +397,12 @@ 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 { - // 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 { + String[] keyArray = key.split("\\."); Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath()); - 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(); - } - } + YamlHelper settingsYaml = new YamlHelper(settingsPath); + settingsYaml.updateValue(Arrays.asList(keyArray), newValue); + settingsYaml.saveOverride(settingsPath); } public static String generateMachineFingerprint() { @@ -915,9 +446,6 @@ public class GeneralUtils { } } - /** - * Extracts a file from classpath:/static/python to a temporary directory and returns the path. - */ public static Path extractScript(String scriptName) throws IOException { // Validate input if (scriptName == null || scriptName.trim().isEmpty()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java index 33a6226fc..d37d4bfb6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java @@ -37,12 +37,12 @@ public class CleanUrlInterceptor implements HandlerInterceptor { 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()) { Map allowedParameters = new HashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index cf9b1ac55..ebe856b00 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -49,28 +49,39 @@ 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 pendingChanges = new ConcurrentHashMap<>(); - + private static final ConcurrentHashMap pendingChanges = + new ConcurrentHashMap<>(); + // Define specific sensitive field names that contain secret values - private static final Set 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" - )); - + private static final Set 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.") + 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"), @@ -79,20 +90,21 @@ public class AdminSettingsController { description = "Access denied - Admin role required") }) public ResponseEntity getSettings( - @RequestParam(value = "includePending", defaultValue = "false") boolean includePending) { + @RequestParam(value = "includePending", defaultValue = "false") + boolean includePending) { log.debug("Admin requested all application settings (includePending={})", includePending); - + // Convert ApplicationProperties to Map Map 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 maskedSettings = maskSensitiveFields(settings); - + return ResponseEntity.ok(maskedSettings); } @@ -116,7 +128,7 @@ public class AdminSettingsController { 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); } @@ -157,10 +169,10 @@ public class AdminSettingsController { log.info("Admin updating setting: {} = {}", key, value); GeneralUtils.saveKeyToSettings(key, value); - + // Track this as a pending change pendingChanges.put(key, value); - + updatedCount++; } @@ -266,10 +278,10 @@ public class AdminSettingsController { log.info("Admin updating section setting: {} = {}", fullKey, value); GeneralUtils.saveKeyToSettings(fullKey, value); - + // Track this as a pending change pendingChanges.put(fullKey, value); - + updatedCount++; } @@ -357,7 +369,7 @@ public class AdminSettingsController { 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); @@ -517,26 +529,28 @@ public class AdminSettingsController { } /** - * Recursively mask sensitive fields in settings map. - * Sensitive fields are replaced with a status indicator showing if they're configured. + * Recursively mask sensitive fields in settings map. Sensitive fields are replaced with a + * status indicator showing if they're configured. */ @SuppressWarnings("unchecked") private Map maskSensitiveFields(Map settings) { return maskSensitiveFieldsWithPath(settings, ""); } - + @SuppressWarnings("unchecked") - private Map maskSensitiveFieldsWithPath(Map settings, String path) { + private Map maskSensitiveFieldsWithPath( + Map settings, String path) { Map masked = new HashMap<>(); - + for (Map.Entry 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) value, currentPath)); + masked.put( + key, maskSensitiveFieldsWithPath((Map) value, currentPath)); } else if (isSensitiveFieldWithPath(key, currentPath)) { // Mask sensitive fields with status indicator masked.put(key, createMaskedValue(value)); @@ -545,65 +559,60 @@ public class AdminSettingsController { masked.put(key, value); } } - + return masked; } - /** - * Check if a field name indicates sensitive data with full path context - */ + /** 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 - */ + /** Create a masked representation for sensitive fields */ private Object createMaskedValue(Object originalValue) { - if (originalValue == null || - (originalValue instanceof String && ((String) originalValue).trim().isEmpty())) { + 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 - */ + /** Merge pending changes into the settings map using dot notation keys */ @SuppressWarnings("unchecked") - private Map mergePendingChanges(Map settings, Map pendingChanges) { + private Map mergePendingChanges( + Map settings, Map pendingChanges) { // Create a deep copy of the settings to avoid modifying the original Map mergedSettings = new HashMap<>(settings); - + for (Map.Entry 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 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)) { @@ -613,13 +622,12 @@ public class AdminSettingsController { } currentMap = (Map) nested; } - + // Set the final value String finalKey = keyParts[keyParts.length - 1]; currentMap.put(finalKey, pendingValue); } - + return mergedSettings; } - }