From 85f5cccf04482c7d3ea61d9c1ef8171964a272f6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:02:02 +0100 Subject: [PATCH 1/5] V2 settings api (Added to V1) (#4015) # Description of Changes --- ## 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 Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com> --- .gitignore | 8 +- .../common/model/ApplicationProperties.java | 48 +- .../software/common/util/GeneralUtils.java | 477 ++++++++++++- .../SPDF/config/CleanUrlInterceptor.java | 8 +- .../SPDF/config/EndpointConfiguration.java | 1 - .../api/AdminSettingsController.java | 625 ++++++++++++++++++ .../model/api/admin/SettingValueResponse.java | 22 + .../api/admin/UpdateSettingValueRequest.java | 16 + .../api/admin/UpdateSettingsRequest.java | 23 + 9 files changed, 1214 insertions(+), 14 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/SettingValueResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingValueRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingsRequest.java diff --git a/.gitignore b/.gitignore index 105ddec3c..6ebd87c35 100644 --- a/.gitignore +++ b/.gitignore @@ -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 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 91b328759..c128a1c1c 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 @@ -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; 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 a164faec2..e96e4e5cf 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,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 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()) { @@ -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() { 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 088c0c0bf..33a6226fc 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 @@ -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 allowedParameters = new HashMap<>(); // Keep only the allowed parameters diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index a701487e1..dab00a89d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -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 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 new file mode 100644 index 000000000..cf9b1ac55 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -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 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" + )); + + + @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 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); + } + + @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 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 updateSettings( + @Valid @RequestBody UpdateSettingsRequest request) { + try { + Map settings = request.getSettings(); + if (settings == null || settings.isEmpty()) { + return ResponseEntity.badRequest().body("No settings provided to update"); + } + + int updatedCount = 0; + for (Map.Entry 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 updateSettingsSection( + @PathVariable String sectionName, @Valid @RequestBody Map 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 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 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 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 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 maskSensitiveFields(Map settings) { + return maskSensitiveFieldsWithPath(settings, ""); + } + + @SuppressWarnings("unchecked") + 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)); + } 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 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)) { + // Create a new nested map if it doesn't exist or isn't a map + nested = new HashMap(); + currentMap.put(keyPart, nested); + } + currentMap = (Map) nested; + } + + // Set the final value + String finalKey = keyParts[keyParts.length - 1]; + currentMap.put(finalKey, pendingValue); + } + + return mergedSettings; + } + +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/SettingValueResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/SettingValueResponse.java new file mode 100644 index 000000000..1436a2291 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/SettingValueResponse.java @@ -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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingValueRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingValueRequest.java new file mode 100644 index 000000000..76c18898f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingValueRequest.java @@ -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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingsRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingsRequest.java new file mode 100644 index 000000000..7e1342fab --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/admin/UpdateSettingsRequest.java @@ -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 settings; +} From 8fb78d612b1e0c2645c41d1f72fe5042d686a693 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:19:46 +0100 Subject: [PATCH 2/5] remove file locks plus formatting (#4049) # Description of Changes --- ## 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. --- .../common/model/ApplicationProperties.java | 4 +- .../software/common/util/GeneralUtils.java | 480 +----------------- .../SPDF/config/CleanUrlInterceptor.java | 4 +- .../api/AdminSettingsController.java | 124 ++--- 4 files changed, 74 insertions(+), 538 deletions(-) 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; } - } From 9bb08eed5adb7db19f3f477aafec492addbbc7a7 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 30 Jul 2025 23:38:43 +0200 Subject: [PATCH 3/5] fix(search): add null-check in dropdown hide handler (#3983) # Description of Changes - **What was changed** - Added a null-check on `dropdownMenu` in the document click handler to prevent errors when the menu element is not present. - **Why the change was made** - To guard against potential runtime errors if `dropdownMenu` is `null`. ```js search.js:126 Uncaught TypeError: Cannot read properties of null (reading 'classList') at HTMLDocument. (search.js:126:60) (anonymous) @ search.js:126 ``` --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/main/resources/static/js/search.js | 93 ++++++++++--------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/app/core/src/main/resources/static/js/search.js b/app/core/src/main/resources/static/js/search.js index 277d722a9..0b8877e62 100644 --- a/app/core/src/main/resources/static/js/search.js +++ b/app/core/src/main/resources/static/js/search.js @@ -82,55 +82,62 @@ document.querySelector("#navbarSearchInput").addEventListener("input", function resultsBox.style.width = window.navItemMaxWidth + "px"; }); +document.addEventListener('DOMContentLoaded', function () { + const searchDropdown = document.getElementById('searchDropdown'); + const searchInput = document.getElementById('navbarSearchInput'); -const searchDropdown = document.getElementById('searchDropdown'); -const searchInput = document.getElementById('navbarSearchInput'); + // Check if elements are missing and skip initialization if necessary + if (!searchDropdown || !searchInput) { + console.warn('Search dropdown or input not found. Skipping initialization.'); + return; + } + const dropdownMenu = searchDropdown.querySelector('.dropdown-menu'); + if (!dropdownMenu) { + console.warn('Dropdown menu not found within the search dropdown. Skipping initialization.'); + return; + } -// Check if elements exist before proceeding -if (searchDropdown && searchInput) { - const dropdownMenu = searchDropdown.querySelector('.dropdown-menu'); + // Create a single dropdown instance + const dropdownInstance = new bootstrap.Dropdown(searchDropdown); - // Create a single dropdown instance - const dropdownInstance = new bootstrap.Dropdown(searchDropdown); - -// Handle click for mobile - searchDropdown.addEventListener('click', function (e) { - e.preventDefault(); - const isOpen = dropdownMenu.classList.contains('show'); - // Close all other open dropdowns - document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => { - if (menu !== dropdownMenu) { - const parentDropdown = menu.closest('.dropdown'); - if (parentDropdown) { - const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]'); - if (parentToggle) { - let instance = bootstrap.Dropdown.getInstance(parentToggle); - if (!instance) { - instance = new bootstrap.Dropdown(parentToggle); - } - instance.hide(); - } - } + // Handle click for mobile + searchDropdown.addEventListener('click', function (e) { + e.preventDefault(); + const isOpen = dropdownMenu.classList.contains('show'); + // Close all other open dropdowns + document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => { + if (menu !== dropdownMenu) { + const parentDropdown = menu.closest('.dropdown'); + if (parentDropdown) { + const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]'); + if (parentToggle) { + let instance = bootstrap.Dropdown.getInstance(parentToggle); + if (!instance) { + instance = new bootstrap.Dropdown(parentToggle); } - }); - if (!isOpen) { - dropdownInstance.show(); - setTimeout(() => searchInput.focus(), 150); - } else { - dropdownInstance.hide(); + instance.hide(); + } } + } }); + if (!isOpen) { + dropdownInstance.show(); + setTimeout(() => searchInput.focus(), 150); + } else { + dropdownInstance.hide(); + } + }); - // Hide dropdown if it's open and user clicks outside - document.addEventListener('click', function(e) { - if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) { - dropdownInstance.hide(); - } - }); + // Hide dropdown if it's open and user clicks outside + document.addEventListener('click', function (e) { + if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) { + dropdownInstance.hide(); + } + }); - // Keep dropdown open if search input is clicked - searchInput.addEventListener('click', function (e) { - e.stopPropagation(); - }); + // Keep dropdown open if search input is clicked + searchInput.addEventListener('click', function (e) { + e.stopPropagation(); + }); -} +}); From 1274dc92790a2d83e695c6ba633014fa76d4c0fe Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 30 Jul 2025 23:40:21 +0200 Subject: [PATCH 4/5] fix(pipeline): correct paths for pipeline & support default WebUI pipeline config extraction (#4051) # Description of Changes - **What was changed:** - Updated `.github/labeler-config-srvaroa.yml` to include `app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**` under the labeler paths. - Removed `COPY pipeline /pipeline` from all three Dockerfiles to slim down images. - Added a new `PIPELINE_PATH` constant and `getPipelinePath()` method in `InstallationPathConfig.java`. - Implemented `GeneralUtils.extractPipeline()` to copy default pipeline JSON configs (`OCR images.json`, `Prepare-pdfs-for-email.json`, `split-rotate-auto-rename.json`) from classpath into the installation directory. - Invoked `GeneralUtils.extractPipeline()` during initial setup in `InitialSetup.java`. - Updated `.gitignore` to treat `./pipeline/` as ignored. - **Why the change was made:** Ensures that default WebUI pipeline configurations are automatically extracted at runtime rather than baked into the image, improving flexibility and reducing image size. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/labeler-config-srvaroa.yml | 1 + Dockerfile | 1 - Dockerfile.fat | 1 - Dockerfile.ultra-lite | 1 - .../configuration/InstallationPathConfig.java | 6 + .../software/common/util/GeneralUtils.java | 118 ++++++++++++++++-- app/core/.gitignore | 3 +- .../software/SPDF/config/InitialSetup.java | 1 + .../defaultWebUIConfigs/OCR images.json | 0 .../Prepare-pdfs-for-email.json | 0 .../split-rotate-auto-rename.json | 0 11 files changed, 116 insertions(+), 16 deletions(-) rename {pipeline => app/core/src/main/resources/static/pipeline}/defaultWebUIConfigs/OCR images.json (100%) rename {pipeline => app/core/src/main/resources/static/pipeline}/defaultWebUIConfigs/Prepare-pdfs-for-email.json (100%) rename {pipeline => app/core/src/main/resources/static/pipeline}/defaultWebUIConfigs/split-rotate-auto-rename.json (100%) diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 44a234e3b..3719c0ad8 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -78,6 +78,7 @@ labels: - 'app/core/src/main/resources/banner.txt' - 'app/core/src/main/resources/static/python/png_to_webp.py' - 'app/core/src/main/resources/static/python/split_photos.py' + - 'app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**' - 'application.properties' - label: 'Security' diff --git a/Dockerfile b/Dockerfile index fe427fea9..4ea6316ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,6 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8 # Copy necessary files COPY scripts /scripts -COPY pipeline /pipeline COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ COPY app/core/build/libs/*.jar app.jar diff --git a/Dockerfile.fat b/Dockerfile.fat index 87cb5121c..fd5964baf 100644 --- a/Dockerfile.fat +++ b/Dockerfile.fat @@ -26,7 +26,6 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8 # Copy necessary files COPY scripts /scripts -COPY pipeline /pipeline COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ # first /app directory is for the build stage, second is for the final image COPY --from=build /app/app/core/build/libs/*.jar app.jar diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite index 85a9ab0ca..a9fdd5079 100644 --- a/Dockerfile.ultra-lite +++ b/Dockerfile.ultra-lite @@ -21,7 +21,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY scripts/installFonts.sh /scripts/installFonts.sh -COPY pipeline /pipeline COPY app/core/build/libs/*.jar app.jar # Set up necessary directories and permissions diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 08618329d..247a012ad 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -15,6 +15,7 @@ public class InstallationPathConfig { private static final String CUSTOM_FILES_PATH; private static final String CLIENT_WEBUI_PATH; private static final String SCRIPTS_PATH; + private static final String PIPELINE_PATH; // Config paths private static final String SETTINGS_PATH; @@ -33,6 +34,7 @@ public class InstallationPathConfig { CONFIG_PATH = BASE_PATH + "configs" + File.separator; CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator; CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator; + PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator; // Initialize config paths SETTINGS_PATH = CONFIG_PATH + "settings.yml"; @@ -95,6 +97,10 @@ public class InstallationPathConfig { return SCRIPTS_PATH; } + public static String getPipelinePath() { + return PIPELINE_PATH; + } + public static String getSettingsPath() { return SETTINGS_PATH; } 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 b132c0540..9f8d7a7e0 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 @@ -14,6 +14,7 @@ import java.util.Arrays; import java.util.Enumeration; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.UUID; import org.springframework.core.io.ClassPathResource; @@ -34,8 +35,16 @@ import stirling.software.common.configuration.InstallationPathConfig; @Slf4j public class GeneralUtils { - private static final List DEFAULT_VALID_SCRIPTS = - List.of("png_to_webp.py", "split_photos.py"); + private static final Set DEFAULT_VALID_SCRIPTS = + Set.of("png_to_webp.py", "split_photos.py"); + private static final Set DEFAULT_VALID_PIPELINE = + Set.of( + "OCR images.json", + "Prepare-pdfs-for-email.json", + "split-rotate-auto-rename.json"); + + private static final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; + private static final String PYTHON_SCRIPTS_DIR = "python"; public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException { String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY"); @@ -446,6 +455,48 @@ public class GeneralUtils { } } + /** + * Extracts the default pipeline configurations from the classpath to the installation path. + * Creates directories if needed and copies default JSON files. + * + *

Existing files will be overwritten atomically (when supported). In case of unsupported + * atomic moves, falls back to non-atomic replace. + * + * @throws IOException if an I/O error occurs during file operations + */ + public static void extractPipeline() throws IOException { + Path pipelineDir = + Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR); + Files.createDirectories(pipelineDir); + + for (String name : DEFAULT_VALID_PIPELINE) { + if (!Paths.get(name).getFileName().toString().equals(name)) { + log.error("Invalid pipeline file name: {}", name); + throw new IllegalArgumentException("Invalid pipeline file name: " + name); + } + Path target = pipelineDir.resolve(name); + ClassPathResource res = + new ClassPathResource( + "static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + } + } + + /** + * Extracts the specified Python script from the classpath to the installation path. Validates + * name and copies file atomically when possible, overwriting existing. + * + *

Existing files will be overwritten atomically (when supported). + * + * @param scriptName the name of the script to extract + * @return the path to the extracted script + * @throws IllegalArgumentException if the script name is invalid or not allowed + * @throws IOException if an I/O error occurs + */ public static Path extractScript(String scriptName) throws IOException { // Validate input if (scriptName == null || scriptName.trim().isEmpty()) { @@ -455,26 +506,71 @@ public class GeneralUtils { throw new IllegalArgumentException( "scriptName must not contain path traversal characters"); } + if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) { + throw new IllegalArgumentException( + "scriptName must not contain path traversal characters"); + } if (!DEFAULT_VALID_SCRIPTS.contains(scriptName)) { throw new IllegalArgumentException( "scriptName must be either 'png_to_webp.py' or 'split_photos.py'"); } - Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), "python"); + Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR); Files.createDirectories(scriptsDir); - Path scriptFile = scriptsDir.resolve(scriptName); - if (!Files.exists(scriptFile)) { - ClassPathResource resource = new ClassPathResource("static/python/" + scriptName); - try (InputStream in = resource.getInputStream()) { - Files.copy(in, scriptFile, StandardCopyOption.REPLACE_EXISTING); + Path target = scriptsDir.resolve(scriptName); + ClassPathResource res = + new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + return target; + } + + /** + * Copies a resource from the classpath to a specified target file. + * + * @param resource the ClassPathResource to copy + * @param target the target Path where the resource will be copied + * @throws IOException if an I/O error occurs during the copy operation + */ + private static void copyResourceToFile(ClassPathResource resource, Path target) + throws IOException { + Path dir = target.getParent(); + Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp"); + try (InputStream in = resource.getInputStream()) { + Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + log.warn( + "Atomic move not supported, falling back to non-atomic move for {}", + target, + e); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (FileAlreadyExistsException e) { + log.debug("File already exists at {}, attempting to replace it.", target); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } catch (AccessDeniedException e) { + log.error("Access denied while attempting to copy resource to {}", target, e); + throw e; + } catch (FileSystemException e) { + log.error("File system error occurred while copying resource to {}", target, e); + throw e; + } catch (IOException e) { + log.error("Failed to copy resource to {}", target, e); + throw e; + } finally { + try { + Files.deleteIfExists(tmp); } catch (IOException e) { - log.error("Failed to extract Python script", e); - throw e; + log.warn("Failed to delete temporary file {}", tmp, e); } } - return scriptFile; } public static boolean isVersionHigher(String currentVersion, String compareVersion) { diff --git a/app/core/.gitignore b/app/core/.gitignore index 2b3880de6..5486f9afe 100644 --- a/app/core/.gitignore +++ b/app/core/.gitignore @@ -16,8 +16,7 @@ local.properties version.properties #### Stirling-PDF Files ### -pipeline/watchedFolders/ -pipeline/finishedFolders/ +pipeline/* customFiles/ configs/ watchedFolders/ diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index d242bfeab..2d261c660 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -35,6 +35,7 @@ public class InitialSetup { initEnableCSRFSecurity(); initLegalUrls(); initSetAppVersion(); + GeneralUtils.extractPipeline(); } public void initUUIDKey() throws IOException { diff --git a/pipeline/defaultWebUIConfigs/OCR images.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/OCR images.json similarity index 100% rename from pipeline/defaultWebUIConfigs/OCR images.json rename to app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/OCR images.json diff --git a/pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json similarity index 100% rename from pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json rename to app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json diff --git a/pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json similarity index 100% rename from pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json rename to app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json From ae5e87726df18884a5e3791da8ee00cc3286d211 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 31 Jul 2025 06:26:27 +0100 Subject: [PATCH 5/5] Version bump + docker ultra lite fix (#4057) # Description of Changes --- ## 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. --- Dockerfile.ultra-lite | 4 ++-- build.gradle | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite index a9fdd5079..acc62b93b 100644 --- a/Dockerfile.ultra-lite +++ b/Dockerfile.ultra-lite @@ -38,10 +38,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et su-exec \ openjdk21-jre && \ # User permissions - mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \ + mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \ chmod +x /scripts/*.sh && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \ chown stirlingpdfuser:stirlingpdfgroup /app.jar # Set environment variables diff --git a/build.gradle b/build.gradle index 1e472e083..897e0ef38 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '1.1.0' + version = '1.1.1' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging'