mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-01 10:05:22 +00:00
remove file locks plus formatting (#4049)
# 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.
This commit is contained in:
parent
85f5cccf04
commit
8fb78d612b
@ -61,10 +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
|
@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();
|
||||||
|
|
||||||
|
@ -4,11 +4,7 @@ import java.io.File;
|
|||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.lang.management.ManagementFactory;
|
|
||||||
import java.net.*;
|
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.charset.StandardCharsets;
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
import java.nio.file.attribute.BasicFileAttributes;
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
@ -19,9 +15,6 @@ import java.util.Enumeration;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.UUID;
|
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.ClassPathResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
@ -44,33 +37,6 @@ public class GeneralUtils {
|
|||||||
private static final List<String> DEFAULT_VALID_SCRIPTS =
|
private static final List<String> DEFAULT_VALID_SCRIPTS =
|
||||||
List.of("png_to_webp.py", "split_photos.py");
|
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 {
|
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
||||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||||
@ -431,447 +397,12 @@ public class GeneralUtils {
|
|||||||
* Internal Implementation Details *
|
* 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 {
|
public static void saveKeyToSettings(String key, Object newValue) throws IOException {
|
||||||
// Use timeout to prevent infinite blocking
|
String[] keyArray = key.split("\\.");
|
||||||
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());
|
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||||
if (!Files.exists(settingsPath)) {
|
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||||
return "";
|
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||||
}
|
settingsYaml.saveOverride(settingsPath);
|
||||||
|
|
||||||
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() {
|
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 {
|
public static Path extractScript(String scriptName) throws IOException {
|
||||||
// Validate input
|
// Validate input
|
||||||
if (scriptName == null || scriptName.trim().isEmpty()) {
|
if (scriptName == null || scriptName.trim().isEmpty()) {
|
||||||
|
@ -37,12 +37,12 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
|||||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
String requestURI = request.getRequestURI();
|
String requestURI = request.getRequestURI();
|
||||||
|
|
||||||
// Skip URL cleaning for API endpoints - they need their own parameter handling
|
// Skip URL cleaning for API endpoints - they need their own parameter handling
|
||||||
if (requestURI.contains("/api/")) {
|
if (requestURI.contains("/api/")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
String queryString = request.getQueryString();
|
String queryString = request.getQueryString();
|
||||||
if (queryString != null && !queryString.isEmpty()) {
|
if (queryString != null && !queryString.isEmpty()) {
|
||||||
Map<String, String> allowedParameters = new HashMap<>();
|
Map<String, String> allowedParameters = new HashMap<>();
|
||||||
|
@ -49,28 +49,39 @@ public class AdminSettingsController {
|
|||||||
|
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
// Track settings that have been modified but not yet applied (require restart)
|
// Track settings that have been modified but not yet applied (require restart)
|
||||||
private static final ConcurrentHashMap<String, Object> pendingChanges = new ConcurrentHashMap<>();
|
private static final ConcurrentHashMap<String, Object> pendingChanges =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
// Define specific sensitive field names that contain secret values
|
// Define specific sensitive field names that contain secret values
|
||||||
private static final Set<String> SENSITIVE_FIELD_NAMES = new HashSet<>(Arrays.asList(
|
private static final Set<String> SENSITIVE_FIELD_NAMES =
|
||||||
// Passwords
|
new HashSet<>(
|
||||||
"password", "dbpassword", "mailpassword", "smtppassword",
|
Arrays.asList(
|
||||||
// OAuth/API secrets
|
// Passwords
|
||||||
"clientsecret", "apisecret", "secret",
|
"password",
|
||||||
// API tokens
|
"dbpassword",
|
||||||
"apikey", "accesstoken", "refreshtoken", "token",
|
"mailpassword",
|
||||||
// Specific secret keys (not all keys, and excluding premium.key)
|
"smtppassword",
|
||||||
"key", // automaticallyGenerated.key
|
// OAuth/API secrets
|
||||||
"enterprisekey", "licensekey"
|
"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
|
@GetMapping
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Get all application settings",
|
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(
|
@ApiResponses(
|
||||||
value = {
|
value = {
|
||||||
@ApiResponse(responseCode = "200", description = "Settings retrieved successfully"),
|
@ApiResponse(responseCode = "200", description = "Settings retrieved successfully"),
|
||||||
@ -79,20 +90,21 @@ public class AdminSettingsController {
|
|||||||
description = "Access denied - Admin role required")
|
description = "Access denied - Admin role required")
|
||||||
})
|
})
|
||||||
public ResponseEntity<?> getSettings(
|
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);
|
log.debug("Admin requested all application settings (includePending={})", includePending);
|
||||||
|
|
||||||
// Convert ApplicationProperties to Map
|
// Convert ApplicationProperties to Map
|
||||||
Map<String, Object> settings = objectMapper.convertValue(applicationProperties, Map.class);
|
Map<String, Object> settings = objectMapper.convertValue(applicationProperties, Map.class);
|
||||||
|
|
||||||
if (includePending && !pendingChanges.isEmpty()) {
|
if (includePending && !pendingChanges.isEmpty()) {
|
||||||
// Merge pending changes into the settings map
|
// Merge pending changes into the settings map
|
||||||
settings = mergePendingChanges(settings, pendingChanges);
|
settings = mergePendingChanges(settings, pendingChanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mask sensitive fields after merging
|
// Mask sensitive fields after merging
|
||||||
Map<String, Object> maskedSettings = maskSensitiveFields(settings);
|
Map<String, Object> maskedSettings = maskSensitiveFields(settings);
|
||||||
|
|
||||||
return ResponseEntity.ok(maskedSettings);
|
return ResponseEntity.ok(maskedSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +128,7 @@ public class AdminSettingsController {
|
|||||||
response.put("pendingChanges", maskSensitiveFields(new HashMap<>(pendingChanges)));
|
response.put("pendingChanges", maskSensitiveFields(new HashMap<>(pendingChanges)));
|
||||||
response.put("hasPendingChanges", !pendingChanges.isEmpty());
|
response.put("hasPendingChanges", !pendingChanges.isEmpty());
|
||||||
response.put("count", pendingChanges.size());
|
response.put("count", pendingChanges.size());
|
||||||
|
|
||||||
log.debug("Admin requested pending changes - found {} settings", pendingChanges.size());
|
log.debug("Admin requested pending changes - found {} settings", pendingChanges.size());
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
@ -157,10 +169,10 @@ public class AdminSettingsController {
|
|||||||
|
|
||||||
log.info("Admin updating setting: {} = {}", key, value);
|
log.info("Admin updating setting: {} = {}", key, value);
|
||||||
GeneralUtils.saveKeyToSettings(key, value);
|
GeneralUtils.saveKeyToSettings(key, value);
|
||||||
|
|
||||||
// Track this as a pending change
|
// Track this as a pending change
|
||||||
pendingChanges.put(key, value);
|
pendingChanges.put(key, value);
|
||||||
|
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,10 +278,10 @@ public class AdminSettingsController {
|
|||||||
|
|
||||||
log.info("Admin updating section setting: {} = {}", fullKey, value);
|
log.info("Admin updating section setting: {} = {}", fullKey, value);
|
||||||
GeneralUtils.saveKeyToSettings(fullKey, value);
|
GeneralUtils.saveKeyToSettings(fullKey, value);
|
||||||
|
|
||||||
// Track this as a pending change
|
// Track this as a pending change
|
||||||
pendingChanges.put(fullKey, value);
|
pendingChanges.put(fullKey, value);
|
||||||
|
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,7 +369,7 @@ public class AdminSettingsController {
|
|||||||
Object value = request.getValue();
|
Object value = request.getValue();
|
||||||
log.info("Admin updating single setting: {} = {}", key, value);
|
log.info("Admin updating single setting: {} = {}", key, value);
|
||||||
GeneralUtils.saveKeyToSettings(key, value);
|
GeneralUtils.saveKeyToSettings(key, value);
|
||||||
|
|
||||||
// Track this as a pending change
|
// Track this as a pending change
|
||||||
pendingChanges.put(key, value);
|
pendingChanges.put(key, value);
|
||||||
|
|
||||||
@ -517,26 +529,28 @@ public class AdminSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively mask sensitive fields in settings map.
|
* Recursively mask sensitive fields in settings map. Sensitive fields are replaced with a
|
||||||
* Sensitive fields are replaced with a status indicator showing if they're configured.
|
* status indicator showing if they're configured.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, Object> maskSensitiveFields(Map<String, Object> settings) {
|
private Map<String, Object> maskSensitiveFields(Map<String, Object> settings) {
|
||||||
return maskSensitiveFieldsWithPath(settings, "");
|
return maskSensitiveFieldsWithPath(settings, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, Object> maskSensitiveFieldsWithPath(Map<String, Object> settings, String path) {
|
private Map<String, Object> maskSensitiveFieldsWithPath(
|
||||||
|
Map<String, Object> settings, String path) {
|
||||||
Map<String, Object> masked = new HashMap<>();
|
Map<String, Object> masked = new HashMap<>();
|
||||||
|
|
||||||
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||||
String key = entry.getKey();
|
String key = entry.getKey();
|
||||||
Object value = entry.getValue();
|
Object value = entry.getValue();
|
||||||
String currentPath = path.isEmpty() ? key : path + "." + key;
|
String currentPath = path.isEmpty() ? key : path + "." + key;
|
||||||
|
|
||||||
if (value instanceof Map) {
|
if (value instanceof Map) {
|
||||||
// Recursively mask nested objects
|
// Recursively mask nested objects
|
||||||
masked.put(key, maskSensitiveFieldsWithPath((Map<String, Object>) value, currentPath));
|
masked.put(
|
||||||
|
key, maskSensitiveFieldsWithPath((Map<String, Object>) value, currentPath));
|
||||||
} else if (isSensitiveFieldWithPath(key, currentPath)) {
|
} else if (isSensitiveFieldWithPath(key, currentPath)) {
|
||||||
// Mask sensitive fields with status indicator
|
// Mask sensitive fields with status indicator
|
||||||
masked.put(key, createMaskedValue(value));
|
masked.put(key, createMaskedValue(value));
|
||||||
@ -545,65 +559,60 @@ public class AdminSettingsController {
|
|||||||
masked.put(key, value);
|
masked.put(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return masked;
|
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) {
|
private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) {
|
||||||
String lowerField = fieldName.toLowerCase();
|
String lowerField = fieldName.toLowerCase();
|
||||||
String lowerPath = fullPath.toLowerCase();
|
String lowerPath = fullPath.toLowerCase();
|
||||||
|
|
||||||
// Don't mask premium.key specifically
|
// Don't mask premium.key specifically
|
||||||
if ("key".equals(lowerField) && "premium.key".equals(lowerPath)) {
|
if ("key".equals(lowerField) && "premium.key".equals(lowerPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct match with sensitive field names
|
// Direct match with sensitive field names
|
||||||
if (SENSITIVE_FIELD_NAMES.contains(lowerField)) {
|
if (SENSITIVE_FIELD_NAMES.contains(lowerField)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for fields containing 'password' or 'secret'
|
// Check for fields containing 'password' or 'secret'
|
||||||
return lowerField.contains("password") || lowerField.contains("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) {
|
private Object createMaskedValue(Object originalValue) {
|
||||||
if (originalValue == null ||
|
if (originalValue == null
|
||||||
(originalValue instanceof String && ((String) originalValue).trim().isEmpty())) {
|
|| (originalValue instanceof String && ((String) originalValue).trim().isEmpty())) {
|
||||||
return originalValue; // Keep empty/null values as-is
|
return originalValue; // Keep empty/null values as-is
|
||||||
} else {
|
} else {
|
||||||
return "********";
|
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")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, Object> mergePendingChanges(Map<String, Object> settings, Map<String, Object> pendingChanges) {
|
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
|
// Create a deep copy of the settings to avoid modifying the original
|
||||||
Map<String, Object> mergedSettings = new HashMap<>(settings);
|
Map<String, Object> mergedSettings = new HashMap<>(settings);
|
||||||
|
|
||||||
for (Map.Entry<String, Object> pendingEntry : pendingChanges.entrySet()) {
|
for (Map.Entry<String, Object> pendingEntry : pendingChanges.entrySet()) {
|
||||||
String dotNotationKey = pendingEntry.getKey();
|
String dotNotationKey = pendingEntry.getKey();
|
||||||
Object pendingValue = pendingEntry.getValue();
|
Object pendingValue = pendingEntry.getValue();
|
||||||
|
|
||||||
// Split the dot notation key into parts
|
// Split the dot notation key into parts
|
||||||
String[] keyParts = dotNotationKey.split("\\.");
|
String[] keyParts = dotNotationKey.split("\\.");
|
||||||
|
|
||||||
// Navigate to the parent object and set the value
|
// Navigate to the parent object and set the value
|
||||||
Map<String, Object> currentMap = mergedSettings;
|
Map<String, Object> currentMap = mergedSettings;
|
||||||
|
|
||||||
// Navigate through all parts except the last one
|
// Navigate through all parts except the last one
|
||||||
for (int i = 0; i < keyParts.length - 1; i++) {
|
for (int i = 0; i < keyParts.length - 1; i++) {
|
||||||
String keyPart = keyParts[i];
|
String keyPart = keyParts[i];
|
||||||
|
|
||||||
// Get or create the nested map
|
// Get or create the nested map
|
||||||
Object nested = currentMap.get(keyPart);
|
Object nested = currentMap.get(keyPart);
|
||||||
if (!(nested instanceof Map)) {
|
if (!(nested instanceof Map)) {
|
||||||
@ -613,13 +622,12 @@ public class AdminSettingsController {
|
|||||||
}
|
}
|
||||||
currentMap = (Map<String, Object>) nested;
|
currentMap = (Map<String, Object>) nested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the final value
|
// Set the final value
|
||||||
String finalKey = keyParts[keyParts.length - 1];
|
String finalKey = keyParts[keyParts.length - 1];
|
||||||
currentMap.put(finalKey, pendingValue);
|
currentMap.put(finalKey, pendingValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergedSettings;
|
return mergedSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user