diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..12a5da573 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)", + "Bash(mkdir:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/common/src/main/java/stirling/software/common/config/TempFileConfiguration.java b/common/src/main/java/stirling/software/common/config/TempFileConfiguration.java new file mode 100644 index 000000000..8939e1474 --- /dev/null +++ b/common/src/main/java/stirling/software/common/config/TempFileConfiguration.java @@ -0,0 +1,96 @@ +package stirling.software.common.config; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.util.TempFileRegistry; + +/** + * Configuration for the temporary file management system. Sets up the necessary beans and + * configures system properties. + */ +@Slf4j +@Configuration +public class TempFileConfiguration { + + @Value("${stirling.tempfiles.directory:}") + private String customTempDirectory; + + @Autowired + @Qualifier("machineType") + private String machineType; + + @Value("${stirling.tempfiles.prefix:stirling-pdf-}") + private String tempFilePrefix; + + /** + * Create the TempFileRegistry bean. + * + * @return A new TempFileRegistry instance + */ + @Bean + public TempFileRegistry tempFileRegistry() { + return new TempFileRegistry(); + } + + @PostConstruct + public void initTempFileConfig() { + try { + // If a custom temp directory is specified in the config, use it + if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + log.info("Created custom temporary directory: {}", tempDir); + } + + // Set Java temp directory system property if in Docker/Kubernetes mode + if ("Docker".equals(machineType) || "Kubernetes".equals(machineType)) { + System.setProperty("java.io.tmpdir", customTempDirectory); + log.info( + "Set system temp directory to: {} for environment: {}", + customTempDirectory, + machineType); + } + } else { + // No custom directory specified, use java.io.tmpdir + application subfolder + String defaultTempDir; + + if ("Docker".equals(machineType) || "Kubernetes".equals(machineType)) { + // Container environments should continue to use /tmp/stirling-pdf + defaultTempDir = "/tmp/stirling-pdf"; + } else { + // Use system temp directory (java.io.tmpdir) with our application subfolder + // This automatically handles Windows (AppData\Local\Temp), macOS, and Linux systems + defaultTempDir = System.getProperty("java.io.tmpdir") + File.separator + "stirling-pdf"; + } + customTempDirectory = defaultTempDir; + + // Create the default temp directory + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + log.info("Created default OS-specific temporary directory: {}", tempDir); + } + } + + log.info("Temporary file configuration initialized"); + log.info("Using temp directory: {}", customTempDirectory); + log.info("Temp file prefix: {}", tempFilePrefix); + } catch (Exception e) { + log.error("Failed to initialize temporary file configuration", e); + } + } +} diff --git a/common/src/main/java/stirling/software/common/config/TempFileShutdownHook.java b/common/src/main/java/stirling/software/common/config/TempFileShutdownHook.java new file mode 100644 index 000000000..6fd3bdeff --- /dev/null +++ b/common/src/main/java/stirling/software/common/config/TempFileShutdownHook.java @@ -0,0 +1,84 @@ +package stirling.software.common.config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.TempFileRegistry; + +/** + * Handles cleanup of temporary files on application shutdown. Implements Spring's DisposableBean + * interface to ensure cleanup happens during normal application shutdown. + */ +@Slf4j +@Component +public class TempFileShutdownHook implements DisposableBean { + + private final TempFileRegistry registry; + + @Autowired + public TempFileShutdownHook(TempFileRegistry registry) { + this.registry = registry; + + // Register a JVM shutdown hook as a backup in case Spring's + // DisposableBean mechanism doesn't trigger (e.g., during a crash) + Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupTempFiles)); + } + + /** Spring's DisposableBean interface method. Called during normal application shutdown. */ + @Override + public void destroy() { + log.info("Application shutting down, cleaning up temporary files"); + cleanupTempFiles(); + } + + /** Clean up all registered temporary files and directories. */ + private void cleanupTempFiles() { + try { + // Clean up all registered files + Set files = registry.getAllRegisteredFiles(); + int deletedCount = 0; + + for (Path file : files) { + try { + if (Files.exists(file)) { + Files.deleteIfExists(file); + deletedCount++; + } + } catch (IOException e) { + log.warn("Failed to delete temp file during shutdown: {}", file, e); + } + } + + // Clean up all registered directories + Set directories = registry.getTempDirectories(); + for (Path dir : directories) { + try { + if (Files.exists(dir)) { + GeneralUtils.deleteDirectory(dir); + deletedCount++; + } + } catch (IOException e) { + log.warn("Failed to delete temp directory during shutdown: {}", dir, e); + } + } + + log.info( + "Shutdown cleanup complete. Deleted {} temporary files/directories", + deletedCount); + + // Clear the registry + registry.clear(); + } catch (Exception e) { + log.error("Error during shutdown cleanup", e); + } + } +} diff --git a/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java b/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java index e4b9173d0..51f52c34d 100644 --- a/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java +++ b/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java @@ -23,6 +23,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; /** * Adaptive PDF document factory that optimizes memory usage based on file size and available system @@ -402,10 +405,37 @@ public class CustomPDFDocumentFactory { } } - // Temp file handling with enhanced logging + // Temp file handling with enhanced logging and registry integration private Path createTempFile(String prefix) throws IOException { + // Check if TempFileManager is available in the application context + try { + TempFileManager tempFileManager = + ApplicationContextProvider.getBean(TempFileManager.class); + if (tempFileManager != null) { + // Use TempFileManager to create and register the temp file + File file = tempFileManager.createTempFile(".tmp"); + log.debug("Created and registered temp file via TempFileManager: {}", file); + return file.toPath(); + } + } catch (Exception e) { + log.debug("TempFileManager not available, falling back to standard temp file creation"); + } + + // Fallback to standard temp file creation Path file = Files.createTempFile(prefix + tempCounter.incrementAndGet() + "-", ".tmp"); log.debug("Created temp file: {}", file); + + // Try to register the file with a static registry if possible + try { + TempFileRegistry registry = ApplicationContextProvider.getBean(TempFileRegistry.class); + if (registry != null) { + registry.register(file); + log.debug("Registered fallback temp file with registry: {}", file); + } + } catch (Exception e) { + log.debug("Could not register fallback temp file with registry: {}", file); + } + return file; } diff --git a/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java b/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java new file mode 100644 index 000000000..ee6fdd3c9 --- /dev/null +++ b/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java @@ -0,0 +1,453 @@ +package stirling.software.common.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Service to periodically clean up temporary files. Runs scheduled tasks to delete old temp files + * and directories. + */ +@Slf4j +@Service +public class TempFileCleanupService { + + private final TempFileRegistry registry; + private final TempFileManager tempFileManager; + + @Value("${stirling.tempfiles.cleanup-interval-minutes:30}") + private long cleanupIntervalMinutes; + + @Value("${stirling.tempfiles.startup-cleanup:true}") + private boolean performStartupCleanup; + + @Autowired + @Qualifier("machineType") + private String machineType; + + @Value("${stirling.tempfiles.system-temp-dir:/tmp}") + private String systemTempDir; + + @Value("${stirling.tempfiles.directory:/tmp/stirling-pdf}") + private String customTempDirectory; + + @Value("${stirling.tempfiles.libreoffice-dir:/tmp/stirling-pdf/libreoffice}") + private String libreOfficeTempDir; + + @Autowired + public TempFileCleanupService(TempFileRegistry registry, TempFileManager tempFileManager) { + this.registry = registry; + this.tempFileManager = tempFileManager; + + // Create necessary directories + ensureDirectoriesExist(); + + // Perform startup cleanup if enabled + if (performStartupCleanup) { + runStartupCleanup(); + } + } + + /** Ensure that all required temp directories exist */ + private void ensureDirectoriesExist() { + try { + // Create the main temp directory if specified + if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + log.info("Created temp directory: {}", tempDir); + } + } + + // Create LibreOffice temp directory if specified + if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) { + Path loTempDir = Path.of(libreOfficeTempDir); + if (!Files.exists(loTempDir)) { + Files.createDirectories(loTempDir); + log.info("Created LibreOffice temp directory: {}", loTempDir); + } + } + } catch (IOException e) { + log.error("Error creating temp directories", e); + } + } + + /** Scheduled task to clean up old temporary files. Runs at the configured interval. */ + @Scheduled( + fixedDelayString = "${stirling.tempfiles.cleanup-interval-minutes:60}", + timeUnit = TimeUnit.MINUTES) + public void scheduledCleanup() { + log.info("Running scheduled temporary file cleanup"); + long maxAgeMillis = tempFileManager.getMaxAgeMillis(); + + // Clean up registered temp files (managed by TempFileRegistry) + int registeredDeletedCount = tempFileManager.cleanupOldTempFiles(maxAgeMillis); + log.info("Cleaned up {} registered temporary files", registeredDeletedCount); + + // Clean up registered temp directories + int directoriesDeletedCount = 0; + for (Path directory : registry.getTempDirectories()) { + try { + if (Files.exists(directory)) { + GeneralUtils.deleteDirectory(directory); + directoriesDeletedCount++; + log.debug("Cleaned up temporary directory: {}", directory); + } + } catch (IOException e) { + log.warn("Failed to clean up temporary directory: {}", directory, e); + } + } + + int unregisteredDeletedCount = 0; + try { + // Get all directories we need to clean + Path systemTempPath; + if (systemTempDir != null && !systemTempDir.isEmpty()) { + systemTempPath = Path.of(systemTempDir); + } else { + systemTempPath = Path.of(System.getProperty("java.io.tmpdir")); + } + + Path[] dirsToScan = { + systemTempPath, Path.of(customTempDirectory), Path.of(libreOfficeTempDir) + }; + + boolean containerMode = + "Docker".equals(machineType) || "Kubernetes".equals(machineType); + + // Process each directory + for (Path tempDir : dirsToScan) { + if (!Files.exists(tempDir)) { + continue; + } + + int dirDeletedCount = cleanupDirectory(tempDir, containerMode, 0, maxAgeMillis); + unregisteredDeletedCount += dirDeletedCount; + if (dirDeletedCount > 0) { + log.info( + "Cleaned up {} unregistered files/directories in {}", + dirDeletedCount, + tempDir); + } + } + } catch (IOException e) { + log.error("Error during scheduled cleanup of unregistered files", e); + } + + log.info( + "Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories", + registeredDeletedCount, + unregisteredDeletedCount, + directoriesDeletedCount); + } + + /** Overload of cleanupDirectory that uses the specified max age for files */ + private int cleanupDirectory( + Path directory, boolean containerMode, int depth, long maxAgeMillis) + throws IOException { + if (depth > 5) { + log.warn("Maximum directory recursion depth reached for: {}", directory); + return 0; + } + + int deletedCount = 0; + + try (Stream paths = Files.list(directory)) { + for (Path path : paths.toList()) { + String fileName = path.getFileName().toString(); + + // Skip registered files - these are handled by TempFileManager + if (registry.contains(path.toFile())) { + continue; + } + + // Skip Jetty-related directories and files + if (fileName.contains("jetty") || fileName.startsWith("jetty-")) { + continue; + } + + // Check if this is a directory we should recursively scan + if (Files.isDirectory(path)) { + // Don't recurse into certain system directories + if (!fileName.equals("proc") + && !fileName.equals("sys") + && !fileName.equals("dev")) { + deletedCount += + cleanupDirectory(path, containerMode, depth + 1, maxAgeMillis); + } + continue; + } + + // Determine if this file matches our temp file patterns + boolean isOurTempFile = + fileName.startsWith("stirling-pdf-") + || fileName.startsWith("output_") + || fileName.startsWith("compressedPDF") + || fileName.startsWith("pdf-save-") + || fileName.startsWith("pdf-stream-") + || fileName.startsWith("PDFBox") + || fileName.startsWith("input_") + || fileName.startsWith("overlay-"); + + // Avoid touching Jetty files + boolean isSystemTempFile = + fileName.matches("lu\\d+[a-z0-9]*\\.tmp") + || fileName.matches("ocr_process\\d+") + || (fileName.startsWith("tmp") && !fileName.contains("jetty")) + || fileName.startsWith("OSL_PIPE_") + || (fileName.endsWith(".tmp") && !fileName.contains("jetty")); + + boolean shouldDelete = isOurTempFile || (containerMode && isSystemTempFile); + + // Special case for zero-byte files - these are often corrupted temp files + try { + if (Files.size(path) == 0) { + // For empty files, use a shorter timeout (5 minutes) + long lastModified = Files.getLastModifiedTime(path).toMillis(); + long currentTime = System.currentTimeMillis(); + // Delete empty files older than 5 minutes + if ((currentTime - lastModified) > 5 * 60 * 1000) { + shouldDelete = true; + } + } + } catch (IOException e) { + log.debug("Could not check file size, skipping: {}", path); + } + + // Check file age against maxAgeMillis + if (shouldDelete) { + try { + long lastModified = Files.getLastModifiedTime(path).toMillis(); + long currentTime = System.currentTimeMillis(); + shouldDelete = (currentTime - lastModified) > maxAgeMillis; + } catch (IOException e) { + log.debug("Could not check file age, skipping: {}", path); + shouldDelete = false; + } + } + + if (shouldDelete) { + try { + Files.deleteIfExists(path); + deletedCount++; + log.debug( + "Deleted unregistered temp file during scheduled cleanup: {}", + path); + } catch (IOException e) { + // Handle locked files more gracefully - just log at debug level + if (e.getMessage() != null + && e.getMessage().contains("being used by another process")) { + log.debug("File locked, skipping delete: {}", path); + } else { + log.warn( + "Failed to delete temp file during scheduled cleanup: {}", + path, + e); + } + } + } + } + } + + return deletedCount; + } + + /** + * Perform startup cleanup of stale temporary files from previous runs. This is especially + * important in Docker environments where temp files persist between container restarts. + */ + private void runStartupCleanup() { + log.info("Running startup temporary file cleanup"); + + try { + // Get all directories we need to clean + Path systemTempPath; + if (systemTempDir != null && !systemTempDir.isEmpty()) { + systemTempPath = Path.of(systemTempDir); + } else { + systemTempPath = Path.of(System.getProperty("java.io.tmpdir")); + } + + Path[] dirsToScan = { + systemTempPath, Path.of(customTempDirectory), Path.of(libreOfficeTempDir) + }; + + int totalDeletedCount = 0; + + boolean containerMode = + "Docker".equals(machineType) || "Kubernetes".equals(machineType); + log.info( + "Running in {} mode, using {} cleanup strategy", + machineType, + containerMode ? "aggressive" : "conservative"); + + // Process each directory + for (Path tempDir : dirsToScan) { + if (!Files.exists(tempDir)) { + log.warn("Temporary directory does not exist: {}", tempDir); + continue; + } + + log.info("Scanning directory for cleanup: {}", tempDir); + int dirDeletedCount = cleanupDirectory(tempDir, containerMode, 0); + totalDeletedCount += dirDeletedCount; + log.info("Cleaned up {} files/directories in {}", dirDeletedCount, tempDir); + } + + log.info( + "Startup cleanup complete. Deleted {} temporary files/directories", + totalDeletedCount); + } catch (IOException e) { + log.error("Error during startup cleanup", e); + } + } + + /** + * Recursively clean up a directory for temporary files. + * + * @param directory The directory to clean + * @param containerMode Whether we're in container mode (more aggressive cleanup) + * @param depth Current recursion depth (to prevent excessive recursion) + * @return Number of files deleted + */ + private int cleanupDirectory(Path directory, boolean containerMode, int depth) + throws IOException { + if (depth > 5) { + log.warn("Maximum directory recursion depth reached for: {}", directory); + return 0; + } + + int deletedCount = 0; + + try (Stream paths = Files.list(directory)) { + for (Path path : paths.toList()) { + String fileName = path.getFileName().toString(); + + // Skip Jetty-related directories and files + if (fileName.contains("jetty") || fileName.startsWith("jetty-")) { + continue; + } + + // Check if this is a directory we should recursively scan + if (Files.isDirectory(path)) { + // Don't recurse into certain system directories + if (!fileName.equals("proc") + && !fileName.equals("sys") + && !fileName.equals("dev")) { + deletedCount += cleanupDirectory(path, containerMode, depth + 1); + } + continue; + } + + // Determine if this file matches our temp file patterns + boolean isOurTempFile = + fileName.startsWith("stirling-pdf-") + || fileName.startsWith("output_") + || fileName.startsWith("compressedPDF") + || fileName.startsWith("pdf-save-") + || fileName.startsWith("pdf-stream-") + || fileName.startsWith("PDFBox") + || fileName.startsWith("input_") + || fileName.startsWith("overlay-"); + + // Avoid touching Jetty files + boolean isSystemTempFile = + fileName.matches("lu\\d+[a-z0-9]*\\.tmp") + || fileName.matches("ocr_process\\d+") + || (fileName.startsWith("tmp") && !fileName.contains("jetty")) + || fileName.startsWith("OSL_PIPE_") + || (fileName.endsWith(".tmp") && !fileName.contains("jetty")); + + boolean shouldDelete = isOurTempFile || (containerMode && isSystemTempFile); + + // Special case for zero-byte files - these are often corrupted temp files + boolean isEmptyFile = false; + try { + if (!Files.isDirectory(path) && Files.size(path) == 0) { + isEmptyFile = true; + // For empty files, use a shorter timeout (5 minutes) + long lastModified = Files.getLastModifiedTime(path).toMillis(); + long currentTime = System.currentTimeMillis(); + // Delete empty files older than 5 minutes + if ((currentTime - lastModified) > 5 * 60 * 1000) { + shouldDelete = true; + } + } + } catch (IOException e) { + log.debug("Could not check file size, skipping: {}", path); + } + + // For non-container mode, check file age before deleting + if (!containerMode && (isOurTempFile || isSystemTempFile) && !isEmptyFile) { + try { + long lastModified = Files.getLastModifiedTime(path).toMillis(); + long currentTime = System.currentTimeMillis(); + // Only delete files older than 24 hours in non-container mode + shouldDelete = (currentTime - lastModified) > 24 * 60 * 60 * 1000; + } catch (IOException e) { + log.debug("Could not check file age, skipping: {}", path); + shouldDelete = false; + } + } + + if (shouldDelete) { + try { + if (Files.isDirectory(path)) { + GeneralUtils.deleteDirectory(path); + } else { + Files.deleteIfExists(path); + } + deletedCount++; + log.debug("Deleted temp file during startup cleanup: {}", path); + } catch (IOException e) { + log.warn("Failed to delete temp file during startup cleanup: {}", path, e); + } + } + } + } + + return deletedCount; + } + + /** Clean up LibreOffice temporary files. This method is called after LibreOffice operations. */ + public void cleanupLibreOfficeTempFiles() { + // Cleanup known LibreOffice temp directories + try { + Set directories = registry.getTempDirectories(); + for (Path dir : directories) { + if (dir.getFileName().toString().contains("libreoffice")) { + // For directories containing "libreoffice", delete all contents + // but keep the directory itself for future use + try (Stream files = Files.list(dir)) { + for (Path file : files.toList()) { + if (Files.isDirectory(file)) { + GeneralUtils.deleteDirectory(file); + } else { + Files.deleteIfExists(file); + } + } + } + log.debug("Cleaned up LibreOffice temp directory contents: {}", dir); + } + } + } catch (IOException e) { + log.warn("Failed to clean up LibreOffice temp files", e); + } + } +} diff --git a/common/src/main/java/stirling/software/common/util/ApplicationContextProvider.java b/common/src/main/java/stirling/software/common/util/ApplicationContextProvider.java new file mode 100644 index 000000000..505b21fab --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/ApplicationContextProvider.java @@ -0,0 +1,76 @@ +package stirling.software.common.util; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +/** + * Helper class that provides access to the ApplicationContext. Useful for getting beans in classes + * that are not managed by Spring. + */ +@Component +public class ApplicationContextProvider implements ApplicationContextAware { + + private static ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext context) throws BeansException { + applicationContext = context; + } + + /** + * Get a bean by class type. + * + * @param The type of the bean + * @param beanClass The class of the bean + * @return The bean instance, or null if not found + */ + public static T getBean(Class beanClass) { + if (applicationContext == null) { + return null; + } + try { + return applicationContext.getBean(beanClass); + } catch (BeansException e) { + return null; + } + } + + /** + * Get a bean by name and class type. + * + * @param The type of the bean + * @param name The name of the bean + * @param beanClass The class of the bean + * @return The bean instance, or null if not found + */ + public static T getBean(String name, Class beanClass) { + if (applicationContext == null) { + return null; + } + try { + return applicationContext.getBean(name, beanClass); + } catch (BeansException e) { + return null; + } + } + + /** + * Check if a bean of the specified type exists. + * + * @param beanClass The class of the bean + * @return true if the bean exists, false otherwise + */ + public static boolean containsBean(Class beanClass) { + if (applicationContext == null) { + return false; + } + try { + applicationContext.getBean(beanClass); + return true; + } catch (BeansException e) { + return false; + } + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFileManager.java b/common/src/main/java/stirling/software/common/util/TempFileManager.java new file mode 100644 index 000000000..b427aee80 --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFileManager.java @@ -0,0 +1,250 @@ +package stirling.software.common.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import lombok.extern.slf4j.Slf4j; + +/** + * Service for managing temporary files in Stirling-PDF. Provides methods for creating, tracking, + * and cleaning up temporary files. + */ +@Slf4j +@Service +public class TempFileManager { + + private final TempFileRegistry registry; + + @Value("${stirling.tempfiles.prefix:stirling-pdf-}") + private String tempFilePrefix; + + @Value("${stirling.tempfiles.directory:}") + private String customTempDirectory; + + @Value("${stirling.tempfiles.libreoffice-dir:}") + private String libreOfficeTempDir; + + @Value("${stirling.tempfiles.max-age-hours:24}") + private long maxAgeHours; + + + @Autowired + public TempFileManager(TempFileRegistry registry) { + this.registry = registry; + } + + /** + * Create a temporary file with the Stirling-PDF prefix. The file is automatically registered + * with the registry. + * + * @param suffix The suffix for the temporary file + * @return The created temporary file + * @throws IOException If an I/O error occurs + */ + public File createTempFile(String suffix) throws IOException { + Path tempFilePath; + if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + tempFilePath = Files.createTempFile(tempDir, tempFilePrefix, suffix); + } else { + tempFilePath = Files.createTempFile(tempFilePrefix, suffix); + } + File tempFile = tempFilePath.toFile(); + return registry.register(tempFile); + } + + /** + * Create a temporary directory with the Stirling-PDF prefix. The directory is automatically + * registered with the registry. + * + * @return The created temporary directory + * @throws IOException If an I/O error occurs + */ + public Path createTempDirectory() throws IOException { + Path tempDirPath; + if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + tempDirPath = Files.createTempDirectory(tempDir, tempFilePrefix); + } else { + tempDirPath = Files.createTempDirectory(tempFilePrefix); + } + return registry.registerDirectory(tempDirPath); + } + + /** + * Convert a MultipartFile to a temporary File and register it. This is a wrapper around + * GeneralUtils.convertMultipartFileToFile that ensures the created temp file is registered. + * + * @param multipartFile The MultipartFile to convert + * @return The created temporary file + * @throws IOException If an I/O error occurs + */ + public File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException { + File tempFile = GeneralUtils.convertMultipartFileToFile(multipartFile); + return registry.register(tempFile); + } + + /** + * Delete a temporary file and unregister it from the registry. + * + * @param file The file to delete + * @return true if the file was deleted successfully, false otherwise + */ + public boolean deleteTempFile(File file) { + if (file != null && file.exists()) { + boolean deleted = file.delete(); + if (deleted) { + registry.unregister(file); + log.debug("Deleted temp file: {}", file.getAbsolutePath()); + } else { + log.warn("Failed to delete temp file: {}", file.getAbsolutePath()); + } + return deleted; + } + return false; + } + + /** + * Delete a temporary file and unregister it from the registry. + * + * @param path The path to delete + * @return true if the file was deleted successfully, false otherwise + */ + public boolean deleteTempFile(Path path) { + if (path != null) { + try { + boolean deleted = Files.deleteIfExists(path); + if (deleted) { + registry.unregister(path); + log.debug("Deleted temp file: {}", path.toString()); + } else { + log.debug("Temp file already deleted or does not exist: {}", path.toString()); + } + return deleted; + } catch (IOException e) { + log.warn("Failed to delete temp file: {}", path.toString(), e); + return false; + } + } + return false; + } + + /** + * Delete a temporary directory and all its contents. + * + * @param directory The directory to delete + */ + public void deleteTempDirectory(Path directory) { + if (directory != null && Files.isDirectory(directory)) { + try { + GeneralUtils.deleteDirectory(directory); + log.debug("Deleted temp directory: {}", directory.toString()); + } catch (IOException e) { + log.warn("Failed to delete temp directory: {}", directory.toString(), e); + } + } + } + + /** + * Register an existing file with the registry. + * + * @param file The file to register + * @return The same file for method chaining + */ + public File register(File file) { + if (file != null && file.exists()) { + return registry.register(file); + } + return file; + } + + /** + * Clean up old temporary files based on age. + * + * @param maxAgeMillis Maximum age in milliseconds for temp files + * @return Number of files deleted + */ + public int cleanupOldTempFiles(long maxAgeMillis) { + int deletedCount = 0; + + // Get files older than max age + Set oldFiles = registry.getFilesOlderThan(maxAgeMillis); + + // Delete each old file + for (Path file : oldFiles) { + if (deleteTempFile(file)) { + deletedCount++; + } + } + + log.info("Cleaned up {} old temporary files", deletedCount); + return deletedCount; + } + + /** + * Get the maximum age for temporary files in milliseconds. + * + * @return Maximum age in milliseconds + */ + public long getMaxAgeMillis() { + return Duration.ofHours(maxAgeHours).toMillis(); + } + + /** + * Generate a unique temporary file name with the Stirling-PDF prefix. + * + * @param type Type identifier for the temp file + * @param extension File extension (without the dot) + * @return A unique temporary file name + */ + public String generateTempFileName(String type, String extension) { + String uuid = UUID.randomUUID().toString().substring(0, 8); + return tempFilePrefix + type + "-" + uuid + "." + extension; + } + + /** + * Register a known LibreOffice temporary directory. This is used when integrating with + * LibreOffice for file conversions. + * + * @return The LibreOffice temp directory + * @throws IOException If directory creation fails + */ + public Path registerLibreOfficeTempDir() throws IOException { + Path loTempDir; + + // First check if explicitly configured + if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) { + loTempDir = Path.of(libreOfficeTempDir); + } + // Next check if we have a custom temp directory + else if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + loTempDir = Path.of(customTempDirectory, "libreoffice"); + } + // Fall back to system temp dir with our application prefix + else { + loTempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf-libreoffice"); + } + + if (!Files.exists(loTempDir)) { + Files.createDirectories(loTempDir); + } + + return registry.registerDirectory(loTempDir); + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFileNamingConvention.java b/common/src/main/java/stirling/software/common/util/TempFileNamingConvention.java new file mode 100644 index 000000000..f0ba2890d --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFileNamingConvention.java @@ -0,0 +1,82 @@ +package stirling.software.common.util; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +/** + * Utility class for generating consistent temporary file names. Provides methods to create + * standardized, identifiable temp file names. + */ +public class TempFileNamingConvention { + + private static final String DEFAULT_PREFIX = "stirling-pdf-"; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + /** + * Create a temporary file name for a specific operation type. + * + * @param operationType The type of operation (e.g., "merge", "split", "watermark") + * @param extension File extension without the dot + * @return A formatted temporary file name + */ + public static String forOperation(String operationType, String extension) { + String timestamp = LocalDateTime.now().format(DATE_FORMATTER); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return DEFAULT_PREFIX + operationType + "-" + timestamp + "-" + uuid + "." + extension; + } + + /** + * Create a temporary file name for intermediate processing. + * + * @param operationType The type of operation + * @param step The processing step number or identifier + * @param extension File extension without the dot + * @return A formatted temporary file name for intermediate processing + */ + public static String forProcessingStep(String operationType, String step, String extension) { + String uuid = UUID.randomUUID().toString().substring(0, 8); + return DEFAULT_PREFIX + operationType + "-" + step + "-" + uuid + "." + extension; + } + + /** + * Create a temporary file name for a LibreOffice operation. + * + * @param sourceFilename The original filename + * @param extension File extension without the dot + * @return A formatted temporary file name for LibreOffice operations + */ + public static String forLibreOffice(String sourceFilename, String extension) { + // Extract base filename without extension + String baseName = sourceFilename; + int lastDot = sourceFilename.lastIndexOf('.'); + if (lastDot > 0) { + baseName = sourceFilename.substring(0, lastDot); + } + + // Sanitize the base name + baseName = baseName.replaceAll("[^a-zA-Z0-9]", "_"); + + // Limit the length of the base name + if (baseName.length() > 20) { + baseName = baseName.substring(0, 20); + } + + String uuid = UUID.randomUUID().toString().substring(0, 8); + return DEFAULT_PREFIX + "lo-" + baseName + "-" + uuid + "." + extension; + } + + /** + * Create a temporary directory name. + * + * @param purpose The purpose of the directory + * @return A formatted temporary directory name + */ + public static String forTempDirectory(String purpose) { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String uuid = UUID.randomUUID().toString().substring(0, 8); + return DEFAULT_PREFIX + purpose + "-" + timestamp + "-" + uuid; + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFileRegistry.java b/common/src/main/java/stirling/software/common/util/TempFileRegistry.java new file mode 100644 index 000000000..96aaf8ffa --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFileRegistry.java @@ -0,0 +1,177 @@ +package stirling.software.common.util; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * Central registry for tracking temporary files created by Stirling-PDF. Maintains a thread-safe + * collection of paths with their creation timestamps. + */ +@Slf4j +@Component +public class TempFileRegistry { + + // Track temp files with creation timestamps + private final Map registeredFiles = new ConcurrentHashMap<>(); + + // Separately track third-party temp files that need special handling + private final Set thirdPartyTempFiles = new ConcurrentSkipListSet<>(); + + // Track temp directories + private final Set tempDirectories = new ConcurrentSkipListSet<>(); + + /** + * Register a temporary file with the registry. + * + * @param file The temporary file to track + * @return The same file for method chaining + */ + public File register(File file) { + if (file != null) { + registeredFiles.put(file.toPath(), Instant.now()); + log.debug("Registered temp file: {}", file.getAbsolutePath()); + } + return file; + } + + /** + * Register a temporary path with the registry. + * + * @param path The temporary path to track + * @return The same path for method chaining + */ + public Path register(Path path) { + if (path != null) { + registeredFiles.put(path, Instant.now()); + log.debug("Registered temp path: {}", path.toString()); + } + return path; + } + + /** + * Register a temporary directory to be cleaned up. + * + * @param directory Directory to register + * @return The same directory for method chaining + */ + public Path registerDirectory(Path directory) { + if (directory != null && Files.isDirectory(directory)) { + tempDirectories.add(directory); + log.debug("Registered temp directory: {}", directory.toString()); + } + return directory; + } + + /** + * Register a third-party temporary file that requires special handling. + * + * @param file The third-party temp file + * @return The same file for method chaining + */ + public File registerThirdParty(File file) { + if (file != null) { + thirdPartyTempFiles.add(file.toPath()); + log.debug("Registered third-party temp file: {}", file.getAbsolutePath()); + } + return file; + } + + /** + * Unregister a file from the registry. + * + * @param file The file to unregister + */ + public void unregister(File file) { + if (file != null) { + registeredFiles.remove(file.toPath()); + thirdPartyTempFiles.remove(file.toPath()); + log.debug("Unregistered temp file: {}", file.getAbsolutePath()); + } + } + + /** + * Unregister a path from the registry. + * + * @param path The path to unregister + */ + public void unregister(Path path) { + if (path != null) { + registeredFiles.remove(path); + thirdPartyTempFiles.remove(path); + log.debug("Unregistered temp path: {}", path.toString()); + } + } + + /** + * Get all registered temporary files. + * + * @return Set of registered file paths + */ + public Set getAllRegisteredFiles() { + return registeredFiles.keySet(); + } + + /** + * Get temporary files older than the specified duration in milliseconds. + * + * @param maxAgeMillis Maximum age in milliseconds + * @return Set of paths older than the specified age + */ + public Set getFilesOlderThan(long maxAgeMillis) { + Instant cutoffTime = Instant.now().minusMillis(maxAgeMillis); + return registeredFiles.entrySet().stream() + .filter(entry -> entry.getValue().isBefore(cutoffTime)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + /** + * Get all registered third-party temporary files. + * + * @return Set of third-party file paths + */ + public Set getThirdPartyTempFiles() { + return thirdPartyTempFiles; + } + + /** + * Get all registered temporary directories. + * + * @return Set of temporary directory paths + */ + public Set getTempDirectories() { + return tempDirectories; + } + + /** + * Check if a file is registered in the registry. + * + * @param file The file to check + * @return True if the file is registered, false otherwise + */ + public boolean contains(File file) { + if (file == null) { + return false; + } + Path path = file.toPath(); + return registeredFiles.containsKey(path) || thirdPartyTempFiles.contains(path); + } + + /** Clear all registry data. */ + public void clear() { + registeredFiles.clear(); + thirdPartyTempFiles.clear(); + tempDirectories.clear(); + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFileUtil.java b/common/src/main/java/stirling/software/common/util/TempFileUtil.java new file mode 100644 index 000000000..2a739deda --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFileUtil.java @@ -0,0 +1,162 @@ +package stirling.software.common.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import lombok.extern.slf4j.Slf4j; + +/** + * Utility class for handling temporary files with proper cleanup. Provides helper methods and + * wrappers to ensure temp files are properly cleaned up. + */ +@Slf4j +public class TempFileUtil { + + /** + * A wrapper class for a temporary file that implements AutoCloseable. Can be used with + * try-with-resources for automatic cleanup. + */ + public static class TempFile implements AutoCloseable { + private final TempFileManager manager; + private final File file; + + public TempFile(TempFileManager manager, String suffix) throws IOException { + this.manager = manager; + this.file = manager.createTempFile(suffix); + } + + public File getFile() { + return file; + } + + public Path getPath() { + return file.toPath(); + } + + @Override + public void close() { + manager.deleteTempFile(file); + } + } + + /** + * A collection of temporary files that implements AutoCloseable. All files in the collection + * are cleaned up when close() is called. + */ + public static class TempFileCollection implements AutoCloseable { + private final TempFileManager manager; + private final List tempFiles = new ArrayList<>(); + + public TempFileCollection(TempFileManager manager) { + this.manager = manager; + } + + public File addTempFile(String suffix) throws IOException { + File file = manager.createTempFile(suffix); + tempFiles.add(file); + return file; + } + + public List getFiles() { + return new ArrayList<>(tempFiles); + } + + @Override + public void close() { + for (File file : tempFiles) { + manager.deleteTempFile(file); + } + } + } + + /** + * Execute a function with a temporary file, ensuring cleanup in a finally block. + * + * @param The return type of the function + * @param tempFileManager The temp file manager + * @param suffix File suffix (e.g., ".pdf") + * @param function The function to execute with the temp file + * @return The result of the function + * @throws IOException If an I/O error occurs + */ + public static R withTempFile( + TempFileManager tempFileManager, String suffix, Function function) + throws IOException { + File tempFile = tempFileManager.createTempFile(suffix); + try { + return function.apply(tempFile); + } finally { + tempFileManager.deleteTempFile(tempFile); + } + } + + /** + * Execute a function with multiple temporary files, ensuring cleanup in a finally block. + * + * @param The return type of the function + * @param tempFileManager The temp file manager + * @param count Number of temp files to create + * @param suffix File suffix (e.g., ".pdf") + * @param function The function to execute with the temp files + * @return The result of the function + * @throws IOException If an I/O error occurs + */ + public static R withMultipleTempFiles( + TempFileManager tempFileManager, + int count, + String suffix, + Function, R> function) + throws IOException { + List tempFiles = new ArrayList<>(count); + try { + for (int i = 0; i < count; i++) { + tempFiles.add(tempFileManager.createTempFile(suffix)); + } + return function.apply(tempFiles); + } finally { + for (File file : tempFiles) { + tempFileManager.deleteTempFile(file); + } + } + } + + /** + * Safely delete a list of temporary files, logging any errors. + * + * @param files The list of files to delete + */ + public static void safeDeleteFiles(List files) { + if (files == null) return; + + for (Path file : files) { + if (file == null) continue; + + try { + Files.deleteIfExists(file); + log.debug("Deleted temp file: {}", file); + } catch (IOException e) { + log.warn("Failed to delete temp file: {}", file, e); + } + } + } + + /** + * Register an already created temp file with the registry. Use this for files created outside + * of TempFileManager. + * + * @param tempFileManager The temp file manager + * @param file The file to register + * @return The registered file + */ + public static File registerExistingTempFile(TempFileManager tempFileManager, File file) { + if (tempFileManager != null && file != null && file.exists()) { + return tempFileManager.register(file); + } + return file; + } +} diff --git a/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java b/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java new file mode 100644 index 000000000..ebb323d93 --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java @@ -0,0 +1,197 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Tests for the TempFileCleanupService, focusing on its pattern-matching and cleanup logic. + */ +public class TempFileCleanupServiceTest { + + @TempDir + Path tempDir; + + @Mock + private TempFileRegistry registry; + + @Mock + private TempFileManager tempFileManager; + + @InjectMocks + private TempFileCleanupService cleanupService; + + private Path systemTempDir; + private Path customTempDir; + private Path libreOfficeTempDir; + + @BeforeEach + public void setup() throws IOException { + MockitoAnnotations.openMocks(this); + + // Create test directories + systemTempDir = tempDir.resolve("systemTemp"); + customTempDir = tempDir.resolve("customTemp"); + libreOfficeTempDir = tempDir.resolve("libreOfficeTemp"); + + Files.createDirectories(systemTempDir); + Files.createDirectories(customTempDir); + Files.createDirectories(libreOfficeTempDir); + + // Configure service with our test directories + ReflectionTestUtils.setField(cleanupService, "systemTempDir", systemTempDir.toString()); + ReflectionTestUtils.setField(cleanupService, "customTempDirectory", customTempDir.toString()); + ReflectionTestUtils.setField(cleanupService, "libreOfficeTempDir", libreOfficeTempDir.toString()); + ReflectionTestUtils.setField(cleanupService, "machineType", "Docker"); // Test in container mode + ReflectionTestUtils.setField(cleanupService, "performStartupCleanup", false); // Disable auto-startup cleanup + + when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L); // 1 hour + } + + @Test + public void testScheduledCleanup_RegisteredFiles() { + // Arrange + when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(5); // 5 files deleted + Set registeredDirs = new HashSet<>(); + registeredDirs.add(tempDir.resolve("registeredDir")); + when(registry.getTempDirectories()).thenReturn(registeredDirs); + + // Act + cleanupService.scheduledCleanup(); + + // Assert + verify(tempFileManager).cleanupOldTempFiles(anyLong()); + verify(registry, times(1)).getTempDirectories(); + } + + @Test + public void testCleanupTempFilePatterns() throws IOException { + // Arrange - Create various temp files + Path ourTempFile1 = Files.createFile(systemTempDir.resolve("output_123.pdf")); + Path ourTempFile2 = Files.createFile(systemTempDir.resolve("compressedPDF456.pdf")); + Path ourTempFile3 = Files.createFile(customTempDir.resolve("stirling-pdf-789.tmp")); + Path ourTempFile4 = Files.createFile(customTempDir.resolve("pdf-save-123-456.tmp")); + Path ourTempFile5 = Files.createFile(libreOfficeTempDir.resolve("input_file.pdf")); + + // System temp files that should be cleaned in container mode + Path sysTempFile1 = Files.createFile(systemTempDir.resolve("lu123abc.tmp")); + Path sysTempFile2 = Files.createFile(customTempDir.resolve("ocr_process123")); + Path sysTempFile3 = Files.createFile(customTempDir.resolve("tmp_upload.tmp")); + + // Files that should be preserved + Path jettyFile1 = Files.createFile(systemTempDir.resolve("jetty-123.tmp")); + Path jettyFile2 = Files.createFile(systemTempDir.resolve("something-with-jetty-inside.tmp")); + Path regularFile = Files.createFile(systemTempDir.resolve("important.txt")); + + // Create a nested directory with temp files + Path nestedDir = Files.createDirectories(systemTempDir.resolve("nested")); + Path nestedTempFile = Files.createFile(nestedDir.resolve("output_nested.pdf")); + + // Empty file (special case) + Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp")); + + // Configure mock registry to say these files aren't registered + when(registry.contains(any(File.class))).thenReturn(false); + + // Create a file older than threshold + Path oldFile = Files.createFile(systemTempDir.resolve("output_old.pdf")); + Files.setLastModifiedTime(oldFile, FileTime.from( Files.getLastModifiedTime(oldFile).toMillis() - 5000000, TimeUnit.MILLISECONDS)); + + // Act + invokeCleanupDirectory(systemTempDir, true, 0, 3600000); + invokeCleanupDirectory(customTempDir, true, 0, 3600000); + invokeCleanupDirectory(libreOfficeTempDir, true, 0, 3600000); + + // Assert - Our temp files and system temp files should be deleted (if old enough) + assertFalse(Files.exists(oldFile), "Old temp file should be deleted"); + assertTrue(Files.exists(ourTempFile1), "Recent temp file should be preserved"); + assertTrue(Files.exists(sysTempFile1), "Recent system temp file should be preserved"); + + // Jetty files and regular files should never be deleted + assertTrue(Files.exists(jettyFile1), "Jetty file should be preserved"); + assertTrue(Files.exists(jettyFile2), "File with jetty in name should be preserved"); + assertTrue(Files.exists(regularFile), "Regular file should be preserved"); + } + + @Test + public void testEmptyFileHandling() throws IOException { + // Arrange - Create an empty file + Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp")); + // Make it "old enough" to be deleted (>5 minutes) + Files.setLastModifiedTime(emptyFile, FileTime.from( Files.getLastModifiedTime(emptyFile).toMillis() - 6 * 60 * 1000, TimeUnit.MILLISECONDS)); + + + // Configure mock registry to say this file isn't registered + when(registry.contains(any(File.class))).thenReturn(false); + + // Act + invokeCleanupDirectory(systemTempDir, true, 0, 3600000); + + // Assert + assertFalse(Files.exists(emptyFile), "Empty file older than 5 minutes should be deleted"); + } + + @Test + public void testRecursiveDirectoryCleaning() throws IOException { + // Arrange - Create a nested directory structure with temp files + Path dir1 = Files.createDirectories(systemTempDir.resolve("dir1")); + Path dir2 = Files.createDirectories(dir1.resolve("dir2")); + Path dir3 = Files.createDirectories(dir2.resolve("dir3")); + + Path tempFile1 = Files.createFile(dir1.resolve("output_1.pdf")); + Path tempFile2 = Files.createFile(dir2.resolve("output_2.pdf")); + Path tempFile3 = Files.createFile(dir3.resolve("output_3.pdf")); + + // Make the deepest file old enough to be deleted + Files.setLastModifiedTime(tempFile3, FileTime.from( Files.getLastModifiedTime(tempFile3).toMillis() - 5000000, TimeUnit.MILLISECONDS)); + + // Configure mock registry to say these files aren't registered + when(registry.contains(any(File.class))).thenReturn(false); + + // Act + invokeCleanupDirectory(systemTempDir, true, 0, 3600000); + + // Assert + assertTrue(Files.exists(tempFile1), "Recent temp file should be preserved"); + assertTrue(Files.exists(tempFile2), "Recent temp file should be preserved"); + assertFalse(Files.exists(tempFile3), "Old temp file in nested directory should be deleted"); + } + + /** + * Helper method to invoke the private cleanupDirectory method using reflection + */ + private int invokeCleanupDirectory(Path directory, boolean containerMode, int depth, long maxAgeMillis) + throws IOException { + try { + var method = TempFileCleanupService.class.getDeclaredMethod( + "cleanupDirectory", Path.class, boolean.class, int.class, long.class); + method.setAccessible(true); + return (int) method.invoke(cleanupService, directory, containerMode, depth, maxAgeMillis); + } catch (Exception e) { + throw new RuntimeException("Error invoking cleanupDirectory", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/stirling/software/SPDF/UnoconvServer.java b/src/main/java/stirling/software/SPDF/UnoconvServer.java new file mode 100644 index 000000000..a010720ed --- /dev/null +++ b/src/main/java/stirling/software/SPDF/UnoconvServer.java @@ -0,0 +1,152 @@ +package stirling.software.SPDF; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.file.Path; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import io.github.pixee.security.SystemCommand; +import lombok.extern.slf4j.Slf4j; +import stirling.software.common.service.TempFileCleanupService; +import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.TempFileManager; + +@Slf4j +@Component +public class UnoconvServer { + + private static final long ACTIVITY_TIMEOUT = 20L * 60 * 1000; // 20 minutes + + private static UnoconvServer INSTANCE; + private static final int LISTENER_PORT = 2002; + private ExecutorService executorService; + private long lastActivityTime; + private Process process; + private Path tempDir; + + private final TempFileManager tempFileManager; + private final TempFileCleanupService cleanupService; + + @Autowired + public UnoconvServer(TempFileManager tempFileManager, TempFileCleanupService cleanupService) { + this.tempFileManager = tempFileManager; + this.cleanupService = cleanupService; + INSTANCE = this; + } + + public static UnoconvServer getInstance() { + // If INSTANCE is not set through Spring, try to get it from the ApplicationContext + if (INSTANCE == null) { + INSTANCE = ApplicationContextProvider.getBean(UnoconvServer.class); + + if (INSTANCE == null) { + log.warn("Creating UnoconvServer without Spring context"); + INSTANCE = new UnoconvServer(null, null); + } + } + return INSTANCE; + } + + private boolean isServerRunning() { + log.info("Checking if unoconv server is running"); + try (Socket socket = new Socket()) { + socket.connect( + new InetSocketAddress("localhost", LISTENER_PORT), + 1000); // Timeout after 1 second + return true; + } catch (Exception e) { + return false; + } + } + + public void start() throws IOException { + // Check if the server is already running + if (process != null && process.isAlive()) { + return; + } + + // Create and register a temp directory for unoconv if TempFileManager is available + if (tempFileManager != null) { + tempDir = tempFileManager.registerLibreOfficeTempDir(); + log.info("Created unoconv temp directory: {}", tempDir); + } + + String command; + if (tempDir != null) { + command = "unoconv-server --user-profile " + tempDir.toString(); + } else { + command = "unoconv-server"; + } + + // Start the server process + process = SystemCommand.runCommand(Runtime.getRuntime(), command); + lastActivityTime = System.currentTimeMillis(); + + // Start a background thread to monitor the activity timeout + executorService = Executors.newSingleThreadExecutor(); + executorService.submit( + () -> { + while (true) { + long idleTime = System.currentTimeMillis() - lastActivityTime; + if (idleTime >= ACTIVITY_TIMEOUT) { + process.destroy(); + + if (cleanupService != null) { + cleanupService.cleanupLibreOfficeTempFiles(); + } + break; + } + try { + Thread.sleep(5000); // Check for inactivity every 5 seconds + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }); + + // Wait for the server to start up + long startTime = System.currentTimeMillis(); + long timeout = 30000; // Timeout after 30 seconds + while (System.currentTimeMillis() - startTime < timeout) { + if (isServerRunning()) { + lastActivityTime = System.currentTimeMillis(); + return; + } + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Error waiting for server to start", e); + } // Check every 1 second + } + } + + public synchronized void stop() { + // Stop the activity timeout monitor thread + if (executorService != null) { + executorService.shutdownNow(); + } + + // Stop the server process + if (process != null && process.isAlive()) { + process.destroy(); + } + + if (cleanupService != null) { + cleanupService.cleanupLibreOfficeTempFiles(); + } + } + + /** + * Notify that unoconv is being used, to reset the inactivity timer. + */ + public void notifyActivity() { + lastActivityTime = System.currentTimeMillis(); + } +} \ No newline at end of file diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java index 85340a163..4a13e9ccc 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java @@ -1,8 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -23,15 +22,22 @@ import stirling.software.common.model.api.PDFFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileUtil; import stirling.software.common.util.WebResponseUtils; @RestController @RequestMapping("/api/v1/misc") @Tag(name = "Misc", description = "Miscellaneous APIs") -@RequiredArgsConstructor public class RepairController { private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; + + public RepairController(CustomPDFDocumentFactory pdfDocumentFactory, TempFileManager tempFileManager) { + this.pdfDocumentFactory = pdfDocumentFactory; + this.tempFileManager = tempFileManager; + } @PostMapping(consumes = "multipart/form-data", value = "/repair") @Operation( @@ -43,25 +49,25 @@ public class RepairController { public ResponseEntity repairPdf(@ModelAttribute PDFFile file) throws IOException, InterruptedException { MultipartFile inputFile = file.getFileInput(); - // Save the uploaded file to a temporary location - Path tempInputFile = Files.createTempFile("input_", ".pdf"); - byte[] pdfBytes = null; - inputFile.transferTo(tempInputFile.toFile()); - try { + + // Use TempFileUtil.TempFile with try-with-resources for automatic cleanup + try (TempFileUtil.TempFile tempFile = new TempFileUtil.TempFile(tempFileManager, ".pdf")) { + // Save the uploaded file to the temporary location + inputFile.transferTo(tempFile.getFile()); List command = new ArrayList<>(); command.add("qpdf"); command.add("--replace-input"); // Automatically fixes problems it can command.add("--qdf"); // Linearizes and normalizes PDF structure command.add("--object-streams=disable"); // Can help with some corruptions - command.add(tempInputFile.toString()); + command.add(tempFile.getFile().getAbsolutePath()); ProcessExecutorResult returnCode = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF) .runCommandWithOutputHandling(command); // Read the optimized PDF file - pdfBytes = pdfDocumentFactory.loadToBytes(tempInputFile.toFile()); + byte[] pdfBytes = pdfDocumentFactory.loadToBytes(tempFile.getFile()); // Return the optimized PDF as a response String outputFilename = @@ -69,9 +75,6 @@ public class RepairController { .replaceFirst("[.][^.]+$", "") + "_repaired.pdf"; return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename); - } finally { - // Clean up the temporary files - Files.deleteIfExists(tempInputFile); } } } diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index 9c0ad2909..e93c3cd53 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -40,6 +40,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.model.api.misc.AddStampRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileUtil; import stirling.software.common.util.WebResponseUtils; @RestController @@ -49,6 +51,7 @@ import stirling.software.common.util.WebResponseUtils; public class StampController { private final CustomPDFDocumentFactory pdfDocumentFactory; + private final stirling.software.common.util.TempFileManager tempFileManager; @PostMapping(consumes = "multipart/form-data", value = "/add-stamp") @Operation( @@ -188,14 +191,14 @@ public class StampController { if (!"".equals(resourceDir)) { ClassPathResource classPathResource = new ClassPathResource(resourceDir); String fileExtension = resourceDir.substring(resourceDir.lastIndexOf(".")); - File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile(); - try (InputStream is = classPathResource.getInputStream(); - FileOutputStream os = new FileOutputStream(tempFile)) { - IOUtils.copy(is, os); - font = PDType0Font.load(document, tempFile); - } finally { - if (tempFile != null) { - Files.deleteIfExists(tempFile.toPath()); + + // Use TempFileUtil.TempFile with try-with-resources for automatic cleanup + try (TempFileUtil.TempFile tempFileWrapper = new TempFileUtil.TempFile(tempFileManager, fileExtension)) { + File tempFile = tempFileWrapper.getFile(); + try (InputStream is = classPathResource.getInputStream(); + FileOutputStream os = new FileOutputStream(tempFile)) { + IOUtils.copy(is, os); + font = PDType0Font.load(document, tempFile); } } } diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index 0226776c5..466a6183d 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -120,6 +120,14 @@ system: weasyprint: '' # Defaults to /opt/venv/bin/weasyprint unoconvert: '' # Defaults to /opt/venv/bin/unoconvert fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB". + tempfiles: + prefix: stirling-pdf- # Prefix for all temporary files created by the application + directory: '' # If empty, defaults to java.io.tmpdir/stirling-pdf (AppData\Local\Temp\stirling-pdf on Windows) + libreoffice-dir: '' # If empty, defaults to java.io.tmpdir/stirling-pdf-libreoffice + max-age-hours: 4 # How long to keep temporary files + cleanup-interval-minutes: 30 # How often to run the cleanup process + startup-cleanup: true # Whether to clean temporary files on application startup + system-temp-dir: '' # If empty, defaults to java.io.tmpdir, e.g., /tmp on Linux or AppData\Local\Temp on Windows ui: appName: '' # application's visible name diff --git a/testing/allEndpointsRemovedSettings.yml b/testing/allEndpointsRemovedSettings.yml index 3290d6fef..556e41615 100644 --- a/testing/allEndpointsRemovedSettings.yml +++ b/testing/allEndpointsRemovedSettings.yml @@ -6,7 +6,6 @@ # ___) || | | || _ <| |___ | || |\ | |_| |_____| __/| |_| | _| # # |____/ |_| |___|_| \_\_____|___|_| \_|\____| |_| |____/|_| # # # -# Custom setting.yml file with all endpoints disabled to only be used for testing purposes # # Do not comment out any entry, it will be removed on next startup # # If you want to override with environment parameter follow parameter naming SECURITY_INITIALLOGIN_USERNAME # ############################################################################################################# @@ -65,12 +64,13 @@ premium: key: 00000000-0000-0000-0000-000000000000 enabled: false # Enable license key checks for pro/enterprise features proFeatures: + database: true # Enable database features SSOAutoLogin: false CustomMetadata: - autoUpdateMetadata: false # set to 'true' to automatically update metadata with below values - author: username # supports text such as 'John Doe' or types such as username to autopopulate with user's username - creator: Stirling-PDF # supports text such as 'Company-PDF' - producer: Stirling-PDF # supports text such as 'Company-PDF' + autoUpdateMetadata: false + author: username + creator: Stirling-PDF + producer: Stirling-PDF googleDrive: enabled: false clientId: '' @@ -120,6 +120,15 @@ system: weasyprint: '' # Defaults to /opt/venv/bin/weasyprint unoconvert: '' # Defaults to /opt/venv/bin/unoconvert fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB". + tempfiles: + prefix: stirling-pdf- # Prefix for all temporary files created by the application + directory: '/tmp/stirling-pdf' # For testing, explicitly set the temp directory + libreoffice-dir: '/tmp/stirling-pdf/libreoffice' # For testing, explicitly set the LibreOffice directory + max-age-hours: 4 # How long to keep temporary files + cleanup-interval-minutes: 30 # How often to run the cleanup process + startup-cleanup: true # Whether to clean temporary files on application startup + system-temp-dir: '/tmp' # For testing, explicitly set the system temp directory +# Always using the registry for consistent temp file tracking ui: appName: '' # application's visible name @@ -127,7 +136,7 @@ ui: appNameNavbar: '' # name displayed on the navigation bar languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled. -endpoints: # All the possible endpoints are disabled +endpoints: toRemove: [crop, merge-pdfs, multi-page-layout, overlay-pdfs, pdf-to-single-page, rearrange-pages, remove-image-pdf, remove-pages, rotate-pdf, scale-pages, split-by-size-or-count, split-pages, split-pdf-by-chapters, split-pdf-by-sections, add-password, add-watermark, auto-redact, cert-sign, get-info-on-pdf, redact, remove-cert-sign, remove-password, sanitize-pdf, validate-signature, file-to-pdf, html-to-pdf, img-to-pdf, markdown-to-pdf, pdf-to-csv, pdf-to-html, pdf-to-img, pdf-to-markdown, pdf-to-pdfa, pdf-to-presentation, pdf-to-text, pdf-to-word, pdf-to-xml, url-to-pdf, add-image, add-page-numbers, add-stamp, auto-rename, auto-split-pdf, compress-pdf, decompress-pdf, extract-image-scans, extract-images, flatten, ocr-pdf, remove-blanks, repair, replace-invert-pdf, show-javascript, update-metadata, filter-contains-image, filter-contains-text, filter-file-size, filter-page-count, filter-page-rotation, filter-page-size] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages']) groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice']) @@ -138,7 +147,7 @@ metrics: AutomaticallyGenerated: key: cbb81c0f-50b1-450c-a2b5-89ae527776eb UUID: 10dd4fba-01fa-4717-9b78-3dc4f54e398a - appVersion: 0.44.3 + appVersion: 0.46.2 processExecutor: sessionLimit: # Process executor instances limits diff --git a/testing/test_temp_files.sh b/testing/test_temp_files.sh new file mode 100644 index 000000000..a5adb473a --- /dev/null +++ b/testing/test_temp_files.sh @@ -0,0 +1,256 @@ +#!/bin/bash + +# This script tests the temporary file cleanup functionality in Stirling-PDF. +# It creates various temporary files inside a Docker container and verifies +# that they are properly cleaned up. + +# Find project root by locating build.gradle +find_root() { + local dir="$PWD" + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/build.gradle" ]]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + echo "Error: build.gradle not found" >&2 + exit 1 +} + +PROJECT_ROOT=$(find_root) +CONTAINER_NAME="stirling-pdf-temp-file-test" +COMPOSE_FILE="$PROJECT_ROOT/testing/testdriver/temp_file_test.yml" +SNAPSHOT_DIR="$PROJECT_ROOT/testing/file_snapshots" +SUCCESS=true + +# Create directories +mkdir -p "$SNAPSHOT_DIR" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +# Function to check the health of the service +check_health() { + local service_name=$1 + local end=$((SECONDS+60)) + + echo -n "Waiting for $service_name to become healthy..." + until [ "$(docker inspect --format='{{json .State.Health.Status}}' "$service_name")" == '"healthy"' ] || [ $SECONDS -ge $end ]; do + sleep 3 + echo -n "." + if [ $SECONDS -ge $end ]; then + echo -e "\n$service_name health check timed out after 60 seconds." + echo "Printing logs for $service_name:" + docker logs "$service_name" + return 1 + fi + done + echo -e "\n$service_name is healthy!" + return 0 +} + +# Function to capture all files in /tmp and its subdirectories +capture_temp_files() { + local output_file=$1 + + echo "Capturing temporary files list..." + docker exec $CONTAINER_NAME sh -c "find /tmp -type f | sort" > "$output_file" + + # Count files + local count=$(wc -l < "$output_file") + echo "Found $count files in /tmp" +} + +# Function to create test temporary files in the container +create_test_files() { + echo "Creating test temporary files..." + + # Create files with various patterns in different directories + docker exec $CONTAINER_NAME sh -c ' + # Create files in /tmp + touch /tmp/output_123.pdf + touch /tmp/compressedPDF456.pdf + touch /tmp/stirling-pdf-789.tmp + touch /tmp/pdf-save-123-456.tmp + touch /tmp/pdf-stream-789-012.tmp + touch /tmp/PDFBox123.tmp + touch /tmp/input_test.pdf + touch /tmp/overlay-test.pdf + + # Create system-like temp files + touch /tmp/lu123abc.tmp + mkdir -p /tmp/ocr_process123 + touch /tmp/tmp_upload.tmp + touch /tmp/OSL_PIPE_1000_stirling + touch /tmp/random.tmp + + # Create Jetty files (should be preserved) + touch /tmp/jetty-123.tmp + touch /tmp/something-with-jetty-inside.tmp + + # Create nested directories with temp files + mkdir -p /tmp/stirling-pdf + touch /tmp/stirling-pdf/nested_output.pdf + + mkdir -p /tmp/webp_outputXYZ + touch /tmp/webp_outputXYZ/output_nested.pdf + + # Create an empty file (special case) + touch /tmp/empty.tmp + + # Create normal files (should be preserved) + touch /tmp/important.txt + + echo "Test files created successfully" + ' +} + +# Function to trigger cleanup by modifying settings +trigger_cleanup() { + echo "Triggering temporary file cleanup..." + + # Set aggressive cleanup settings and restart + docker exec $CONTAINER_NAME sh -c ' + echo "stirling.tempfiles.max-age-hours=0.001" >> /app/application.properties + echo "stirling.tempfiles.cleanup-interval-minutes=0.1" >> /app/application.properties + touch /app/restart-trigger + ' + + # Wait for cleanup to run + echo "Waiting for cleanup to run (30 seconds)..." + sleep 30 +} + +# Function to verify cleanup results +verify_cleanup() { + local before_file=$1 + local after_file=$2 + local status=true + + echo "Verifying cleanup results..." + + # Files that should be cleaned + local should_be_cleaned=( + "/tmp/output_123.pdf" + "/tmp/compressedPDF456.pdf" + "/tmp/stirling-pdf-789.tmp" + "/tmp/pdf-save-123-456.tmp" + "/tmp/pdf-stream-789-012.tmp" + "/tmp/PDFBox123.tmp" + "/tmp/input_test.pdf" + "/tmp/overlay-test.pdf" + "/tmp/lu123abc.tmp" + "/tmp/ocr_process123" + "/tmp/tmp_upload.tmp" + "/tmp/OSL_PIPE_1000_stirling" + "/tmp/random.tmp" + "/tmp/empty.tmp" + "/tmp/stirling-pdf/nested_output.pdf" + "/tmp/webp_outputXYZ/output_nested.pdf" + ) + + # Files that should be preserved + local should_be_preserved=( + "/tmp/jetty-123.tmp" + "/tmp/something-with-jetty-inside.tmp" + "/tmp/important.txt" + ) + + # Check files that should be cleaned + for file in "${should_be_cleaned[@]}"; do + if grep -q "$file" "$after_file"; then + echo -e "${RED}FAIL: $file was not cleaned up${NC}" + status=false + else + echo -e "${GREEN}PASS: $file was properly cleaned up${NC}" + fi + done + + # Check files that should be preserved + for file in "${should_be_preserved[@]}"; do + if grep -q "$file" "$after_file"; then + echo -e "${GREEN}PASS: $file was properly preserved${NC}" + else + echo -e "${RED}FAIL: $file was incorrectly cleaned up${NC}" + status=false + fi + done + + return $status +} + +# Main function +main() { + echo -e "${YELLOW}Starting temporary file cleanup test...${NC}" + + # Create special test compose file + cat > "$COMPOSE_FILE" << EOL +version: '3.8' +services: + stirling-pdf: + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-ultra-lite + container_name: $CONTAINER_NAME + environment: + - DOCKER_ENABLE_SECURITY=false + - APP_FILESYSTEM_DIRECTORY_BASE=/app/customFiles + - STIRLING_MACHINE_TYPE=Docker + - STIRLING_TEMPFILES_STARTUP_CLEANUP=false + - STIRLING_TEMPFILES_CLEANUP_INTERVAL_MINUTES=5 + - JAVA_OPTS=-Xmx500m + ports: + - 8080:8080 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped +EOL + + # Start the container + docker-compose -f "$COMPOSE_FILE" up -d + + # Wait for container to be healthy + if ! check_health "$CONTAINER_NAME"; then + echo -e "${RED}Failed to start test container${NC}" + docker-compose -f "$COMPOSE_FILE" down + exit 1 + fi + + # Create temporary files + create_test_files + + # Capture initial state + BEFORE_FILE="$SNAPSHOT_DIR/temp_files_before.txt" + capture_temp_files "$BEFORE_FILE" + + # Trigger cleanup + trigger_cleanup + + # Capture final state + AFTER_FILE="$SNAPSHOT_DIR/temp_files_after.txt" + capture_temp_files "$AFTER_FILE" + + # Verify cleanup results + if verify_cleanup "$BEFORE_FILE" "$AFTER_FILE"; then + echo -e "${GREEN}Temporary file cleanup test PASSED${NC}" + else + echo -e "${RED}Temporary file cleanup test FAILED${NC}" + SUCCESS=false + fi + + # Clean up + docker-compose -f "$COMPOSE_FILE" down + + if $SUCCESS; then + exit 0 + else + exit 1 + fi +} + +main \ No newline at end of file diff --git a/testing/testdriver/temp_file_test.yml b/testing/testdriver/temp_file_test.yml new file mode 100644 index 000000000..6fef117f6 --- /dev/null +++ b/testing/testdriver/temp_file_test.yml @@ -0,0 +1,20 @@ +version: '3.8' +services: + stirling-pdf: + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-ultra-lite + container_name: stirling-pdf-temp-file-test + environment: + - DOCKER_ENABLE_SECURITY=false + - APP_FILESYSTEM_DIRECTORY_BASE=/app/customFiles + - STIRLING_MACHINE_TYPE=Docker + - STIRLING_TEMPFILES_STARTUP_CLEANUP=false + - STIRLING_TEMPFILES_CLEANUP_INTERVAL_MINUTES=5 + - JAVA_OPTS=-Xmx500m + ports: + - 8080:8080 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped