Merge branch 'main' into enforce_exclusion

This commit is contained in:
Ludy 2025-07-01 11:17:02 +02:00 committed by GitHub
commit 719d168682
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 4149 additions and 1262 deletions

View File

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(chmod:*)",
"Bash(mkdir:*)",
"Bash(./gradlew:*)",
"Bash(grep:*)",
"Bash(cat:*)"
],
"deny": []
}
}

View File

@ -83,6 +83,7 @@ jobs:
proprietary/build/reports/tests/
proprietary/build/test-results/
proprietary/build/reports/problems/
build/reports/problems/
retention-days: 3
if-no-files-found: warn
@ -152,7 +153,7 @@ jobs:
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python

View File

@ -33,7 +33,11 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# JDK for app
@ -78,17 +82,17 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]

View File

@ -27,7 +27,11 @@ RUN apt-get update && apt-get install -y \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Setze die Environment Variable für setuptools
ENV SETUPTOOLS_USE_DISTUTILS=local
ENV SETUPTOOLS_USE_DISTUTILS=local \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Installation der benötigten Python-Pakete
RUN python3 -m venv --system-site-packages /opt/venv \
@ -40,8 +44,9 @@ ENV PATH="/opt/venv/bin:$PATH"
COPY . /workspace
RUN adduser --disabled-password --gecos '' devuser \
&& chown -R devuser:devuser /home/devuser /workspace
RUN mkdir -p /tmp/stirling-pdf \
&& adduser --disabled-password --gecos '' devuser \
&& chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf
RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
&& chmod 0440 /etc/sudoers.d/devuser

View File

@ -46,7 +46,11 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# JDK for app
@ -92,16 +96,16 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]

View File

@ -11,7 +11,11 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
JAVA_CUSTOM_OPTS="" \
PUID=1000 \
PGID=1000 \
UMASK=022
UMASK=022 \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Copy necessary files
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
@ -35,10 +39,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
su-exec \
openjdk21-jre && \
# User permissions
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto && \
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Set environment variables
@ -48,4 +52,4 @@ EXPOSE 8080/tcp
# Run the application
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD ["java", "-Dfile.encoding=UTF-8", "-jar", "/app.jar"]
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]

View File

@ -8,22 +8,22 @@ import org.springframework.web.bind.annotation.RequestMethod;
/**
* Shortcut for a POST endpoint that is executed through the Stirling "autojob" framework.
* <p>
* Behaviour notes:
* <ul>
* <li>The endpoint is registered with {@code POST} and, by default, consumes
* {@code multipart/form-data} unless you override {@link #consumes()}.</li>
* <li>When the client supplies {@code ?async=true} the call is handed to
* {@link stirling.software.common.service.JobExecutorService JobExecutorService} where it may
* be queued, retried, tracked and subject to timeouts. For synchronous (default)
* invocations these advanced options are ignored.</li>
* <li>Progress information (see {@link #trackProgress()}) is stored in
* {@link stirling.software.common.service.TaskManager TaskManager} and can be
* polled via <code>GET /api/v1/general/job/{id}</code>.</li>
* </ul>
* </p>
*
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution.</p>
* <p>Behaviour notes:
*
* <ul>
* <li>The endpoint is registered with {@code POST} and, by default, consumes {@code
* multipart/form-data} unless you override {@link #consumes()}.
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
* queued, retried, tracked and subject to timeouts. For synchronous (default) invocations
* these advanced options are ignored.
* <li>Progress information (see {@link #trackProgress()}) is stored in {@link
* stirling.software.common.service.TaskManager TaskManager} and can be polled via <code>
* GET /api/v1/general/job/{id}</code>.
* </ul>
*
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ -31,42 +31,42 @@ import org.springframework.web.bind.annotation.RequestMethod;
@RequestMapping(method = RequestMethod.POST)
public @interface AutoJobPostMapping {
/**
* Alias for {@link RequestMapping#value} the path mapping of the endpoint.
*/
/** Alias for {@link RequestMapping#value} the path mapping of the endpoint. */
@AliasFor(annotation = RequestMapping.class, attribute = "value")
String[] value() default {};
/**
* MIME types this endpoint accepts. Defaults to {@code multipart/form-data}.
*/
/** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
String[] consumes() default {"multipart/form-data"};
/**
* Maximum execution time in milliseconds before the job is aborted.
* A negative value means "use the application default".
* <p>Only honoured when {@code async=true}.</p>
* Maximum execution time in milliseconds before the job is aborted. A negative value means "use
* the application default".
*
* <p>Only honoured when {@code async=true}.
*/
long timeout() default -1;
/**
* Total number of attempts (initial + retries). Must be at least&nbsp;1.
* Retries are executed with exponential backoff.
* <p>Only honoured when {@code async=true}.</p>
* Total number of attempts (initial + retries). Must be at least&nbsp;1. Retries are executed
* with exponential backoff.
*
* <p>Only honoured when {@code async=true}.
*/
int retryCount() default 1;
/**
* Record percentage / note updates so they can be retrieved via the REST status endpoint.
* <p>Only honoured when {@code async=true}.</p>
*
* <p>Only honoured when {@code async=true}.
*/
boolean trackProgress() default true;
/**
* If {@code true} the job may be placed in a queue instead of being rejected when resources
* are scarce.
* <p>Only honoured when {@code async=true}.</p>
* If {@code true} the job may be placed in a queue instead of being rejected when resources are
* scarce.
*
* <p>Only honoured when {@code async=true}.
*/
boolean queueable() default false;

View File

@ -0,0 +1,59 @@
package stirling.software.common.config;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileRegistry;
/**
* Configuration for the temporary file management system. Sets up the necessary beans and
* configures system properties.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class TempFileConfiguration {
private final ApplicationProperties applicationProperties;
/**
* Create the TempFileRegistry bean.
*
* @return A new TempFileRegistry instance
*/
@Bean
public TempFileRegistry tempFileRegistry() {
return new TempFileRegistry();
}
@PostConstruct
public void initTempFileConfig() {
try {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
String customTempDirectory = tempFiles.getBaseTmpDir();
// Create the temp directory if it doesn't exist
Path tempDir = Path.of(customTempDirectory);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
log.info("Created temporary directory: {}", tempDir);
}
log.debug("Temporary file configuration initialized");
log.debug("Using temp directory: {}", customTempDirectory);
log.debug("Temp file prefix: {}", tempFiles.getPrefix());
} catch (Exception e) {
log.error("Failed to initialize temporary file configuration", e);
}
}
}

View File

@ -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<Path> 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<Path> 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);
}
}
}

View File

@ -292,6 +292,7 @@ public class ApplicationProperties {
private Boolean enableUrlToPDF;
private CustomPaths customPaths = new CustomPaths();
private String fileUploadLimit;
private TempFileManagement tempFileManagement = new TempFileManagement();
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@ -317,6 +318,30 @@ public class ApplicationProperties {
}
}
@Data
public static class TempFileManagement {
private String baseTmpDir = "";
private String libreofficeDir = "";
private String systemTempDir = "";
private String prefix = "stirling-pdf-";
private long maxAgeHours = 24;
private long cleanupIntervalMinutes = 30;
private boolean startupCleanup = true;
private boolean cleanupSystemTemp = false;
public String getBaseTmpDir() {
return baseTmpDir != null && !baseTmpDir.isEmpty()
? baseTmpDir
: java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf";
}
public String getLibreofficeDir() {
return libreofficeDir != null && !libreofficeDir.isEmpty()
? libreofficeDir
: getBaseTmpDir() + "/libreoffice";
}
}
@Data
public static class Datasource {
private boolean enableCustomDatabase;

View File

@ -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;
}

View File

@ -173,7 +173,9 @@ public class ResourceMonitor {
log.info("System resource status changed from {} to {}", oldStatus, newStatus);
log.info(
"Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB",
String.format("%.1f", cpuUsage * 100), String.format("%.1f", memoryUsage * 100), freeMemory / (1024 * 1024));
String.format("%.1f", cpuUsage * 100),
String.format("%.1f", memoryUsage * 100),
freeMemory / (1024 * 1024));
}
} catch (Exception e) {
log.error("Error updating resource metrics: {}", e.getMessage(), e);

View File

@ -0,0 +1,447 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
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
@RequiredArgsConstructor
public class TempFileCleanupService {
private final TempFileRegistry registry;
private final TempFileManager tempFileManager;
private final ApplicationProperties applicationProperties;
@Autowired
@Qualifier("machineType")
private String machineType;
// Maximum recursion depth for directory traversal
private static final int MAX_RECURSION_DEPTH = 5;
// File patterns that identify our temp files
private static final Predicate<String> IS_OUR_TEMP_FILE =
fileName ->
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-");
// File patterns that identify common system temp files
private static final Predicate<String> IS_SYSTEM_TEMP_FILE =
fileName ->
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"));
// File patterns that should be excluded from cleanup
private static final Predicate<String> SHOULD_SKIP =
fileName ->
fileName.contains("jetty")
|| fileName.startsWith("jetty-")
|| fileName.equals("proc")
|| fileName.equals("sys")
|| fileName.equals("dev")
|| fileName.equals("hsperfdata_stirlingpdfuser")
|| fileName.startsWith("hsperfdata_")
|| fileName.equals(".pdfbox.cache");
@PostConstruct
public void init() {
// Create necessary directories
ensureDirectoriesExist();
// Perform startup cleanup if enabled
if (applicationProperties.getSystem().getTempFileManagement().isStartupCleanup()) {
runStartupCleanup();
}
}
/** Ensure that all required temp directories exist */
private void ensureDirectoriesExist() {
try {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
// Create the main temp directory
String customTempDirectory = tempFiles.getBaseTmpDir();
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
String libreOfficeTempDir = tempFiles.getLibreofficeDir();
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 =
"#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}",
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);
}
}
// Clean up PDFBox cache file
cleanupPDFBoxCache();
// Clean up unregistered temp files based on our cleanup strategy
boolean containerMode = isContainerMode();
int unregisteredDeletedCount = cleanupUnregisteredFiles(containerMode, true, maxAgeMillis);
log.info(
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
registeredDeletedCount,
unregisteredDeletedCount,
directoriesDeletedCount);
}
/**
* 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");
boolean containerMode = isContainerMode();
log.info(
"Running in {} mode, using {} cleanup strategy",
machineType,
containerMode ? "aggressive" : "conservative");
// For startup cleanup, we use a longer timeout for non-container environments
long maxAgeMillis = containerMode ? 0 : 24 * 60 * 60 * 1000; // 0 or 24 hours
int totalDeletedCount = cleanupUnregisteredFiles(containerMode, false, maxAgeMillis);
log.info(
"Startup cleanup complete. Deleted {} temporary files/directories",
totalDeletedCount);
}
/**
* Clean up unregistered temporary files across all configured temp directories.
*
* @param containerMode Whether we're in container mode (more aggressive cleanup)
* @param isScheduled Whether this is a scheduled cleanup or startup cleanup
* @param maxAgeMillis Maximum age of files to clean in milliseconds
* @return Number of files deleted
*/
private int cleanupUnregisteredFiles(
boolean containerMode, boolean isScheduled, long maxAgeMillis) {
AtomicInteger totalDeletedCount = new AtomicInteger(0);
try {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path[] dirsToScan;
if (tempFiles.isCleanupSystemTemp()
&& tempFiles.getSystemTempDir() != null
&& !tempFiles.getSystemTempDir().isEmpty()) {
Path systemTempPath = getSystemTempPath();
dirsToScan =
new Path[] {
systemTempPath,
Path.of(tempFiles.getBaseTmpDir()),
Path.of(tempFiles.getLibreofficeDir())
};
} else {
dirsToScan =
new Path[] {
Path.of(tempFiles.getBaseTmpDir()),
Path.of(tempFiles.getLibreofficeDir())
};
}
// Process each directory
Arrays.stream(dirsToScan)
.filter(Files::exists)
.forEach(
tempDir -> {
try {
String phase = isScheduled ? "scheduled" : "startup";
log.info(
"Scanning directory for {} cleanup: {}",
phase,
tempDir);
AtomicInteger dirDeletedCount = new AtomicInteger(0);
cleanupDirectoryStreaming(
tempDir,
containerMode,
0,
maxAgeMillis,
isScheduled,
path -> {
dirDeletedCount.incrementAndGet();
if (log.isDebugEnabled()) {
log.debug(
"Deleted temp file during {} cleanup: {}",
phase,
path);
}
});
int count = dirDeletedCount.get();
totalDeletedCount.addAndGet(count);
if (count > 0) {
log.info(
"Cleaned up {} files/directories in {}",
count,
tempDir);
}
} catch (IOException e) {
log.error("Error during cleanup of directory: {}", tempDir, e);
}
});
} catch (Exception e) {
log.error("Error during cleanup of unregistered files", e);
}
return totalDeletedCount.get();
}
/** Get the system temp directory path based on configuration or system property. */
private Path getSystemTempPath() {
String systemTempDir =
applicationProperties.getSystem().getTempFileManagement().getSystemTempDir();
if (systemTempDir != null && !systemTempDir.isEmpty()) {
return Path.of(systemTempDir);
} else {
return Path.of(System.getProperty("java.io.tmpdir"));
}
}
/** Determine if we're running in a container environment. */
private boolean isContainerMode() {
return "Docker".equals(machineType) || "Kubernetes".equals(machineType);
}
/**
* Recursively clean up a directory using a streaming approach to reduce memory usage.
*
* @param directory The directory to clean
* @param containerMode Whether we're in container mode (more aggressive cleanup)
* @param depth Current recursion depth
* @param maxAgeMillis Maximum age of files to delete
* @param isScheduled Whether this is a scheduled cleanup (vs startup)
* @param onDeleteCallback Callback function when a file is deleted
* @throws IOException If an I/O error occurs
*/
private void cleanupDirectoryStreaming(
Path directory,
boolean containerMode,
int depth,
long maxAgeMillis,
boolean isScheduled,
Consumer<Path> onDeleteCallback)
throws IOException {
if (depth > MAX_RECURSION_DEPTH) {
log.debug("Maximum directory recursion depth reached for: {}", directory);
return;
}
java.util.List<Path> subdirectories = new java.util.ArrayList<>();
try (Stream<Path> pathStream = Files.list(directory)) {
pathStream.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
if (SHOULD_SKIP.test(fileName)) {
return;
}
if (Files.isDirectory(path)) {
subdirectories.add(path);
return;
}
if (registry.contains(path.toFile())) {
return;
}
if (shouldDeleteFile(path, fileName, containerMode, maxAgeMillis)) {
try {
Files.deleteIfExists(path);
onDeleteCallback.accept(path);
} catch (IOException e) {
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: {}", path, e);
}
}
}
} catch (Exception e) {
log.warn("Error processing path: {}", path, e);
}
});
}
for (Path subdirectory : subdirectories) {
try {
cleanupDirectoryStreaming(
subdirectory,
containerMode,
depth + 1,
maxAgeMillis,
isScheduled,
onDeleteCallback);
} catch (IOException e) {
log.warn("Error processing subdirectory: {}", subdirectory, e);
}
}
}
/** Determine if a file should be deleted based on its name, age, and other criteria. */
private boolean shouldDeleteFile(
Path path, String fileName, boolean containerMode, long maxAgeMillis) {
// First check if it matches our known temp file patterns
boolean isOurTempFile = IS_OUR_TEMP_FILE.test(fileName);
boolean isSystemTempFile = IS_SYSTEM_TEMP_FILE.test(fileName);
// Normal operation - check against temp file patterns
boolean shouldDelete = isOurTempFile || (containerMode && isSystemTempFile);
// Get file info for age checks
long lastModified = 0;
long currentTime = System.currentTimeMillis();
boolean isEmptyFile = false;
try {
lastModified = Files.getLastModifiedTime(path).toMillis();
// Special case for zero-byte files - these are often corrupted temp files
if (Files.size(path) == 0) {
isEmptyFile = true;
// For empty files, use a shorter timeout (5 minutes)
// Delete empty files older than 5 minutes
if ((currentTime - lastModified) > 5 * 60 * 1000) {
shouldDelete = true;
}
}
} catch (IOException e) {
log.debug("Could not check file info, skipping: {}", path);
}
// Check file age against maxAgeMillis only if it's not an empty file that we've already
// decided to delete
if (!isEmptyFile && shouldDelete && maxAgeMillis > 0) {
// In normal mode, check age against maxAgeMillis
shouldDelete = (currentTime - lastModified) > maxAgeMillis;
}
return shouldDelete;
}
/** Clean up LibreOffice temporary files. This method is called after LibreOffice operations. */
public void cleanupLibreOfficeTempFiles() {
// Cleanup known LibreOffice temp directories
try {
Set<Path> directories = registry.getTempDirectories();
for (Path dir : directories) {
if (dir.getFileName().toString().contains("libreoffice") && Files.exists(dir)) {
// For directories containing "libreoffice", delete all contents
// but keep the directory itself for future use
cleanupDirectoryStreaming(
dir,
isContainerMode(),
0,
0, // age doesn't matter for LibreOffice cleanup
false,
path -> log.debug("Cleaned up LibreOffice temp file: {}", path));
log.debug("Cleaned up LibreOffice temp directory contents: {}", dir);
}
}
} catch (IOException e) {
log.warn("Failed to clean up LibreOffice temp files", e);
}
}
/**
* Clean up PDFBox cache file from user home directory. This cache file can grow large and
* should be periodically cleaned.
*/
private void cleanupPDFBoxCache() {
try {
Path userHome = Path.of(System.getProperty("user.home"));
Path pdfboxCache = userHome.resolve(".pdfbox.cache");
if (Files.exists(pdfboxCache)) {
Files.deleteIfExists(pdfboxCache);
log.debug("Cleaned up PDFBox cache file: {}", pdfboxCache);
}
} catch (IOException e) {
log.warn("Failed to clean up PDFBox cache file", e);
}
}
}

View File

@ -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 <T> The type of the bean
* @param beanClass The class of the bean
* @return The bean instance, or null if not found
*/
public static <T> T getBean(Class<T> 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 <T> 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> T getBean(String name, Class<T> 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;
}
}
}

View File

@ -134,7 +134,8 @@ public class EmlToPdf {
byte[] emlBytes,
String fileName,
boolean disableSanitize,
stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory)
stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager)
throws IOException, InterruptedException {
validateEmlInput(emlBytes);
@ -153,7 +154,8 @@ public class EmlToPdf {
// Convert HTML to PDF
byte[] pdfBytes =
convertHtmlToPdf(weasyprintPath, request, htmlContent, disableSanitize);
convertHtmlToPdf(
weasyprintPath, request, htmlContent, disableSanitize, tempFileManager);
// Attach files if available and requested
if (shouldAttachFiles(emailContent, request)) {
@ -194,7 +196,8 @@ public class EmlToPdf {
String weasyprintPath,
EmlToPdfRequest request,
String htmlContent,
boolean disableSanitize)
boolean disableSanitize,
TempFileManager tempFileManager)
throws IOException, InterruptedException {
HTMLToPdfRequest htmlRequest = createHtmlRequest(request);
@ -205,7 +208,8 @@ public class EmlToPdf {
htmlRequest,
htmlContent.getBytes(StandardCharsets.UTF_8),
"email.html",
disableSanitize);
disableSanitize,
tempFileManager);
} catch (IOException | InterruptedException e) {
log.warn("Initial HTML to PDF conversion failed, trying with simplified HTML");
String simplifiedHtml = simplifyHtmlContent(htmlContent);
@ -214,7 +218,8 @@ public class EmlToPdf {
htmlRequest,
simplifiedHtml.getBytes(StandardCharsets.UTF_8),
"email.html",
disableSanitize);
disableSanitize,
tempFileManager);
}
}

View File

@ -26,23 +26,25 @@ public class FileToPdf {
HTMLToPdfRequest request,
byte[] fileBytes,
String fileName,
boolean disableSanitize)
boolean disableSanitize,
TempFileManager tempFileManager)
throws IOException, InterruptedException {
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
Path tempInputFile = null;
byte[] pdfBytes;
try {
try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
try (TempFile tempInputFile =
new TempFile(tempFileManager, fileName.endsWith(".html") ? ".html" : ".zip")) {
if (fileName.endsWith(".html")) {
tempInputFile = Files.createTempFile("input_", ".html");
String sanitizedHtml =
sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8), disableSanitize);
Files.write(tempInputFile, sanitizedHtml.getBytes(StandardCharsets.UTF_8));
Files.write(
tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else if (fileName.endsWith(".zip")) {
tempInputFile = Files.createTempFile("input_", ".zip");
Files.write(tempInputFile, fileBytes);
sanitizeHtmlFilesInZip(tempInputFile, disableSanitize);
Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip(
tempInputFile.getPath(), disableSanitize, tempFileManager);
} else {
throw new IllegalArgumentException("Unsupported file format: " + fileName);
}
@ -53,47 +55,51 @@ public class FileToPdf {
command.add("utf-8");
command.add("-v");
command.add("--pdf-forms");
command.add(tempInputFile.toString());
command.add(tempOutputFile.toString());
command.add(tempInputFile.getAbsolutePath());
command.add(tempOutputFile.getAbsolutePath());
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
.runCommandWithOutputHandling(command);
pdfBytes = Files.readAllBytes(tempOutputFile);
} catch (IOException e) {
pdfBytes = Files.readAllBytes(tempOutputFile);
byte[] pdfBytes = Files.readAllBytes(tempOutputFile.getPath());
try {
return pdfBytes;
} catch (Exception e) {
pdfBytes = Files.readAllBytes(tempOutputFile.getPath());
if (pdfBytes.length < 1) {
throw e;
}
} finally {
Files.deleteIfExists(tempOutputFile);
Files.deleteIfExists(tempInputFile);
}
return pdfBytes;
}
} // tempInputFile auto-closed
} // tempOutputFile auto-closed
}
private static String sanitizeHtmlContent(String htmlContent, boolean disableSanitize) {
return (!disableSanitize) ? CustomHtmlSanitizer.sanitize(htmlContent) : htmlContent;
}
private static void sanitizeHtmlFilesInZip(Path zipFilePath, boolean disableSanitize)
private static void sanitizeHtmlFilesInZip(
Path zipFilePath, boolean disableSanitize, TempFileManager tempFileManager)
throws IOException {
Path tempUnzippedDir = Files.createTempDirectory("unzipped_");
try (TempDirectory tempUnzippedDir = new TempDirectory(tempFileManager)) {
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(
new ByteArrayInputStream(Files.readAllBytes(zipFilePath)))) {
ZipEntry entry = zipIn.getNextEntry();
while (entry != null) {
Path filePath = tempUnzippedDir.resolve(sanitizeZipFilename(entry.getName()));
Path filePath =
tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName()));
if (!entry.isDirectory()) {
Files.createDirectories(filePath.getParent());
if (entry.getName().toLowerCase().endsWith(".html")
|| entry.getName().toLowerCase().endsWith(".htm")) {
String content = new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
String content =
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
String sanitizedContent = sanitizeHtmlContent(content, disableSanitize);
Files.write(filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
Files.write(
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
} else {
Files.copy(zipIn, filePath);
}
@ -104,10 +110,8 @@ public class FileToPdf {
}
// Repack the sanitized files
zipDirectory(tempUnzippedDir, zipFilePath);
// Clean up
deleteDirectory(tempUnzippedDir);
zipDirectory(tempUnzippedDir.getPath(), zipFilePath);
} // tempUnzippedDir auto-cleaned
}
private static void zipDirectory(Path sourceDir, Path zipFilePath) throws IOException {

View File

@ -34,7 +34,27 @@ import stirling.software.common.configuration.InstallationPathConfig;
public class GeneralUtils {
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
File tempFile = Files.createTempFile("temp", null).toFile();
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
if (customTempDir == null || customTempDir.isEmpty()) {
customTempDir = System.getProperty("stirling.tempfiles.directory");
}
File tempFile;
if (customTempDir != null && !customTempDir.isEmpty()) {
Path tempDir = Path.of(customTempDir);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
tempFile = Files.createTempFile(tempDir, "stirling-pdf-", null).toFile();
} else {
Path tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
tempFile = Files.createTempFile(tempDir, "stirling-pdf-", null).toFile();
}
try (InputStream inputStream = multipartFile.getInputStream();
FileOutputStream outputStream = new FileOutputStream(tempFile)) {

View File

@ -0,0 +1,44 @@
package stirling.software.common.util;
import java.io.IOException;
import java.nio.file.Path;
import lombok.extern.slf4j.Slf4j;
/**
* A wrapper class for a temporary directory that implements AutoCloseable. Can be used with
* try-with-resources for automatic cleanup.
*/
@Slf4j
public class TempDirectory implements AutoCloseable {
private final TempFileManager manager;
private final Path directory;
public TempDirectory(TempFileManager manager) throws IOException {
this.manager = manager;
this.directory = manager.createTempDirectory();
}
public Path getPath() {
return directory;
}
public String getAbsolutePath() {
return directory.toAbsolutePath().toString();
}
public boolean exists() {
return java.nio.file.Files.exists(directory);
}
@Override
public void close() {
manager.deleteTempDirectory(directory);
}
@Override
public String toString() {
return "TempDirectory{" + directory.toAbsolutePath() + "}";
}
}

View File

@ -0,0 +1,49 @@
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import lombok.extern.slf4j.Slf4j;
/**
* A wrapper class for a temporary file that implements AutoCloseable. Can be used with
* try-with-resources for automatic cleanup.
*/
@Slf4j
public 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();
}
public String getAbsolutePath() {
return file.getAbsolutePath();
}
public boolean exists() {
return file.exists();
}
@Override
public void close() {
manager.deleteTempFile(file);
}
@Override
public String toString() {
return "TempFile{" + file.getAbsolutePath() + "}";
}
}

View File

@ -0,0 +1,249 @@
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.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Service for managing temporary files in Stirling-PDF. Provides methods for creating, tracking,
* and cleaning up temporary files.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TempFileManager {
private final TempFileRegistry registry;
private final ApplicationProperties applicationProperties;
/**
* 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 {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path tempFilePath;
String customTempDirectory = tempFiles.getBaseTmpDir();
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
Path tempDir = Path.of(customTempDirectory);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
tempFilePath = Files.createTempFile(tempDir, tempFiles.getPrefix(), suffix);
} else {
tempFilePath = Files.createTempFile(tempFiles.getPrefix(), 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 {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path tempDirPath;
String customTempDirectory = tempFiles.getBaseTmpDir();
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
Path tempDir = Path.of(customTempDirectory);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
tempDirPath = Files.createTempDirectory(tempDir, tempFiles.getPrefix());
} else {
tempDirPath = Files.createTempDirectory(tempFiles.getPrefix());
}
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<Path> 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() {
long maxAgeHours =
applicationProperties.getSystem().getTempFileManagement().getMaxAgeHours();
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 tempFilePrefix =
applicationProperties.getSystem().getTempFileManagement().getPrefix();
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 {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path loTempDir;
String libreOfficeTempDir = tempFiles.getLibreofficeDir();
String customTempDirectory = tempFiles.getBaseTmpDir();
// 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);
}
}

View File

@ -0,0 +1,176 @@
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.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
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 {
private final ConcurrentMap<Path, Instant> registeredFiles = new ConcurrentHashMap<>();
private final Set<Path> thirdPartyTempFiles =
Collections.newSetFromMap(new ConcurrentHashMap<>());
private final Set<Path> tempDirectories =
Collections.newSetFromMap(new ConcurrentHashMap<>());
/**
* 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<Path> 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<Path> 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<Path> getThirdPartyTempFiles() {
return thirdPartyTempFiles;
}
/**
* Get all registered temporary directories.
*
* @return Set of temporary directory paths
*/
public Set<Path> 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();
}
}

View File

@ -0,0 +1,135 @@
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 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<File> 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<File> 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 <R> 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> R withTempFile(
TempFileManager tempFileManager, String suffix, Function<File, R> 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 <R> 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> R withMultipleTempFiles(
TempFileManager tempFileManager,
int count,
String suffix,
Function<List<File>, R> function)
throws IOException {
List<File> 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<Path> 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;
}
}

View File

@ -0,0 +1,464 @@
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.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
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;
@Mock
private ApplicationProperties applicationProperties;
@Mock
private ApplicationProperties.System system;
@Mock
private ApplicationProperties.TempFileManagement tempFileManagement;
@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 ApplicationProperties mocks
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getTempFileManagement()).thenReturn(tempFileManagement);
when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString());
when(tempFileManagement.getLibreofficeDir()).thenReturn(libreOfficeTempDir.toString());
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
when(tempFileManagement.isStartupCleanup()).thenReturn(false);
when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false);
when(tempFileManagement.getCleanupIntervalMinutes()).thenReturn(30L);
// Set machineType using reflection (still needed for this field)
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L); // 1 hour
}
@Test
public void testScheduledCleanup_RegisteredFiles() {
// Arrange
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(5); // 5 files deleted
Set<Path> 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"));
// Old temporary files
Path oldTempFile = Files.createFile(systemTempDir.resolve("output_old.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);
// The set of files that will be deleted in our test
Set<Path> deletedFiles = new HashSet<>();
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for each directory we'll process
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(
ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1,
jettyFile1, jettyFile2, regularFile, emptyFile, nestedDir));
mockedFiles.when(() -> Files.list(eq(customTempDir)))
.thenReturn(Stream.of(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3));
mockedFiles.when(() -> Files.list(eq(libreOfficeTempDir)))
.thenReturn(Stream.of(ourTempFile5));
mockedFiles.when(() -> Files.list(eq(nestedDir)))
.thenReturn(Stream.of(nestedTempFile));
// Configure Files.isDirectory for each path
mockedFiles.when(() -> Files.isDirectory(eq(nestedDir))).thenReturn(true);
mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false);
// Configure Files.exists to return true for all paths
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
// For files with "old" in the name, return a timestamp older than maxAgeMillis
if (fileName.contains("old")) {
return FileTime.fromMillis(System.currentTimeMillis() - 5000000);
}
// For empty.tmp file, return a timestamp older than 5 minutes (for empty file test)
else if (fileName.equals("empty.tmp")) {
return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000);
}
// For all other files, return a recent timestamp
else {
return FileTime.fromMillis(System.currentTimeMillis() - 60000); // 1 minute ago
}
});
// Configure Files.size to return different sizes based on file names
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
// Return 0 bytes for the empty file
if (fileName.equals("empty.tmp")) {
return 0L;
}
// Return normal size for all other files
else {
return 1024L; // 1 KB
}
});
// For deleteIfExists, track which files would be deleted
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act - set containerMode to false for this test
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
invokeCleanupDirectoryStreaming(customTempDir, false, 0, 3600000);
invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 0, 3600000);
// Assert - Only old temp files and empty files should be deleted
assertTrue(deletedFiles.contains(oldTempFile), "Old temp file should be deleted");
assertTrue(deletedFiles.contains(emptyFile), "Empty file should be deleted");
// Regular temp files should not be deleted because they're too new
assertFalse(deletedFiles.contains(ourTempFile1), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile2), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile3), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile4), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile5), "Recent temp file should be preserved");
// System temp files should not be deleted in non-container mode
assertFalse(deletedFiles.contains(sysTempFile1), "System temp file should be preserved in non-container mode");
assertFalse(deletedFiles.contains(sysTempFile2), "System temp file should be preserved in non-container mode");
assertFalse(deletedFiles.contains(sysTempFile3), "System temp file should be preserved in non-container mode");
// Jetty files and regular files should never be deleted
assertFalse(deletedFiles.contains(jettyFile1), "Jetty file should be preserved");
assertFalse(deletedFiles.contains(jettyFile2), "File with jetty in name should be preserved");
assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved");
}
}
@Test
public void testContainerModeCleanup() throws IOException {
// Arrange - Create various temp files
Path ourTempFile = Files.createFile(systemTempDir.resolve("output_123.pdf"));
Path sysTempFile = Files.createFile(systemTempDir.resolve("lu123abc.tmp"));
Path regularFile = Files.createFile(systemTempDir.resolve("important.txt"));
// Configure mock registry to say these files aren't registered
when(registry.contains(any(File.class))).thenReturn(false);
// The set of files that will be deleted in our test
Set<Path> deletedFiles = new HashSet<>();
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for systemTempDir
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(ourTempFile, sysTempFile, regularFile));
// Configure Files.isDirectory
mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false);
// Configure Files.exists
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return recent timestamps
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenReturn(FileTime.fromMillis(System.currentTimeMillis() - 60000)); // 1 minute ago
// Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenReturn(1024L); // 1 KB
// For deleteIfExists, track which files would be deleted
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act - set containerMode to true and maxAgeMillis to 0 for container startup cleanup
invokeCleanupDirectoryStreaming(systemTempDir, true, 0, 0);
// Assert - In container mode, both our temp files and system temp files should be deleted
// regardless of age (when maxAgeMillis is 0)
assertTrue(deletedFiles.contains(ourTempFile), "Our temp file should be deleted in container mode");
assertTrue(deletedFiles.contains(sysTempFile), "System temp file should be deleted in container mode");
assertFalse(deletedFiles.contains(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"));
Path recentEmptyFile = Files.createFile(systemTempDir.resolve("recent_empty.tmp"));
// Configure mock registry to say these files aren't registered
when(registry.contains(any(File.class))).thenReturn(false);
// The set of files that will be deleted in our test
Set<Path> deletedFiles = new HashSet<>();
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for systemTempDir
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(emptyFile, recentEmptyFile));
// Configure Files.isDirectory
mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false);
// Configure Files.exists
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
if (fileName.equals("empty.tmp")) {
// More than 5 minutes old
return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000);
} else {
// Less than 5 minutes old
return FileTime.fromMillis(System.currentTimeMillis() - 2 * 60 * 1000);
}
});
// Configure Files.size to return 0 for empty files
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenReturn(0L);
// For deleteIfExists, track which files would be deleted
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
// Assert
assertTrue(deletedFiles.contains(emptyFile),
"Empty file older than 5 minutes should be deleted");
assertFalse(deletedFiles.contains(recentEmptyFile),
"Empty file newer than 5 minutes should not 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_old_3.pdf"));
// Configure mock registry to say these files aren't registered
when(registry.contains(any(File.class))).thenReturn(false);
// The set of files that will be deleted in our test
Set<Path> deletedFiles = new HashSet<>();
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for each directory
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(dir1));
mockedFiles.when(() -> Files.list(eq(dir1)))
.thenReturn(Stream.of(tempFile1, dir2));
mockedFiles.when(() -> Files.list(eq(dir2)))
.thenReturn(Stream.of(tempFile2, dir3));
mockedFiles.when(() -> Files.list(eq(dir3)))
.thenReturn(Stream.of(tempFile3));
// Configure Files.isDirectory for each path
mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true);
mockedFiles.when(() -> Files.isDirectory(eq(dir2))).thenReturn(true);
mockedFiles.when(() -> Files.isDirectory(eq(dir3))).thenReturn(true);
mockedFiles.when(() -> Files.isDirectory(eq(tempFile1))).thenReturn(false);
mockedFiles.when(() -> Files.isDirectory(eq(tempFile2))).thenReturn(false);
mockedFiles.when(() -> Files.isDirectory(eq(tempFile3))).thenReturn(false);
// Configure Files.exists to return true for all paths
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
if (fileName.contains("old")) {
// Old file
return FileTime.fromMillis(System.currentTimeMillis() - 5000000);
} else {
// Recent file
return FileTime.fromMillis(System.currentTimeMillis() - 60000);
}
});
// Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenReturn(1024L);
// For deleteIfExists, track which files would be deleted
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
// Debug - print what was deleted
System.out.println("Deleted files: " + deletedFiles);
System.out.println("Looking for: " + tempFile3);
// Assert
assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved");
assertTrue(deletedFiles.contains(tempFile3), "Old temp file in nested directory should be deleted");
}
}
/**
* Helper method to invoke the private cleanupDirectoryStreaming method using reflection
*/
private void invokeCleanupDirectoryStreaming(Path directory, boolean containerMode, int depth, long maxAgeMillis)
throws IOException {
try {
// Create a consumer that tracks deleted files
AtomicInteger deleteCount = new AtomicInteger(0);
Consumer<Path> deleteCallback = path -> deleteCount.incrementAndGet();
// Get the method with updated signature
var method = TempFileCleanupService.class.getDeclaredMethod(
"cleanupDirectoryStreaming",
Path.class, boolean.class, int.class, long.class, boolean.class, Consumer.class);
method.setAccessible(true);
// Invoke the method with appropriate parameters
method.invoke(cleanupService, directory, containerMode, depth, maxAgeMillis, false, deleteCallback);
} catch (Exception e) {
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
}
}
// Matcher for exact path equality
private static Path eq(Path path) {
return argThat(arg -> arg != null && arg.equals(path));
}
}

View File

@ -0,0 +1,893 @@
package stirling.software.common.util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@DisplayName("EML to PDF Conversion tests")
class EmlToPdfTest {
// Focus on testing EML to HTML conversion functionality since the PDF conversion relies on WeasyPrint
// But HTML to PDF conversion is also briefly tested at PdfConversionTests class.
private void testEmailConversion(String emlContent, String[] expectedContent, boolean includeAttachments) throws IOException {
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = includeAttachments ? createRequestWithAttachments() : createBasicRequest();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
for (String expected : expectedContent) {
assertTrue(htmlResult.contains(expected), "HTML should contain: " + expected);
}
}
@Nested
@DisplayName("Core EML Parsing")
class CoreParsingTests {
@Test
@DisplayName("Should parse simple text email correctly")
void parseSimpleTextEmail() throws IOException {
String emlContent = createSimpleTextEmail(
"sender@example.com",
"recipient@example.com",
"Simple Test Subject",
"This is a simple plain text email body.");
testEmailConversion(emlContent, new String[] {
"Simple Test Subject",
"sender@example.com",
"recipient@example.com",
"This is a simple plain text email body",
"<!DOCTYPE html>"
}, false);
}
@Test
@DisplayName("Should parse email with missing Subject and To headers")
void parseEmailWithMissingHeaders() throws IOException {
String emlContent = createEmailWithCustomHeaders();
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains("sender@example.com"));
assertTrue(htmlResult.contains("This is an email body"));
assertTrue(htmlResult.contains("<title></title>") ||
htmlResult.contains("<title>No Subject</title>"));
}
@Test
@DisplayName("Should parse HTML email with styling")
void parseHtmlEmailWithStyling() throws IOException {
String htmlBody = "<html><head><style>.header{color:blue;font-weight:bold;}" +
".content{margin:10px;}.footer{font-size:12px;}</style></head>" +
"<body><div class=\"header\">Important Notice</div>" +
"<div class=\"content\">This is <strong>HTML content</strong> with styling.</div>" +
"<div class=\"footer\">Best regards</div></body></html>";
String emlContent = createHtmlEmail(
"html@example.com", "user@example.com", "HTML Email Test", htmlBody);
testEmailConversion(emlContent, new String[] {
"HTML Email Test",
"Important Notice",
"HTML content",
"font-weight: bold"
}, false);
}
@Test
@DisplayName("Should parse multipart email with attachments")
void parseMultipartEmailWithAttachments() throws IOException {
String boundary = "----=_Part_" + getTimestamp();
String emlContent = createMultipartEmailWithAttachment(
"multipart@example.com",
"user@example.com",
"Multipart Email Test",
"This email has both text content and an attachment.",
boundary,
"document.txt",
"Sample attachment content");
testEmailConversion(emlContent, new String[] {
"Multipart Email Test",
"This email has both text content"
}, true);
}
}
@Nested
@DisplayName("Email Encoding Support")
class EncodingTests {
@Test
@DisplayName("Should handle international characters and UTF-8")
void handleInternationalCharacters() throws IOException {
String bodyWithIntlChars = "Hello! 你好 Привет مرحبا Hëllö Thañks! Önë Mörë";
String emlContent = createSimpleTextEmail(
"intl@example.com",
"user@example.com",
"International Characters Test",
bodyWithIntlChars);
testEmailConversion(emlContent, new String[] {
"你好", "Привет", "مرحبا", "Hëllö", "Önë", "Mörë"
}, false);
}
@Test
@DisplayName("Should decode quoted-printable content correctly")
void decodeQuotedPrintableContent() throws IOException {
String content = createQuotedPrintableEmail(
);
testEmailConversion(content, new String[] {
"Quoted-Printable Test",
"This is quoted printable content with special chars: éàè."
}, false);
}
@Test
@DisplayName("Should decode Base64 content")
void decodeBase64Content() throws IOException {
String originalText = "This is Base64 encoded content: éàü ñ";
String content = createBase64Email(
originalText);
testEmailConversion(content, new String[] {
"Base64 Test", "Base64 encoded content"
}, false);
}
@Test
@DisplayName("Should correctly handle inline images with CID references")
void handleInlineImages() throws IOException {
String boundary = "----=_Part_CID_1234567890";
String cid = "image123@example.com";
String htmlBody = "<html><body><p>Here is an image:</p><img src=\"cid:" + cid + "\"></body></html>";
String imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
String emlContent = createEmailWithInlineImage(htmlBody, boundary, cid, imageBase64);
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createRequestWithAttachments();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains("data:image/png;base64," + imageBase64));
assertFalse(htmlResult.contains("cid:" + cid));
}
}
@Nested
@DisplayName("HTML Output Quality")
class HtmlOutputTests {
@Test
@DisplayName("Should generate valid HTML structure")
void generateValidHtmlStructure() throws IOException {
String emlContent = createSimpleTextEmail(
"structure@test.com",
"user@test.com",
"HTML Structure Test",
"Testing HTML structure output");
testEmailConversion(emlContent, new String[] {
"<!DOCTYPE html>", "<html", "</html>", "HTML Structure Test"
}, false);
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, createBasicRequest());
assertTrue(htmlResult.length() > 100, "HTML should have substantial content");
}
@Test
@DisplayName("Should preserve safe CSS and remove problematic styles")
void handleCssStylesCorrectly() throws IOException {
String styledHtml = "<html><head><style>" +
".safe { color: blue; font-size: 14px; }" +
".problematic { position: fixed; word-break: break-all; }" +
".good { margin: 10px; padding: 5px; }" +
"</style></head><body>" +
"<div class=\"safe\">Safe styling</div>" +
"<div class=\"problematic\">Problematic styling</div>" +
"<div class=\"good\">Good styling</div>" +
"</body></html>";
String emlContent = createHtmlEmail("css@test.com", "user@test.com", "CSS Test", styledHtml);
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains("color: blue"));
assertTrue(htmlResult.contains("font-size: 14px"));
assertTrue(htmlResult.contains("margin: 10px"));
assertFalse(htmlResult.contains("position: fixed"));
}
@Test
@DisplayName("Should handle complex nested HTML structures")
void handleComplexNestedHtml() throws IOException {
String complexHtml = "<html><head><title>Complex Email</title></head><body>" +
"<div class=\"container\"><header><h1>Email Header</h1></header><main><section>" +
"<p>Paragraph with <a href=\"https://example.com\">link</a></p><ul>" +
"<li>List item 1</li><li>List item 2 with <em>emphasis</em></li></ul><table>" +
"<tr><td>Cell 1</td><td>Cell 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>" +
"</table></section></main></div></body></html>";
String emlContent = createHtmlEmail(
"complex@test.com", "user@test.com", "Complex HTML Test", complexHtml);
testEmailConversion(emlContent, new String[] {
"Email Header", "List item 2", "Cell 3", "example.com"
}, false);
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, createBasicRequest());
assertTrue(htmlResult.length() > 300, "HTML should have substantial content");
}
}
@Nested
@DisplayName("Error Handling & Edge Cases")
class ErrorHandlingTests {
@Test
@DisplayName("Should reject null input")
void rejectNullInput() {
EmlToPdfRequest request = createBasicRequest();
Exception exception = assertThrows(
IllegalArgumentException.class,
() -> EmlToPdf.convertEmlToHtml(null, request));
assertTrue(exception.getMessage().contains("EML file is empty or null"));
}
@Test
@DisplayName("Should reject empty input")
void rejectEmptyInput() {
EmlToPdfRequest request = createBasicRequest();
Exception exception = assertThrows(
IllegalArgumentException.class,
() -> EmlToPdf.convertEmlToHtml(new byte[0], request));
assertTrue(exception.getMessage().contains("EML file is empty or null"));
}
@Test
@DisplayName("Should handle malformed EML gracefully")
void handleMalformedEmlGracefully() {
String malformedEml = """
From: sender@test.com
Subject: Malformed EML
This line breaks header format
Content-Type: text/plain
Body content""";
byte[] emlBytes = malformedEml.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
try {
String result = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(result, "Result should not be null");
assertFalse(result.isEmpty(), "Result should not be empty");
} catch (Exception e) {
assertNotNull(e.getMessage(), "Exception message should not be null");
assertFalse(e.getMessage().isEmpty(), "Exception message should not be empty");
}
}
@Test
@DisplayName("Should reject invalid EML format")
void rejectInvalidEmlFormat() {
byte[] invalidEml = "This is definitely not an EML file".getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
Exception exception = assertThrows(
IllegalArgumentException.class,
() -> EmlToPdf.convertEmlToHtml(invalidEml, request));
assertTrue(exception.getMessage().contains("Invalid EML file format"));
}
}
@Nested
@DisplayName("Advanced Parsing Tests (Jakarta Mail)")
class AdvancedParsingTests {
@Test
@DisplayName("Should successfully parse email using advanced parser")
void initializeDependencyMailSession() {
assertDoesNotThrow(() -> {
String emlContent = createSimpleTextEmail(
"Dependency@test.com",
"user@test.com",
"Dependency Mail Test",
"Testing Dependency Mail integration.");
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains("Dependency Mail Test"));
});
}
@Test
@DisplayName("Should parse complex MIME structures and select HTML part")
void parseComplexMimeStructures() throws IOException {
String boundary = "----=_Advanced_1234567890";
String textBody = "This is the plain text part.";
String htmlBody = "<html><body><h1>This is the HTML part</h1></body></html>";
String emlContent = createMultipartAlternativeEmail(textBody, htmlBody, boundary);
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createRequestWithAttachments();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains("This is the HTML part"));
assertFalse(htmlResult.contains("This is the plain text part"));
}
}
@Nested
@DisplayName("Additional Correctness Tests")
class AdditionalCorrectnessTests {
@Test
@DisplayName("Should handle email with only an attachment and no body")
void handleAttachmentOnlyEmail() throws IOException {
String boundary = "----=_Part_AttachmentOnly_1234567890";
String emlContent = createMultipartEmailWithAttachment(
"sender@example.com",
"recipient@example.com",
"Attachment Only Test",
"",
boundary,
"data.bin",
"binary data");
testEmailConversion(emlContent, new String[] {
"Attachment Only Test", "data.bin", "No content available"
}, true);
}
@Test
@DisplayName("Should handle mixed inline and regular attachments")
void handleMixedAttachments() throws IOException {
String boundary = "----=_Part_MixedAttachments_1234567890";
String cid = "inline_image@example.com";
String htmlBody = "<html><body><img src=\"cid:" + cid + "\"></body></html>";
String imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR42mNkAAIAAAoAAb6A/yoAAAAASUVORK5CYII=";
String attachmentText = "This is a text attachment.";
String emlContent = createEmailWithMixedAttachments(
htmlBody, boundary, cid, imageBase64, attachmentText);
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createRequestWithAttachments();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains("data:image/png;base64," + imageBase64));
assertTrue(htmlResult.contains("text.txt"));
}
@Test
@DisplayName("Should handle non-standard but valid character sets like ISO-8859-1")
void handleIso88591Charset() throws IOException {
String subject = "Subject with special characters: ñ é ü";
String body = "Body with special characters: ñ é ü";
String emlContent = createSimpleTextEmailWithCharset(
"sender@example.com",
"recipient@example.com",
subject,
body,
"ISO-8859-1");
byte[] emlBytes = emlContent.getBytes(StandardCharsets.ISO_8859_1);
EmlToPdfRequest request = createBasicRequest();
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains(subject));
assertTrue(htmlResult.contains(body));
}
@Test
@DisplayName("Should handle emails with extremely long lines")
void handleLongLines() throws IOException {
StringBuilder longLine = new StringBuilder("This is a very long line: ");
for (int i = 0; i < 1000; i++) {
longLine.append("word").append(i).append(" ");
}
String emlContent = createSimpleTextEmail(
"sender@example.com",
"recipient@example.com",
"Long Line Test",
longLine.toString());
testEmailConversion(emlContent, new String[] {
"Long Line Test", "This is a very long line", "word999"
}, false);
}
@Test
@DisplayName("Should handle .eml files as attachments")
void handleEmlAttachment() throws IOException {
String boundary = "----=_Part_EmlAttachment_1234567890";
String innerEmlContent = createSimpleTextEmail(
"inner@example.com",
"inner_recipient@example.com",
"Inner Email Subject",
"This is the body of the attached email.");
String emlContent = createEmailWithEmlAttachment(
boundary,
innerEmlContent);
testEmailConversion(emlContent, new String[] {
"Fwd: Inner Email Subject",
"Please see the attached email.",
"attached_email.eml"
}, true);
}
@ParameterizedTest
@ValueSource(strings = {"windows-1252", "ISO-8859-2", "KOI8-R", "Shift_JIS"})
@DisplayName("Should handle various character encodings")
void handleVariousEncodings(String charset) throws IOException {
String subject = "Encoding Test";
String body = "Testing " + charset + " encoding";
String emlContent = createSimpleTextEmailWithCharset(
"sender@example.com",
"recipient@example.com",
subject,
body,
charset);
testEmailConversion(emlContent, new String[] {subject, body}, false);
}
}
@Nested
@ExtendWith(MockitoExtension.class)
@DisplayName("PDF Conversion Tests")
class PdfConversionTests {
@Mock private CustomPDFDocumentFactory mockPdfDocumentFactory;
@Mock private PDDocument mockPdDocument;
@Mock private TempFileManager mockTempFileManager;
@Test
@DisplayName("Should convert EML to PDF without attachments when not requested")
void convertEmlToPdfWithoutAttachments() throws Exception {
String emlContent =
createSimpleTextEmail("from@test.com", "to@test.com", "Subject", "Body");
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
PDDocument testPdf = new PDDocument();
testPdf.addPage(new PDPage());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
testPdf.save(baos);
testPdf.close();
byte[] fakePdfBytes = baos.toByteArray();
when(mockPdfDocumentFactory.load(any(byte[].class))).thenReturn(mockPdDocument);
when(mockPdDocument.getNumberOfPages()).thenReturn(1);
try (MockedStatic<FileToPdf> fileToPdf = mockStatic(FileToPdf.class)) {
fileToPdf
.when(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
any(),
any(byte[].class),
anyString(),
anyBoolean(),
any(TempFileManager.class)))
.thenReturn(fakePdfBytes);
byte[] resultPdf =
EmlToPdf.convertEmlToPdf(
"weasyprint",
request,
emlBytes,
"test.eml",
false,
mockPdfDocumentFactory,
mockTempFileManager);
assertArrayEquals(fakePdfBytes, resultPdf);
try (PDDocument resultDoc = mockPdfDocumentFactory.load(resultPdf)) {
assertNotNull(resultDoc);
assertTrue(resultDoc.getNumberOfPages() > 0);
}
fileToPdf.verify(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
any(),
any(byte[].class),
anyString(),
anyBoolean(),
any(TempFileManager.class)));
verify(mockPdfDocumentFactory).load(resultPdf);
}
}
@Test
@DisplayName("Should convert EML to PDF with attachments when requested")
void convertEmlToPdfWithAttachments() throws Exception {
String boundary = "----=_Part_1234567890";
String emlContent = createMultipartEmailWithAttachment(
"multipart@example.com",
"user@example.com",
"Multipart Email Test",
"This email has both text content and an attachment.",
boundary,
"document.txt",
"Sample attachment content");
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createRequestWithAttachments();
PDDocument testPdf = new PDDocument();
testPdf.addPage(new PDPage());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
testPdf.save(baos);
testPdf.close();
byte[] fakePdfBytes = baos.toByteArray();
when(mockPdfDocumentFactory.load(any(byte[].class))).thenReturn(mockPdDocument);
when(mockPdDocument.getNumberOfPages()).thenReturn(1);
try (MockedStatic<FileToPdf> fileToPdf = mockStatic(FileToPdf.class)) {
fileToPdf
.when(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
any(),
any(byte[].class),
anyString(),
anyBoolean(),
any(TempFileManager.class)))
.thenReturn(fakePdfBytes);
try (MockedStatic<EmlToPdf> ignored =
mockStatic(
EmlToPdf.class,
invocation -> {
String methodName = invocation.getMethod().getName();
return switch (methodName) {
case "shouldAttachFiles" -> true;
case "attachFilesToPdf" -> fakePdfBytes;
default -> invocation.callRealMethod();
};
})) {
byte[] resultPdf =
EmlToPdf.convertEmlToPdf(
"weasyprint",
request,
emlBytes,
"test.eml",
false,
mockPdfDocumentFactory,
mockTempFileManager);
assertArrayEquals(fakePdfBytes, resultPdf);
try (PDDocument resultDoc = mockPdfDocumentFactory.load(resultPdf)) {
assertNotNull(resultDoc);
assertTrue(resultDoc.getNumberOfPages() > 0);
}
fileToPdf.verify(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
any(),
any(byte[].class),
anyString(),
anyBoolean(),
any(TempFileManager.class)));
verify(mockPdfDocumentFactory).load(resultPdf);
}
}
}
@Test
@DisplayName("Should handle errors during EML to PDF conversion")
void handleErrorsDuringConversion() {
String emlContent =
createSimpleTextEmail("from@test.com", "to@test.com", "Subject", "Body");
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
String errorMessage = "Conversion failed";
try (MockedStatic<FileToPdf> fileToPdf = mockStatic(FileToPdf.class)) {
fileToPdf
.when(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
any(),
any(byte[].class),
anyString(),
anyBoolean(),
any(TempFileManager.class)))
.thenThrow(new IOException(errorMessage));
IOException exception = assertThrows(
IOException.class,
() -> EmlToPdf.convertEmlToPdf(
"weasyprint",
request,
emlBytes,
"test.eml",
false,
mockPdfDocumentFactory,
mockTempFileManager));
assertTrue(exception.getMessage().contains(errorMessage));
}
}
}
// Helper methods
private String getTimestamp() {
java.time.ZonedDateTime fixedDateTime = java.time.ZonedDateTime.of(2023, 1, 1, 12, 0, 0, 0, java.time.ZoneId.of("GMT"));
return java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(fixedDateTime);
}
private String createSimpleTextEmail(String from, String to, String subject, String body) {
return createSimpleTextEmailWithCharset(from, to, subject, body, "UTF-8");
}
private String createSimpleTextEmailWithCharset(String from, String to, String subject, String body, String charset) {
return String.format(
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
from, to, subject, getTimestamp(), charset, body);
}
private String createEmailWithCustomHeaders() {
return String.format(
"From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
getTimestamp(), "This is an email body with some headers missing.");
}
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
return String.format(
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
from, to, subject, getTimestamp(), htmlBody);
}
private String createMultipartEmailWithAttachment(String from, String to, String subject, String body,
String boundary, String filename, String attachmentContent) {
String encodedContent = Base64.getEncoder().encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
return String.format(
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
--%s--""",
from, to, subject, getTimestamp(), boundary, boundary, body, boundary, filename, encodedContent, boundary);
}
private String createEmailWithEmlAttachment(String boundary, String attachmentEmlContent) {
String encodedContent = Base64.getEncoder().encodeToString(attachmentEmlContent.getBytes(StandardCharsets.UTF_8));
return String.format(
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
--%s
Content-Type: message/rfc822; name="%s"
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
--%s--""",
"outer@example.com", "outer_recipient@example.com", "Fwd: Inner Email Subject", getTimestamp(), boundary, boundary, "Please see the attached email.", boundary, "attached_email.eml", "attached_email.eml", encodedContent, boundary);
}
private String createMultipartAlternativeEmail(String textBody, String htmlBody, String boundary) {
return String.format(
"""
From: %s
To: %s
Subject: %s
Date: %s
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
--%s--""",
"sender@example.com", "receiver@example.com", "Multipart/Alternative Test", getTimestamp(),
boundary, boundary, textBody, boundary, htmlBody, boundary);
}
private String createQuotedPrintableEmail() {
return String.format(
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
"sender@example.com", "recipient@example.com", "Quoted-Printable Test", getTimestamp(), "This is quoted=20printable content with special chars: =C3=A9=C3=A0=C3=A8.");
}
private String createBase64Email(String body) {
String encodedBody = Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
return String.format(
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
"sender@example.com", "recipient@example.com", "Base64 Test", getTimestamp(), encodedBody);
}
private String createEmailWithInlineImage(String htmlBody, String boundary, String contentId, String base64Image) {
return String.format(
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/related; boundary="%s"
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
--%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
%s
--%s--""",
"sender@example.com", "receiver@example.com", "Inline Image Test", getTimestamp(),
boundary, boundary, htmlBody, boundary, contentId, base64Image, boundary);
}
private String createEmailWithMixedAttachments(String htmlBody, String boundary, String contentId,
String base64Image, String attachmentBody) {
String encodedAttachment = Base64.getEncoder().encodeToString(attachmentBody.getBytes(StandardCharsets.UTF_8));
return String.format(
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: multipart/related; boundary="related-%s"
--related-%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
--related-%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
%s
--related-%s--
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
--%s--""",
"sender@example.com", "receiver@example.com", "Mixed Attachments Test", getTimestamp(),
boundary, boundary, boundary, boundary, htmlBody, boundary, contentId, base64Image, boundary,
boundary, "text.txt", encodedAttachment, boundary);
}
// Creates a basic EmlToPdfRequest with default settings
private EmlToPdfRequest createBasicRequest() {
EmlToPdfRequest request = new EmlToPdfRequest();
request.setIncludeAttachments(false);
return request;
}
private EmlToPdfRequest createRequestWithAttachments() {
EmlToPdfRequest request = new EmlToPdfRequest();
request.setIncludeAttachments(true);
request.setMaxAttachmentSizeMB(10);
return request;
}
}

View File

@ -3,7 +3,11 @@ package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.anyString;
import java.io.File;
import java.io.IOException;
import org.junit.jupiter.api.Test;
@ -22,14 +26,24 @@ public class FileToPdfTest {
byte[] fileBytes = new byte[0]; // Sample file bytes (empty input)
String fileName = "test.html"; // Sample file name indicating an HTML file
boolean disableSanitize = false; // Flag to control sanitization
TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager
// Expect an IOException to be thrown due to empty input
// Mock the temp file creation to return real temp files
try {
when(tempFileManager.createTempFile(anyString()))
.thenReturn(File.createTempFile("test", ".pdf"))
.thenReturn(File.createTempFile("test", ".html"));
} catch (IOException e) {
throw new RuntimeException(e);
}
// Expect an IOException to be thrown due to empty input or invalid weasyprint path
Throwable thrown =
assertThrows(
IOException.class,
Exception.class,
() ->
FileToPdf.convertHtmlToPdf(
"/path/", request, fileBytes, fileName, disableSanitize));
"/path/", request, fileBytes, fileName, disableSanitize, tempFileManager));
assertNotNull(thrown);
}

View File

@ -65,6 +65,9 @@ public class KeygenLicenseVerifier {
}
public License verifyLicense(String licenseKeyOrCert) {
if (!applicationProperties.getPremium().isEnabled()) {
return License.NORMAL;
}
License license;
LicenseContext context = new LicenseContext();

View File

@ -859,8 +859,28 @@ ignore = [
[sr_LATN_RS]
ignore = [
'language.direction',
'licenses.version',
'poweredBy',
'lang.div',
'lang.epo',
'lang.hin',
'lang.iku',
'lang.mar',
'lang.san',
'lang.snd',
'lang.tel',
'lang.tgl',
'lang.urd',
'font',
'info',
'pro',
'team.status',
'endpointStatistics.top10',
'endpointStatistics.top20',
'endpointStatistics.top',
'showJS.tags',
'validateSignature.status',
'audit.dashboard.status',
'audit.dashboard.table.id',
'audit.dashboard.modal.id',
]
[sv_SE]

View File

@ -28,9 +28,11 @@ if [[ -n "$LANGS" ]]; then
fi
echo "Setting permissions and ownership for necessary directories..."
# Ensure temp directory exists and has correct permissions
mkdir -p /tmp/stirling-pdf || true
# Attempt to change ownership of directories and files
if chown -R stirlingpdfuser:stirlingpdfgroup $HOME /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /app.jar; then
chmod -R 755 /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /app.jar || true
if chown -R stirlingpdfuser:stirlingpdfgroup $HOME /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /app.jar; then
chmod -R 755 /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /app.jar || true
# If chown succeeds, execute the command as stirlingpdfuser
exec su-exec stirlingpdfuser "$@"
else

View File

@ -28,4 +28,9 @@ if [[ -n "$TESSERACT_LANGS" ]]; then
done
fi
# Ensure temp directory exists with correct permissions before running main init
mkdir -p /tmp/stirling-pdf || true
chown -R stirlingpdfuser:stirlingpdfgroup /tmp/stirling-pdf || true
chmod -R 755 /tmp/stirling-pdf || true
/scripts/init-without-ocr.sh "$@"

View File

@ -24,6 +24,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.EmlToPdf;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -35,6 +36,7 @@ public class ConvertEmlToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/eml/pdf")
@Operation(
@ -102,7 +104,8 @@ public class ConvertEmlToPDF {
fileBytes,
originalFilename,
false,
pdfDocumentFactory);
pdfDocumentFactory,
tempFileManager);
if (pdfBytes == null || pdfBytes.length == 0) {
log.error("PDF conversion failed - empty output for {}", originalFilename);

View File

@ -18,6 +18,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -32,6 +33,8 @@ public class ConvertHtmlToPDF {
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
@ -62,7 +65,8 @@ public class ConvertHtmlToPDF {
request,
fileInput.getBytes(),
originalFilename,
disableSanitize);
disableSanitize,
tempFileManager);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);

View File

@ -28,6 +28,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -41,6 +42,8 @@ public class ConvertMarkdownToPdf {
private final ApplicationProperties applicationProperties;
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation(
summary = "Convert a Markdown file to PDF",
@ -82,7 +85,8 @@ public class ConvertMarkdownToPdf {
null,
htmlContent.getBytes(),
"converted.html",
disableSanitize);
disableSanitize,
tempFileManager);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
String outputFilename =
originalFilename.replaceFirst("[.][^.]+$", "")

View File

@ -2,7 +2,6 @@ package stirling.software.SPDF.controller.api.misc;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.zip.ZipEntry;
@ -23,7 +22,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.BoundedLineReader;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -34,6 +32,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
import stirling.software.common.model.ApplicationProperties;
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;
@RestController
@RequestMapping("/api/v1/misc")
@ -43,8 +44,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
public class OCRController {
private final ApplicationProperties applicationProperties;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
/** Gets the list of available Tesseract languages from the tessdata directory */
public List<String> getAvailableTesseractLanguages() {
@ -73,93 +74,117 @@ public class OCRController {
MultipartFile inputFile = request.getFileInput();
List<String> languages = request.getLanguages();
String ocrType = request.getOcrType();
Path tempDir = Files.createTempDirectory("ocr_process");
Path tempInputFile = tempDir.resolve("input.pdf");
Path tempOutputDir = tempDir.resolve("output");
Path tempImagesDir = tempDir.resolve("images");
Path finalOutputFile = tempDir.resolve("final_output.pdf");
Files.createDirectories(tempOutputDir);
Files.createDirectories(tempImagesDir);
Process process = null;
// Create a temp directory using TempFileManager directly
Path tempDirPath = tempFileManager.createTempDirectory();
File tempDir = tempDirPath.toFile();
try {
File tempInputFile = new File(tempDir, "input.pdf");
File tempOutputDir = new File(tempDir, "output");
File tempImagesDir = new File(tempDir, "images");
File finalOutputFile = new File(tempDir, "final_output.pdf");
// Create directories
tempOutputDir.mkdirs();
tempImagesDir.mkdirs();
// Save input file
inputFile.transferTo(tempInputFile.toFile());
inputFile.transferTo(tempInputFile);
PDFMergerUtility merger = new PDFMergerUtility();
merger.setDestinationFileName(finalOutputFile.toString());
try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
try (PDDocument document = pdfDocumentFactory.load(tempInputFile)) {
PDFRenderer pdfRenderer = new PDFRenderer(document);
int pageCount = document.getNumberOfPages();
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
PDPage page = document.getPage(pageNum);
boolean hasText = false;
// Check for existing text
try (PDDocument tempDoc = new PDDocument()) {
tempDoc.addPage(page);
PDFTextStripper stripper = new PDFTextStripper();
hasText = !stripper.getText(tempDoc).trim().isEmpty();
}
boolean shouldOcr =
switch (ocrType) {
case "skip-text" -> !hasText;
case "force-ocr" -> true;
default -> true;
};
Path pageOutputPath =
tempOutputDir.resolve(String.format("page_%d.pdf", pageNum));
File pageOutputPath =
new File(tempOutputDir, String.format("page_%d.pdf", pageNum));
if (shouldOcr) {
// Convert page to image
BufferedImage image = pdfRenderer.renderImageWithDPI(pageNum, 300);
Path imagePath =
tempImagesDir.resolve(String.format("page_%d.png", pageNum));
ImageIO.write(image, "png", imagePath.toFile());
File imagePath =
new File(tempImagesDir, String.format("page_%d.png", pageNum));
ImageIO.write(image, "png", imagePath);
// Build OCR command
List<String> command = new ArrayList<>();
command.add("tesseract");
command.add(imagePath.toString());
command.add(
tempOutputDir
.resolve(String.format("page_%d", pageNum))
new File(tempOutputDir, String.format("page_%d", pageNum))
.toString());
command.add("-l");
command.add(String.join("+", languages));
// Always output PDF
command.add("pdf");
ProcessBuilder pb = new ProcessBuilder(command);
process = pb.start();
// Capture any error output
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
log.debug("Tesseract: {}", line);
}
}
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException(
"Tesseract failed with exit code: " + exitCode);
}
// Use ProcessExecutor to run tesseract command
try {
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.TESSERACT)
.runCommandWithOutputHandling(command);
log.debug(
"Tesseract OCR completed for page {} with exit code {}",
pageNum,
result.getRc());
// Add OCR'd PDF to merger
merger.addSource(pageOutputPath.toFile());
merger.addSource(pageOutputPath);
} catch (IOException | InterruptedException e) {
log.error(
"Error processing page {} with tesseract: {}",
pageNum,
e.getMessage());
// If OCR fails, fall back to the original page
try (PDDocument pageDoc = new PDDocument()) {
pageDoc.addPage(page);
pageDoc.save(pageOutputPath);
merger.addSource(pageOutputPath);
}
}
} else {
// Save original page without OCR
try (PDDocument pageDoc = new PDDocument()) {
pageDoc.addPage(page);
pageDoc.save(pageOutputPath.toFile());
merger.addSource(pageOutputPath.toFile());
pageDoc.save(pageOutputPath);
merger.addSource(pageOutputPath);
}
}
}
}
// Merge all pages into final PDF
merger.mergeDocuments(null);
// Read the final PDF file
byte[] pdfContent = Files.readAllBytes(finalOutputFile);
byte[] pdfContent = java.nio.file.Files.readAllBytes(finalOutputFile.toPath());
String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_OCR.pdf";
return ResponseEntity.ok()
.header(
"Content-Disposition",
@ -167,11 +192,8 @@ public class OCRController {
.contentType(MediaType.APPLICATION_PDF)
.body(pdfContent);
} finally {
if (process != null) {
process.destroy();
}
// Clean up temporary files
deleteDirectory(tempDir);
// Clean up the temp directory and all its contents
tempFileManager.deleteTempDirectory(tempDirPath);
}
}
@ -192,21 +214,4 @@ public class OCRController {
zipOut.closeEntry();
}
}
private void deleteDirectory(Path directory) {
try {
Files.walk(directory)
.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.delete(path);
} catch (IOException e) {
log.error("Error deleting {}: {}", path, e.getMessage());
}
});
} catch (IOException e) {
log.error("Error walking directory {}: {}", directory, e.getMessage());
}
}
}

View File

@ -1,8 +1,6 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@ -23,6 +21,8 @@ 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.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -32,6 +32,7 @@ import stirling.software.common.util.WebResponseUtils;
public class RepairController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/repair")
@Operation(
@ -43,25 +44,25 @@ public class RepairController {
public ResponseEntity<byte[]> 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 TempFile with try-with-resources for automatic cleanup
try (TempFile tempFile = new TempFile(tempFileManager, ".pdf")) {
// Save the uploaded file to the temporary location
inputFile.transferTo(tempFile.getFile());
List<String> 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 +70,6 @@ public class RepairController {
.replaceFirst("[.][^.]+$", "")
+ "_repaired.pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
} finally {
// Clean up the temporary files
Files.deleteIfExists(tempInputFile);
}
}
}

View File

@ -6,7 +6,6 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.List;
import javax.imageio.ImageIO;
@ -40,6 +39,8 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.misc.AddStampRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -49,6 +50,7 @@ import stirling.software.common.util.WebResponseUtils;
public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/add-stamp")
@Operation(
@ -179,6 +181,9 @@ public class StampController {
case "chinese":
resourceDir = "static/fonts/SimSun.ttf";
break;
case "thai":
resourceDir = "static/fonts/NotoSansThai-Regular.ttf";
break;
case "roman":
default:
resourceDir = "static/fonts/NotoSans-Regular.ttf";
@ -188,14 +193,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();
// Use TempFile with try-with-resources for automatic cleanup
try (TempFile tempFileWrapper = new 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);
} finally {
if (tempFile != null) {
Files.deleteIfExists(tempFile.toPath());
}
}
}

View File

@ -170,6 +170,9 @@ public class WatermarkController {
case "chinese":
resourceDir = "static/fonts/SimSun.ttf";
break;
case "thai":
resourceDir = "static/fonts/NotoSansThai-Regular.ttf";
break;
case "roman":
default:
resourceDir = "static/fonts/NotoSans-Regular.ttf";

View File

@ -120,8 +120,8 @@ public class ApiDocService {
ObjectMapper mapper = new ObjectMapper();
apiDocsJsonRootNode = mapper.readTree(apiDocsJson);
JsonNode paths = apiDocsJsonRootNode.path("paths");
paths.fields()
.forEachRemaining(
paths.propertyStream()
.forEach(
entry -> {
String path = entry.getKey();
JsonNode pathNode = entry.getValue();

View File

@ -45,3 +45,6 @@ posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
posthog.host=https://eu.i.posthog.com
spring.main.allow-bean-definition-overriding=true
# Set up a consistent temporary directory location
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}

File diff suppressed because it is too large Load Diff

View File

@ -125,6 +125,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".
tempFileManagement:
baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf
libreofficeDir: '' # Defaults to tempFileManagement.baseTmpDir/libreoffice
systemTempDir: '' # Only used if cleanupSystemTemp is true
prefix: stirling-pdf- # Prefix for temp file names
maxAgeHours: 24 # Maximum age in hours before temp files are cleaned up
cleanupIntervalMinutes: 30 # How often to run cleanup (in minutes)
startupCleanup: true # Clean up old temp files on startup
cleanupSystemTemp: false # Whether to clean broader system temp directory
ui:
appName: '' # application's visible name

View File

@ -88,6 +88,7 @@
<option value="japanese">日本語</option>
<option value="korean">한국어</option>
<option value="chinese">简体中文</option>
<option value="thai">ไทย</option>
</select>
</div>

View File

@ -38,6 +38,7 @@
<option value="japanese">日本語</option>
<option value="korean">한국어</option>
<option value="chinese">简体中文</option>
<option value="thai">ไทย</option>
</select>
</div>
<div id="watermarkTextGroup" class="mb-3">

View File

@ -55,10 +55,12 @@ capture_file_list() {
-not -path '/config/*' \
-not -path '/logs/*' \
-not -path '*/home/stirlingpdfuser/.config/libreoffice/*' \
-not -path '*/tmp/PDFBox*' \
-not -path '*/home/stirlingpdfuser/.pdfbox.cache' \
-not -path '*/tmp/stirling-pdf/PDFBox*' \
-not -path '*/tmp/stirling-pdf/hsperfdata_stirlingpdfuser/*' \
-not -path '*/tmp/hsperfdata_stirlingpdfuser/*' \
-not -path '*/tmp/lu*' \
-not -path '*/tmp/tmp*' \
-not -path '*/tmp/stirling-pdf/lu*' \
-not -path '*/tmp/stirling-pdf/tmp*' \
2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file"
# Check if the output file has content
@ -74,8 +76,10 @@ capture_file_list() {
-not -path '/config/*' \
-not -path '/logs/*' \
-not -path '*/home/stirlingpdfuser/.config/libreoffice/*' \
-not -path '*/home/stirlingpdfuser/.pdfbox.cache' \
-not -path '*/tmp/PDFBox*' \
-not -path '*/tmp/hsperfdata_stirlingpdfuser/*' \
-not -path '*/tmp/stirling-pdf/hsperfdata_stirlingpdfuser/*' \
-not -path '*/tmp/lu*' \
-not -path '*/tmp/tmp*' \
2>/dev/null | sort" > "$output_file"