diff --git a/app/common/src/main/java/stirling/software/common/config/CleanupAsyncConfig.java b/app/common/src/main/java/stirling/software/common/config/CleanupAsyncConfig.java index 419451f36..e5f39ae9e 100644 --- a/app/common/src/main/java/stirling/software/common/config/CleanupAsyncConfig.java +++ b/app/common/src/main/java/stirling/software/common/config/CleanupAsyncConfig.java @@ -1,14 +1,19 @@ package stirling.software.common.config; import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import lombok.extern.slf4j.Slf4j; + @Configuration @EnableAsync +@Slf4j public class CleanupAsyncConfig { @Bean(name = "cleanupExecutor") @@ -18,6 +23,23 @@ public class CleanupAsyncConfig { exec.setMaxPoolSize(1); exec.setQueueCapacity(100); exec.setThreadNamePrefix("cleanup-"); + + // Set custom rejection handler to log when queue is full + exec.setRejectedExecutionHandler(new RejectedExecutionHandler() { + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { + log.warn("Cleanup task rejected - queue full! Active: {}, Queue size: {}, Pool size: {}", + executor.getActiveCount(), + executor.getQueue().size(), + executor.getPoolSize()); + + // Use caller-runs policy as fallback - this will block the scheduler thread + // but ensures the cleanup still happens + log.warn("Executing cleanup task on scheduler thread as fallback"); + r.run(); + } + }); + exec.initialize(); return exec; } diff --git a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java index 82cafc879..26179e4e8 100644 --- a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java +++ b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java @@ -5,8 +5,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import java.util.function.Predicate; @@ -45,6 +49,12 @@ public class TempFileCleanupService { // Maximum recursion depth for directory traversal private static final int MAX_RECURSION_DEPTH = 5; + + // Cleanup state management + private final AtomicBoolean cleanupRunning = new AtomicBoolean(false); + private final AtomicLong lastCleanupDuration = new AtomicLong(0); + private final AtomicLong cleanupCount = new AtomicLong(0); + private final AtomicLong lastCleanupTimestamp = new AtomicLong(0); // File patterns that identify our temp files private static final Predicate IS_OUR_TEMP_FILE = @@ -126,8 +136,51 @@ public class TempFileCleanupService { fixedDelayString = "#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}", timeUnit = TimeUnit.MINUTES) - public void scheduledCleanup() { - log.info("Running scheduled temporary file cleanup"); + public CompletableFuture scheduledCleanup() { + // Check if cleanup is already running + if (!cleanupRunning.compareAndSet(false, true)) { + log.warn("Cleanup already in progress (running for {}ms), skipping this cycle", + System.currentTimeMillis() - lastCleanupTimestamp.get()); + return CompletableFuture.completedFuture(null); + } + + // Calculate timeout as 2x cleanup interval + long timeoutMinutes = applicationProperties.getSystem().getTempFileManagement().getCleanupIntervalMinutes() * 2; + + return CompletableFuture.supplyAsync(() -> { + long startTime = System.currentTimeMillis(); + lastCleanupTimestamp.set(startTime); + long cleanupNumber = cleanupCount.incrementAndGet(); + + try { + log.info("Starting cleanup #{} with {}min timeout", cleanupNumber, timeoutMinutes); + doScheduledCleanup(); + + long duration = System.currentTimeMillis() - startTime; + lastCleanupDuration.set(duration); + log.info("Cleanup #{} completed successfully in {}ms", cleanupNumber, duration); + return null; + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + lastCleanupDuration.set(duration); + log.error("Cleanup #{} failed after {}ms", cleanupNumber, duration, e); + return null; + } finally { + cleanupRunning.set(false); + } + }).orTimeout(timeoutMinutes, TimeUnit.MINUTES) + .exceptionally(throwable -> { + if (throwable.getCause() instanceof TimeoutException) { + log.error("Cleanup #{} timed out after {}min - forcing cleanup state reset", + cleanupCount.get(), timeoutMinutes); + cleanupRunning.set(false); + } + return null; + }); + } + + /** Internal method that performs the actual cleanup work */ + private void doScheduledCleanup() { long maxAgeMillis = tempFileManager.getMaxAgeMillis(); // Clean up registered temp files (managed by TempFileRegistry) @@ -464,4 +517,51 @@ public class TempFileCleanupService { log.warn("Failed to clean up PDFBox cache file", e); } } + + /** + * Get cleanup status and metrics for monitoring + */ + public String getCleanupStatus() { + if (cleanupRunning.get()) { + long runningTime = System.currentTimeMillis() - lastCleanupTimestamp.get(); + return String.format("Running for %dms (cleanup #%d)", runningTime, cleanupCount.get()); + } else { + long lastDuration = lastCleanupDuration.get(); + long lastTime = lastCleanupTimestamp.get(); + if (lastTime > 0) { + long timeSinceLastRun = System.currentTimeMillis() - lastTime; + return String.format("Last cleanup #%d: %dms duration, %dms ago", + cleanupCount.get(), lastDuration, timeSinceLastRun); + } else { + return "No cleanup runs yet"; + } + } + } + + /** + * Check if cleanup is currently running + */ + public boolean isCleanupRunning() { + return cleanupRunning.get(); + } + + /** + * Get cleanup metrics + */ + public CleanupMetrics getMetrics() { + return new CleanupMetrics( + cleanupCount.get(), + lastCleanupDuration.get(), + lastCleanupTimestamp.get(), + cleanupRunning.get() + ); + } + + /** Simple record for cleanup metrics */ + public record CleanupMetrics( + long totalRuns, + long lastDurationMs, + long lastRunTimestamp, + boolean currentlyRunning + ) {} } diff --git a/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java b/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java index 959e7f354..5e665ed7d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java +++ b/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java @@ -11,7 +11,11 @@ import java.awt.TrayIcon; import java.awt.event.WindowEvent; import java.awt.event.WindowStateListener; import java.io.File; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Objects; import java.util.concurrent.CompletableFuture; @@ -30,6 +34,7 @@ import org.cef.callback.CefDownloadItem; import org.cef.callback.CefDownloadItemCallback; import org.cef.handler.CefDownloadHandlerAdapter; import org.cef.handler.CefLoadHandlerAdapter; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -62,7 +67,11 @@ public class DesktopBrowser implements WebBrowser { private static TrayIcon trayIcon; private static SystemTray systemTray; - public DesktopBrowser() { + private final String appVersion; + private static final String VERSION_FILE = "last_version.txt"; + + public DesktopBrowser(@Qualifier("appVersion") String appVersion) { + this.appVersion = appVersion; SwingUtilities.invokeLater( () -> { loadingWindow = new LoadingWindow(null, "Initializing..."); @@ -120,6 +129,10 @@ public class DesktopBrowser implements WebBrowser { CefSettings settings = builder.getCefSettings(); String basePath = InstallationPathConfig.getClientWebUIPath(); log.info("basePath " + basePath); + + // Check if version has changed and reset cache if needed + checkVersionAndResetCache(basePath); + settings.cache_path = new File(basePath + "cache").getAbsolutePath(); settings.root_cache_path = new File(basePath + "root_cache").getAbsolutePath(); // settings.browser_subprocess_path = new File(basePath + @@ -424,6 +437,87 @@ public class DesktopBrowser implements WebBrowser { } } + private void checkVersionAndResetCache(String basePath) { + try { + Path versionFilePath = Paths.get(basePath, VERSION_FILE); + String currentVersion = appVersion != null ? appVersion : "0.0.0"; + + // Read last stored version + String lastVersion = "0.0.0"; + if (Files.exists(versionFilePath)) { + lastVersion = new String(Files.readAllBytes(versionFilePath)).trim(); + } + + log.info("Current version: {}, Last version: {}", currentVersion, lastVersion); + + // Compare major and minor versions + if (shouldResetCache(currentVersion, lastVersion)) { + log.info("Version change detected, resetting cache"); + resetCache(basePath); + + // Store current version + Files.createDirectories(versionFilePath.getParent()); + Files.write(versionFilePath, currentVersion.getBytes()); + log.info("Version file updated to: {}", currentVersion); + } + } catch (Exception e) { + log.error("Error checking version and resetting cache", e); + } + } + + private boolean shouldResetCache(String currentVersion, String lastVersion) { + try { + String[] currentParts = currentVersion.split("\\."); + String[] lastParts = lastVersion.split("\\."); + + if (currentParts.length < 2 || lastParts.length < 2) { + return true; // Reset if version format is unexpected + } + + int currentMajor = Integer.parseInt(currentParts[0]); + int currentMinor = Integer.parseInt(currentParts[1]); + int lastMajor = Integer.parseInt(lastParts[0]); + int lastMinor = Integer.parseInt(lastParts[1]); + + return currentMajor != lastMajor || currentMinor != lastMinor; + } catch (Exception e) { + log.warn("Error comparing versions, will reset cache: {}", e.getMessage()); + return true; + } + } + + private void resetCache(String basePath) { + try { + Path cachePath = Paths.get(basePath, "cache"); + Path rootCachePath = Paths.get(basePath, "root_cache"); + + if (Files.exists(cachePath)) { + deleteDirectoryRecursively(cachePath); + log.info("Deleted cache directory: {}", cachePath); + } + + if (Files.exists(rootCachePath)) { + deleteDirectoryRecursively(rootCachePath); + log.info("Deleted root cache directory: {}", rootCachePath); + } + } catch (Exception e) { + log.error("Error resetting cache directories", e); + } + } + + private void deleteDirectoryRecursively(Path path) throws IOException { + Files.walk(path) + .sorted((a, b) -> b.compareTo(a)) // Delete files before directories + .forEach( + p -> { + try { + Files.delete(p); + } catch (IOException e) { + log.warn("Could not delete: {}", p, e); + } + }); + } + @PreDestroy public void cleanup() { if (browser != null) browser.close(true); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java index 82dcc2bc5..71290f258 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java @@ -232,7 +232,8 @@ public class ConvertImgPDFController { PdfUtils.imageToPdf(file, fitOption, autoRotate, colorType, pdfDocumentFactory); return WebResponseUtils.bytesToWebResponse( bytes, - new File(file[0].getOriginalFilename()).getName().replaceFirst("[.][^.]+$", "") + "_converted.pdf"); + new File(file[0].getOriginalFilename()).getName().replaceFirst("[.][^.]+$", "") + + "_converted.pdf"); } private String getMediaType(String imageFormat) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java index e572432df..fc7b7d298 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java @@ -47,7 +47,8 @@ public class PrintFileController { throws IOException { MultipartFile file = request.getFileInput(); String originalFilename = file.getOriginalFilename(); - if (originalFilename != null && (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) { + if (originalFilename != null + && (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) { throw new IOException("Invalid file path detected: " + originalFilename); } String printerName = request.getPrinterName(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index a784b0f39..f5bc9dc65 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -42,7 +42,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; -import java.lang.IllegalArgumentException; @RestController @RequestMapping("/api/v1/misc") @@ -67,7 +66,7 @@ public class StampController { if (pdfFileName.contains("..") || pdfFileName.startsWith("/")) { throw new IllegalArgumentException("Invalid PDF file path"); } - + String stampType = request.getStampType(); String stampText = request.getStampText(); MultipartFile stampImage = request.getStampImage(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java index d79105c26..44f2b892a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java @@ -331,7 +331,8 @@ public class PipelineProcessor { for (File file : files) { Path normalizedPath = Paths.get(file.getName()).normalize(); if (normalizedPath.startsWith("..")) { - throw new SecurityException("Potential path traversal attempt in file name: " + file.getName()); + throw new SecurityException( + "Potential path traversal attempt in file name: " + file.getName()); } Path path = Paths.get(file.getAbsolutePath()); // debug statement diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index fd5a9b288..484a1c116 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -83,7 +83,9 @@ public class WatermarkController { MultipartFile watermarkImage = request.getWatermarkImage(); if (watermarkImage != null) { String watermarkImageFileName = watermarkImage.getOriginalFilename(); - if (watermarkImageFileName != null && (watermarkImageFileName.contains("..") || watermarkImageFileName.startsWith("/"))) { + if (watermarkImageFileName != null + && (watermarkImageFileName.contains("..") + || watermarkImageFileName.startsWith("/"))) { throw new SecurityException("Invalid file path in watermarkImage"); } }