Merge branch 'main' into V2

This commit is contained in:
Anthony Stirling
2025-07-16 21:25:37 +01:00
1172 changed files with 3359 additions and 1254 deletions
+172
View File
@@ -0,0 +1,172 @@
{
"allowedLicenses": [
{
"moduleName": ".*",
"moduleLicense": "BSD License"
},
{
"moduleName": ".*",
"moduleLicense": "The BSD License"
},
{
"moduleName": ".*",
"moduleLicense": "BSD-2-Clause"
},
{
"moduleName": ".*",
"moduleLicense": "BSD 2-Clause License"
},
{
"moduleName": ".*",
"moduleLicense": "The 2-Clause BSD License"
},
{
"moduleName": ".*",
"moduleLicense": "BSD-3-Clause"
},
{
"moduleName": ".*",
"moduleLicense": "The BSD 3-Clause License (BSD3)"
},
{
"moduleName": ".*",
"moduleLicense": "BSD-4 License"
},
{
"moduleName": ".*",
"moduleLicense": "MIT"
},
{
"moduleName": ".*",
"moduleLicense": "MIT License"
},
{
"moduleName": ".*",
"moduleLicense": "The MIT License"
},
{
"moduleName": "com.github.jai-imageio:jai-imageio-core",
"moduleLicense": "LICENSE.txt"
},
{
"moduleName": "com.github.jai-imageio:jai-imageio-jpeg2000",
"moduleLicense": "LICENSE-JJ2000.txt, LICENSE-Sun.txt"
},
{
"moduleName": ".*",
"moduleLicense": "Apache 2"
},
{
"moduleName": ".*",
"moduleLicense": "Apache 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache-2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache-2.0 License"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "The Apache License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "The Apache Software License, Version 2.0"
},
{
"moduleName": "com.nimbusds:oauth2-oidc-sdk",
"moduleLicense": "\"Apache License, version 2.0\";link=\"https://www.apache.org/licenses/LICENSE-2.0.html\""
},
{
"moduleName": ".*",
"moduleLicense": "MPL 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "UnboundID SCIM2 SDK Free Use License"
},
{
"moduleName": ".*",
"moduleLicense": "GPL2 w/ CPE"
},
{
"moduleName": ".*",
"moduleLicense": "GPLv2+CE"
},
{
"moduleName": ".*",
"moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception"
},
{
"moduleName": "com.martiansoftware:jsap",
"moduleLicense": "LGPL"
},
{
"moduleName": "org.hibernate.orm:hibernate-core",
"moduleLicense": "GNU Library General Public License v2.1 or later"
},
{
"moduleName": ".*",
"moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License 1.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License - v 1.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License v2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License v. 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License - v 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License - Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Ubuntu Font Licence 1.0"
},
{
"moduleName": ".*",
"moduleLicense": "Bouncy Castle Licence"
},
{
"moduleName": ".*",
"moduleLicense": "Public Domain, per Creative Commons CC0"
},
{
"moduleName": ".*",
"moduleLicense": "The W3C License"
}
]
}
+196
View File
@@ -0,0 +1,196 @@
### Eclipse ###
.metadata
bin/
tmp/
*.tmp
*.bak
*.exe
*.swp
*~.nib
local.properties
.settings/
.loadpath
.recommenders
.classpath
.project
version.properties
#### Stirling-PDF Files ###
pipeline/watchedFolders/
pipeline/finishedFolders/
customFiles/
configs/
watchedFolders/
clientWebUI/
!cucumber/
!cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/
/testing/file_snapshots
SwaggerDoc.json
# Gradle
.gradle
.lock
# External tool builders
.externalToolBuilders/
# Locally stored "Eclipse launch configurations"
*.launch
# PyDev specific (Python IDE for Eclipse)
*.pydevproject
# CDT-specific (C/C++ Development Tooling)
.cproject
# CDT- autotools
.autotools
# Java annotation processor (APT)
.factorypath
# PDT-specific (PHP Development Tools)
.buildpath
# sbteclipse plugin
.target
# Tern plugin
.tern-project
# TeXlipse plugin
.texlipse
# STS (Spring Tool Suite)
.springBeans
# Code Recommenders
.recommenders/
# Annotation Processing
.apt_generated/
.apt_generated_test/
# Scala IDE specific (Scala & Java development for Eclipse)
.cache-main
.scala_dependencies
.worksheet
# Uncomment this line if you wish to ignore the project description file.
# Typically, this file would be tracked if it contains build/dependency configurations:
#.project
### Eclipse Patch ###
# Spring Boot Tooling
.sts4-cache/
### Git ###
# Created by git for backups. To disable backups in Git:
# $ git config --global mergetool.keepBackup false
*.orig
# Created by git when using merge tools for conflicts
*.BACKUP.*
*.BASE.*
*.LOCAL.*
*.REMOTE.*
*_BACKUP_*.txt
*_BASE_*.txt
*_LOCAL_*.txt
*_REMOTE_*.txt
### Java ###
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
*.db
/build
/app/common/build/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*.pyo
# Virtual environments
.env*
.venv*
env*/
venv*/
ENV/
env.bak/
venv.bak/
# VS Code
/.vscode/**/*
!/.vscode/settings.json
!/.vscode/extensions.json
# IntelliJ IDEA
.idea/
*.iml
out/
# Ignore Mac DS_Store files
.DS_Store
**/.DS_Store
# cucumber
/cucumber/reports/**
# Certs and Security Files
*.p12
*.pk8
*.pem
*.crt
*.cer
*.cert
*.der
*.key
*.csr
*.kdbx
*.jks
*.asc
# SSH Keys
*.pub
*.priv
id_rsa
id_rsa.pub
id_ecdsa
id_ecdsa.pub
id_ed25519
id_ed25519.pub
.ssh/
*ssh
# cache
.cache
.ruff_cache
.mypy_cache
.pytest_cache
.ipynb_checkpoints
**/jcef-bundle/
# node_modules
node_modules/
+33
View File
@@ -0,0 +1,33 @@
// Configure bootRun to disable it or point to a main class
bootRun {
enabled = false
}
spotless {
java {
target sourceSets.main.allJava
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
}
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-aop'
api 'org.springframework.boot:spring-boot-starter-thymeleaf'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.18.0'
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.9'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9"
api 'jakarta.mail:jakarta.mail-api:2.1.3'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.3'
}
@@ -0,0 +1,39 @@
package org.apache.pdfbox.examples.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import lombok.extern.slf4j.Slf4j;
/** A custom RandomAccessRead implementation that deletes the file when closed */
@Slf4j
public class DeletingRandomAccessFile extends RandomAccessReadBufferedFile {
private final Path tempFilePath;
public DeletingRandomAccessFile(File file) throws IOException {
super(file);
this.tempFilePath = file.toPath();
}
@Override
public void close() throws IOException {
try {
super.close();
} finally {
try {
boolean deleted = Files.deleteIfExists(tempFilePath);
if (deleted) {
log.info("Successfully deleted temp file: {}", tempFilePath);
} else {
log.warn("Failed to delete temp file (may not exist): {}", tempFilePath);
}
} catch (IOException e) {
log.error("Error deleting temp file: {}", tempFilePath, e);
}
}
}
}
@@ -0,0 +1,78 @@
package stirling.software.common.annotations;
import java.lang.annotation.*;
import org.springframework.core.annotation.AliasFor;
import org.springframework.web.bind.annotation.RequestMapping;
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>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)
@Documented
@RequestMapping(method = RequestMethod.POST)
public @interface AutoJobPostMapping {
/** 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}. */
@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}.
*/
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}.
*/
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}.
*/
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}.
*/
boolean queueable() default false;
/**
* Relative resource weight (1100) used by the scheduler to prioritise / throttle jobs. Values
* below 1 are clamped to&nbsp;1, values above 100 to&nbsp;100.
*/
int resourceWeight() default 50;
}
@@ -0,0 +1,289 @@
package stirling.software.common.aop;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileOrUploadService;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Order(0) // Highest precedence - executes before audit aspects
public class AutoJobAspect {
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
private final JobExecutorService jobExecutorService;
private final HttpServletRequest request;
private final FileOrUploadService fileOrUploadService;
private final FileStorage fileStorage;
@Around("@annotation(autoJobPostMapping)")
public Object wrapWithJobExecution(
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
// This aspect will run before any audit aspects due to @Order(0)
// Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async"));
log.debug(
"AutoJobAspect: Processing {} {} with async={}",
request.getMethod(),
request.getRequestURI(),
async);
long timeout = autoJobPostMapping.timeout();
int retryCount = autoJobPostMapping.retryCount();
boolean trackProgress = autoJobPostMapping.trackProgress();
log.debug(
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}",
async,
timeout > 0 ? timeout : "default",
retryCount,
trackProgress);
// Process arguments in-place to avoid type mismatch issues
Object[] args = processArgsInPlace(joinPoint.getArgs(), async);
// Extract queueable and resourceWeight parameters and validate
boolean queueable = autoJobPostMapping.queueable();
int resourceWeight = Math.max(1, Math.min(100, autoJobPostMapping.resourceWeight()));
// Integrate with the JobExecutorService
if (retryCount <= 1) {
// No retries needed, simple execution
return jobExecutorService.runJobGeneric(
async,
() -> {
try {
// Note: Progress tracking is handled in TaskManager/JobExecutorService
// The trackProgress flag controls whether detailed progress is stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
throw new RuntimeException(ex);
}
},
timeout,
queueable,
resourceWeight);
} else {
// Use retry logic
return executeWithRetries(
joinPoint,
args,
async,
timeout,
retryCount,
trackProgress,
queueable,
resourceWeight);
}
}
private Object executeWithRetries(
ProceedingJoinPoint joinPoint,
Object[] args,
boolean async,
long timeout,
int maxRetries,
boolean trackProgress,
boolean queueable,
int resourceWeight) {
// Keep jobId reference for progress tracking in TaskManager
AtomicReference<String> jobIdRef = new AtomicReference<>();
return jobExecutorService.runJobGeneric(
async,
() -> {
// Use iterative approach instead of recursion to avoid stack overflow
Throwable lastException = null;
// Attempt counter starts at 1 for first try
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
try {
if (trackProgress && async) {
// Get jobId for progress tracking in TaskManager
// This enables REST API progress queries, not WebSocket
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Tracking progress for job {} (attempt {}/{})",
jobId,
currentAttempt,
maxRetries);
// Progress is tracked in TaskManager for REST API access
// No WebSocket notifications sent here
}
}
// Attempt to execute the operation
return joinPoint.proceed(args);
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution (attempt {}/{}): {}",
currentAttempt,
maxRetries,
ex.getMessage(),
ex);
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
if (trackProgress && async) {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API access
}
}
// Use non-blocking delay for all retry attempts to avoid blocking
// threads
// For sync jobs this avoids starving the tomcat thread pool under
// load
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
// Execute the retry after a delay through the JobExecutorService
// rather than blocking the current thread with sleep
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
// Use a delayed executor for non-blocking delay
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.execute(
() -> {
// Continue the retry loop in the next iteration
// We can't return from here directly since
// we're in a Runnable
delayedRetry.complete(null);
});
// Wait for the delay to complete before continuing
try {
delayedRetry.join();
} catch (Exception e) {
Thread.currentThread().interrupt();
break;
}
} else {
// No more retries, we'll throw the exception after the loop
break;
}
}
}
// If we get here, all retries failed
if (lastException != null) {
throw new RuntimeException(
"Job failed after "
+ maxRetries
+ " attempts: "
+ lastException.getMessage(),
lastException);
}
// This should never happen if lastException is properly tracked
throw new RuntimeException("Job failed but no exception was recorded");
},
timeout,
queueable,
resourceWeight);
}
/**
* Processes arguments in-place to handle file resolution and async file persistence. This
* approach avoids type mismatch issues by modifying the original objects directly.
*
* @param originalArgs The original arguments
* @param async Whether this is an async operation
* @return The original array with processed arguments
*/
private Object[] processArgsInPlace(Object[] originalArgs, boolean async) {
if (originalArgs == null || originalArgs.length == 0) {
return originalArgs;
}
// Process all arguments in-place
for (int i = 0; i < originalArgs.length; i++) {
Object arg = originalArgs[i];
if (arg instanceof PDFFile pdfFile) {
// Case 1: fileId is provided but no fileInput
if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) {
try {
log.debug("Using fileId {} to get file content", pdfFile.getFileId());
MultipartFile file = fileStorage.retrieveFile(pdfFile.getFileId());
pdfFile.setFileInput(file);
} catch (Exception e) {
throw new RuntimeException(
"Failed to resolve file by ID: " + pdfFile.getFileId(), e);
}
}
// Case 2: For async requests, we need to make a copy of the MultipartFile
else if (async && pdfFile.getFileInput() != null) {
try {
log.debug("Making persistent copy of uploaded file for async processing");
MultipartFile originalFile = pdfFile.getFileInput();
String fileId = fileStorage.storeFile(originalFile);
// Store the fileId for later reference
pdfFile.setFileId(fileId);
// Replace the original MultipartFile with our persistent copy
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
pdfFile.setFileInput(persistentFile);
log.debug("Created persistent file copy with fileId: {}", fileId);
} catch (IOException e) {
throw new RuntimeException(
"Failed to create persistent copy of uploaded file", e);
}
}
}
}
return originalArgs;
}
private String getJobIdFromContext() {
try {
return (String) request.getAttribute("jobId");
} catch (Exception e) {
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
return null;
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,82 @@
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.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;
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);
}
}
}
@@ -0,0 +1,297 @@
package stirling.software.common.configuration;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.function.Predicate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.Scope;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.ClassUtils;
import org.thymeleaf.spring6.SpringTemplateEngine;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Lazy
@Slf4j
@Configuration
@RequiredArgsConstructor
public class AppConfig {
private final Environment env;
private final ApplicationProperties applicationProperties;
@Getter
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@Getter
@Value("${server.servlet.context-path:/}")
private String contextPath;
@Getter
@Value("${server.port:8080}")
private String serverPort;
@Bean
@ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true")
public SpringTemplateEngine templateEngine(ResourceLoader resourceLoader) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.addTemplateResolver(new FileFallbackTemplateResolver(resourceLoader));
return templateEngine;
}
@Bean(name = "loginEnabled")
public boolean loginEnabled() {
return applicationProperties.getSecurity().getEnableLogin();
}
@Bean(name = "appName")
public String appName() {
String homeTitle = applicationProperties.getUi().getAppName();
return (homeTitle != null) ? homeTitle : "Stirling PDF";
}
@Bean(name = "appVersion")
public String appVersion() {
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
try {
props.load(resource.getInputStream());
return props.getProperty("version");
} catch (IOException e) {
log.error("exception", e);
}
return "0.0.0";
}
@Bean(name = "homeText")
public String homeText() {
return (applicationProperties.getUi().getHomeDescription() != null)
? applicationProperties.getUi().getHomeDescription()
: "null";
}
@Bean(name = "languages")
public List<String> languages() {
return applicationProperties.getUi().getLanguages();
}
@Bean
public String contextPath(@Value("${server.servlet.context-path}") String contextPath) {
return contextPath;
}
@Bean(name = "navBarText")
public String navBarText() {
String defaultNavBar =
applicationProperties.getUi().getAppNameNavbar() != null
? applicationProperties.getUi().getAppNameNavbar()
: applicationProperties.getUi().getAppName();
return (defaultNavBar != null) ? defaultNavBar : "Stirling PDF";
}
@Bean(name = "enableAlphaFunctionality")
public boolean enableAlphaFunctionality() {
return applicationProperties.getSystem().getEnableAlphaFunctionality() != null
? applicationProperties.getSystem().getEnableAlphaFunctionality()
: false;
}
@Bean(name = "rateLimit")
public boolean rateLimit() {
String rateLimit = System.getProperty("rateLimit");
if (rateLimit == null) rateLimit = System.getenv("rateLimit");
return (rateLimit != null) ? Boolean.valueOf(rateLimit) : false;
}
@Bean(name = "RunningInDocker")
public boolean runningInDocker() {
return Files.exists(Paths.get("/.dockerenv"));
}
@Bean(name = "configDirMounted")
public boolean isRunningInDockerWithConfig() {
Path dockerEnv = Paths.get("/.dockerenv");
// default to true if not docker
if (!Files.exists(dockerEnv)) {
return true;
}
Path mountInfo = Paths.get("/proc/1/mountinfo");
// this should always exist, if not some unknown usecase
if (!Files.exists(mountInfo)) {
return true;
}
try {
return Files.lines(mountInfo).anyMatch(line -> line.contains(" /configs "));
} catch (IOException e) {
return false;
}
}
@Bean(name = "activeSecurity")
public boolean missingActiveSecurity() {
return ClassUtils.isPresent(
"stirling.software.proprietary.security.configuration.SecurityConfiguration",
this.getClass().getClassLoader());
}
@Bean(name = "directoryFilter")
public Predicate<Path> processOnlyFiles() {
return path -> {
if (Files.isDirectory(path)) {
return !path.toString().contains("processing");
} else {
return true;
}
};
}
@Bean(name = "termsAndConditions")
public String termsAndConditions() {
return applicationProperties.getLegal().getTermsAndConditions();
}
@Bean(name = "privacyPolicy")
public String privacyPolicy() {
return applicationProperties.getLegal().getPrivacyPolicy();
}
@Bean(name = "cookiePolicy")
public String cookiePolicy() {
return applicationProperties.getLegal().getCookiePolicy();
}
@Bean(name = "impressum")
public String impressum() {
return applicationProperties.getLegal().getImpressum();
}
@Bean(name = "accessibilityStatement")
public String accessibilityStatement() {
return applicationProperties.getLegal().getAccessibilityStatement();
}
@Bean(name = "analyticsPrompt")
@Scope("request")
public boolean analyticsPrompt() {
return applicationProperties.getSystem().getEnableAnalytics() == null;
}
@Bean(name = "analyticsEnabled")
@Scope("request")
public boolean analyticsEnabled() {
if (applicationProperties.getPremium().isEnabled()) return true;
return applicationProperties.getSystem().isAnalyticsEnabled();
}
@Bean(name = "StirlingPDFLabel")
public String stirlingPDFLabel() {
return "Stirling-PDF" + " v" + appVersion();
}
@Bean(name = "UUID")
public String uuid() {
return applicationProperties.getAutomaticallyGenerated().getUUID();
}
@Bean
public ApplicationProperties.Security security() {
return applicationProperties.getSecurity();
}
@Bean
public ApplicationProperties.Security.OAUTH2 oAuth2() {
return applicationProperties.getSecurity().getOauth2();
}
@Bean
public ApplicationProperties.Premium premium() {
return applicationProperties.getPremium();
}
@Bean
public ApplicationProperties.System system() {
return applicationProperties.getSystem();
}
@Bean
public ApplicationProperties.Datasource datasource() {
return applicationProperties.getSystem().getDatasource();
}
@Bean(name = "runningProOrHigher")
@Profile("default")
public boolean runningProOrHigher() {
return false;
}
@Bean(name = "runningEE")
@Profile("default")
public boolean runningEnterprise() {
return false;
}
@Bean(name = "GoogleDriveEnabled")
@Profile("default")
public boolean googleDriveEnabled() {
return false;
}
@Bean(name = "license")
@Profile("default")
public String licenseType() {
return "NORMAL";
}
@Bean(name = "disablePixel")
public boolean disablePixel() {
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
}
@Bean(name = "machineType")
public String determineMachineType() {
try {
boolean isDocker = runningInDocker();
boolean isKubernetes = System.getenv("KUBERNETES_SERVICE_HOST") != null;
boolean isBrowserOpen = "true".equalsIgnoreCase(env.getProperty("BROWSER_OPEN"));
if (isKubernetes) {
return "Kubernetes";
} else if (isDocker) {
return "Docker";
} else if (isBrowserOpen) {
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
return "Client-windows";
} else if (os.contains("mac")) {
return "Client-mac";
} else {
return "Client-unix";
}
} else {
return "Server-jar";
}
} catch (Exception e) {
return "Unknown";
}
}
}
@@ -0,0 +1,126 @@
package stirling.software.common.configuration;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.YamlHelper;
/**
* A naive, line-based approach to merging "settings.yml" with "settings.yml.template" while
* preserving exact whitespace, blank lines, and inline comments -- but we only rewrite the file if
* the merged content actually differs.
*/
@Slf4j
public class ConfigInitializer {
public void ensureConfigExists() throws IOException, URISyntaxException {
// 1) If settings file doesn't exist, create from template
Path destPath = Paths.get(InstallationPathConfig.getSettingsPath());
if (Files.notExists(destPath)) {
Files.createDirectories(destPath.getParent());
try (InputStream in =
getClass().getClassLoader().getResourceAsStream("settings.yml.template")) {
if (in == null) {
throw new FileNotFoundException(
"Resource file not found: settings.yml.template");
}
Files.copy(in, destPath);
}
log.info("Created settings file from template");
} else {
// 2) Merge existing file with the template
URL templateResource = getClass().getClassLoader().getResource("settings.yml.template");
if (templateResource == null) {
throw new IOException("Resource not found: settings.yml.template");
}
// Copy template to a temp location so we can read lines
Path tempTemplatePath = Files.createTempFile("settings.yml", ".template");
try (InputStream in = templateResource.openStream()) {
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
}
// Copy setting.yaml to a temp location so we can read lines
Path settingTempPath = Files.createTempFile("settings", ".yaml");
try (InputStream in = Files.newInputStream(destPath)) {
Files.copy(in, settingTempPath, StandardCopyOption.REPLACE_EXISTING);
}
YamlHelper settingsTemplateFile = new YamlHelper(tempTemplatePath);
YamlHelper settingsFile = new YamlHelper(settingTempPath);
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
boolean changesMade =
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
if (changesMade) {
settingsTemplateFile.save(destPath);
log.info("Settings file updated based on template changes.");
} else {
log.info("No changes detected; settings file left as-is.");
}
Files.deleteIfExists(tempTemplatePath);
Files.deleteIfExists(settingTempPath);
}
// 3) Ensure custom settings file exists
Path customSettingsPath = Paths.get(InstallationPathConfig.getCustomSettingsPath());
if (Files.notExists(customSettingsPath)) {
Files.createFile(customSettingsPath);
log.info("Created custom_settings file: {}", customSettingsPath);
}
}
// TODO: Remove post migration
private void migrateEnterpriseEditionToPremium(YamlHelper yaml, YamlHelper template) {
if (yaml.getValueByExactKeyPath("enterpriseEdition", "enabled") != null) {
template.updateValue(
List.of("premium", "enabled"),
yaml.getValueByExactKeyPath("enterpriseEdition", "enabled"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "key") != null) {
template.updateValue(
List.of("premium", "key"),
yaml.getValueByExactKeyPath("enterpriseEdition", "key"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin") != null) {
template.updateValue(
List.of("premium", "proFeatures", "SSOAutoLogin"),
yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "autoUpdateMetadata")
!= null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "autoUpdateMetadata"),
yaml.getValueByExactKeyPath(
"enterpriseEdition", "CustomMetadata", "autoUpdateMetadata"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author") != null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "author"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator") != null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "creator"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer")
!= null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "producer"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer"));
}
}
}
@@ -0,0 +1,59 @@
package stirling.software.common.configuration;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.templateresolver.AbstractConfigurableTemplateResolver;
import org.thymeleaf.templateresource.FileTemplateResource;
import org.thymeleaf.templateresource.ITemplateResource;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.InputStreamTemplateResource;
@Slf4j
public class FileFallbackTemplateResolver extends AbstractConfigurableTemplateResolver {
private final ResourceLoader resourceLoader;
public FileFallbackTemplateResolver(ResourceLoader resourceLoader) {
super();
this.resourceLoader = resourceLoader;
setSuffix(".html");
}
// Note this does not work in local IDE, Prod jar only.
@Override
protected ITemplateResource computeTemplateResource(
IEngineConfiguration configuration,
String ownerTemplate,
String template,
String resourceName,
String characterEncoding,
Map<String, Object> templateResolutionAttributes) {
Resource resource =
resourceLoader.getResource(
"file:" + InstallationPathConfig.getTemplatesPath() + resourceName);
try {
if (resource.exists() && resource.isReadable()) {
return new FileTemplateResource(resource.getFile().getPath(), characterEncoding);
}
} catch (IOException e) {
// Log the exception to help with debugging issues loading external templates
log.warn("Unable to read template '{}' from file system", resourceName, e);
}
InputStream inputStream =
Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream("templates/" + resourceName);
if (inputStream != null) {
return new InputStreamTemplateResource(inputStream, "UTF-8");
}
return null;
}
}
@@ -0,0 +1,111 @@
package stirling.software.common.configuration;
import java.io.File;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InstallationPathConfig {
private static final String BASE_PATH;
// Root paths
private static final String LOG_PATH;
private static final String CONFIG_PATH;
private static final String CUSTOM_FILES_PATH;
private static final String CLIENT_WEBUI_PATH;
// Config paths
private static final String SETTINGS_PATH;
private static final String CUSTOM_SETTINGS_PATH;
// Custom file paths
private static final String STATIC_PATH;
private static final String TEMPLATES_PATH;
private static final String SIGNATURES_PATH;
static {
BASE_PATH = initializeBasePath();
// Initialize root paths
LOG_PATH = BASE_PATH + "logs" + File.separator;
CONFIG_PATH = BASE_PATH + "configs" + File.separator;
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
// Initialize config paths
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
CUSTOM_SETTINGS_PATH = CONFIG_PATH + "custom_settings.yml";
// Initialize custom file paths
STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator;
TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator;
SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator;
}
private static String initializeBasePath() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
return Paths.get(
System.getenv("APPDATA"), // parent path
"Stirling-PDF")
+ File.separator;
} else if (os.contains("mac")) {
return Paths.get(
System.getProperty("user.home"),
"Library",
"Application Support",
"Stirling-PDF")
+ File.separator;
} else {
return Paths.get(
System.getProperty("user.home"), // parent path
".config",
"Stirling-PDF")
+ File.separator;
}
}
return "." + File.separator;
}
public static String getPath() {
return BASE_PATH;
}
public static String getLogPath() {
return LOG_PATH;
}
public static String getConfigPath() {
return CONFIG_PATH;
}
public static String getCustomFilesPath() {
return CUSTOM_FILES_PATH;
}
public static String getClientWebUIPath() {
return CLIENT_WEBUI_PATH;
}
public static String getSettingsPath() {
return SETTINGS_PATH;
}
public static String getCustomSettingsPath() {
return CUSTOM_SETTINGS_PATH;
}
public static String getStaticPath() {
return STATIC_PATH;
}
public static String getTemplatesPath() {
return TEMPLATES_PATH;
}
public static String getSignaturesPath() {
return SIGNATURES_PATH;
}
}
@@ -0,0 +1,41 @@
package stirling.software.common.configuration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.posthog.java.PostHog;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
@Configuration
@Slf4j
public class PostHogConfig {
@Value("${posthog.api.key}")
private String posthogApiKey;
@Value("${posthog.host}")
private String posthogHost;
private PostHog postHogClient;
@Bean
public PostHog postHogClient() {
postHogClient =
new PostHog.Builder(posthogApiKey)
.host(posthogHost)
.logger(new PostHogLoggerImpl())
.build();
return postHogClient;
}
@PreDestroy
public void shutdownPostHog() {
if (postHogClient != null) {
postHogClient.shutdown();
}
}
}
@@ -0,0 +1,42 @@
package stirling.software.common.configuration;
import org.springframework.stereotype.Component;
import com.posthog.java.PostHogLogger;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class PostHogLoggerImpl implements PostHogLogger {
@Override
public void debug(String message) {
log.debug(message);
}
@Override
public void info(String message) {
log.info(message);
}
@Override
public void warn(String message) {
log.warn(message);
}
@Override
public void error(String message) {
log.error(message);
}
@Override
public void error(String message, Throwable throwable) {
if (message.contains("Error sending events to PostHog")) {
log.warn(
"Error sending metrics, Likely caused by no internet connection. Non Blocking");
} else {
log.error(message, throwable);
}
}
}
@@ -0,0 +1,79 @@
package stirling.software.common.configuration;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Configuration;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
@Slf4j
@Configuration
@Getter
public class RuntimePathConfig {
private final ApplicationProperties properties;
private final String basePath;
private final String weasyPrintPath;
private final String unoConvertPath;
// Pipeline paths
private final String pipelineWatchedFoldersPath;
private final String pipelineFinishedFoldersPath;
private final String pipelineDefaultWebUiConfigs;
private final String pipelinePath;
public RuntimePathConfig(ApplicationProperties properties) {
this.properties = properties;
this.basePath = InstallationPathConfig.getPath();
this.pipelinePath = Path.of(basePath, "pipeline").toString();
String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString();
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
this.pipelineWatchedFoldersPath =
resolvePath(
defaultWatchedFolders,
pipeline != null ? pipeline.getWatchedFoldersDir() : null);
this.pipelineFinishedFoldersPath =
resolvePath(
defaultFinishedFolders,
pipeline != null ? pipeline.getFinishedFoldersDir() : null);
this.pipelineDefaultWebUiConfigs =
resolvePath(
defaultWebUIConfigs,
pipeline != null ? pipeline.getWebUIConfigsDir() : null);
boolean isDocker = isRunningInDocker();
// Initialize Operation paths
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
Operations operations = properties.getSystem().getCustomPaths().getOperations();
this.weasyPrintPath =
resolvePath(
defaultWeasyPrintPath,
operations != null ? operations.getWeasyprint() : null);
this.unoConvertPath =
resolvePath(
defaultUnoConvertPath,
operations != null ? operations.getUnoconvert() : null);
}
private String resolvePath(String defaultPath, String customPath) {
return StringUtils.isNotBlank(customPath) ? customPath : defaultPath;
}
private boolean isRunningInDocker() {
return Files.exists(Path.of("/.dockerenv"));
}
}
@@ -0,0 +1,22 @@
package stirling.software.common.configuration;
import java.util.Properties;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertySourceFactory;
public class YamlPropertySourceFactory implements PropertySourceFactory {
@Override
public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource) {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
factory.setResources(encodedResource.getResource());
Properties properties = factory.getObject();
return new PropertiesPropertySource(
encodedResource.getResource().getFilename(), properties);
}
}
@@ -0,0 +1,7 @@
package stirling.software.common.configuration.interfaces;
public interface ShowAdminInterface {
default boolean getShowUpdateOnlyAdmins() {
return true;
}
}
@@ -0,0 +1,646 @@
package stirling.software.common.model;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.stereotype.Component;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.model.oauth2.Provider;
import stirling.software.common.util.ValidationUtils;
@Data
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@ConfigurationProperties(prefix = "")
public class ApplicationProperties {
private Legal legal = new Legal();
private Security security = new Security();
private System system = new System();
private Ui ui = new Ui();
private Endpoints endpoints = new Endpoints();
private Metrics metrics = new Metrics();
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail();
private Premium premium = new Premium();
private EnterpriseEdition enterpriseEdition = new EnterpriseEdition();
private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
throws IOException {
String configPath = InstallationPathConfig.getSettingsPath();
log.debug("Attempting to load settings from: " + configPath);
File file = new File(configPath);
if (!file.exists()) {
log.error("Warning: Settings file does not exist at: " + configPath);
}
Resource resource = new FileSystemResource(configPath);
if (!resource.exists()) {
throw new FileNotFoundException("Settings file not found at: " + configPath);
}
EncodedResource encodedResource = new EncodedResource(resource);
PropertySource<?> propertySource =
new YamlPropertySourceFactory().createPropertySource(null, encodedResource);
environment.getPropertySources().addFirst(propertySource);
log.debug("Loaded properties: " + propertySource.getSource());
return propertySource;
}
@Data
public static class AutoPipeline {
private String outputFolder;
}
@Data
public static class Legal {
private String termsAndConditions;
private String privacyPolicy;
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
}
@Data
public static class Security {
private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();
private int loginAttemptCount;
private long loginResetTimeMinutes;
private String loginMethod = "all";
private String customGlobalAPIKey;
public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled();
}
public enum LoginMethods {
ALL("all"),
NORMAL("normal"),
OAUTH2("oauth2"),
SAML2("saml2");
private String method;
LoginMethods(String method) {
this.method = method;
}
@Override
public String toString() {
return method;
}
}
public boolean isUserPass() {
return (loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())
|| loginMethod.equalsIgnoreCase(LoginMethods.ALL.toString()));
}
public boolean isOauth2Active() {
return (oauth2 != null
&& oauth2.getEnabled()
&& !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString()));
}
public boolean isSaml2Active() {
return (saml2 != null
&& saml2.getEnabled()
&& !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString()));
}
@Data
public static class InitialLogin {
private String username;
@ToString.Exclude private String password;
}
@Getter
@Setter
@ToString
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@ToString.Exclude private String idpMetadataUri;
private String idpSingleLogoutUrl;
private String idpSingleLoginUrl;
private String idpIssuer;
private String idpCert;
@ToString.Exclude private String privateKey;
@ToString.Exclude private String spCert;
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath".length()))
.getInputStream();
}
try {
URI uri = new URI(idpMetadataUri);
URL url = uri.toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
return connection.getInputStream();
} catch (URISyntaxException e) {
throw new IOException("Invalid URI format: " + idpMetadataUri, e);
}
}
public Resource getSpCert() {
if (spCert == null) return null;
if (spCert.startsWith("classpath:")) {
return new ClassPathResource(spCert.substring("classpath:".length()));
} else {
return new FileSystemResource(spCert);
}
}
public Resource getIdpCert() {
if (idpCert == null) return null;
if (idpCert.startsWith("classpath:")) {
return new ClassPathResource(idpCert.substring("classpath:".length()));
} else {
return new FileSystemResource(idpCert);
}
}
public Resource getPrivateKey() {
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
return new FileSystemResource(privateKey);
}
}
}
@Data
public static class OAUTH2 {
private Boolean enabled = false;
private String issuer;
private String clientId;
@ToString.Exclude private String clientSecret;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String useAsUsername;
private Collection<String> scopes = new ArrayList<>();
private String provider;
private Client client = new Client();
public void setScopes(String scopes) {
List<String> scopesList =
Arrays.stream(scopes.split(",")).map(String::trim).toList();
this.scopes.addAll(scopesList);
}
protected boolean isValid(String value, String name) {
return value != null && !value.trim().isEmpty();
}
protected boolean isValid(Collection<String> value, String name) {
return value != null && !value.isEmpty();
}
public boolean isSettingsValid() {
return !ValidationUtils.isStringEmpty(this.getIssuer())
&& !ValidationUtils.isStringEmpty(this.getClientId())
&& !ValidationUtils.isStringEmpty(this.getClientSecret())
&& !ValidationUtils.isCollectionEmpty(this.getScopes())
&& !ValidationUtils.isStringEmpty(this.getUseAsUsername());
}
@Data
public static class Client {
private GoogleProvider google = new GoogleProvider();
private GitHubProvider github = new GitHubProvider();
private KeycloakProvider keycloak = new KeycloakProvider();
public Provider get(String registrationId) throws UnsupportedProviderException {
return switch (registrationId.toLowerCase()) {
case "google" -> getGoogle();
case "github" -> getGithub();
case "keycloak" -> getKeycloak();
default ->
throw new UnsupportedProviderException(
"Logout from the provider "
+ registrationId
+ " is not supported. "
+ "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues");
};
}
}
}
}
@Data
public static class System {
private String defaultLocale;
private Boolean googlevisibility;
private boolean showUpdate;
private Boolean showUpdateOnlyAdmin;
private boolean customHTMLFiles;
private String tessdataDir;
private Boolean enableAlphaFunctionality;
private Boolean enableAnalytics;
private Datasource datasource;
private Boolean disableSanitize;
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();
}
}
@Data
public static class CustomPaths {
private Pipeline pipeline = new Pipeline();
private Operations operations = new Operations();
@Data
public static class Pipeline {
private String watchedFoldersDir;
private String finishedFoldersDir;
private String webUIConfigsDir;
}
@Data
public static class Operations {
private String weasyprint;
private String unoconvert;
}
}
@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;
private String customDatabaseUrl;
private String type;
private String hostName;
private Integer port;
private String name;
private String username;
@ToString.Exclude private String password;
}
public enum Driver {
H2("h2"),
POSTGRESQL("postgresql"),
ORACLE("oracle"),
MYSQL("mysql");
private final String driverName;
Driver(String driverName) {
this.driverName = driverName;
}
@Override
public String toString() {
return """
Driver {
driverName='%s'
}
"""
.formatted(driverName);
}
}
@Data
public static class Ui {
private String appName;
private String homeDescription;
private String appNameNavbar;
private List<String> languages;
public String getAppName() {
return appName != null && appName.trim().length() > 0 ? appName : null;
}
public String getHomeDescription() {
return homeDescription != null && homeDescription.trim().length() > 0
? homeDescription
: null;
}
public String getAppNameNavbar() {
return appNameNavbar != null && appNameNavbar.trim().length() > 0
? appNameNavbar
: null;
}
}
@Data
public static class Endpoints {
private List<String> toRemove;
private List<String> groupsToRemove;
}
@Data
public static class Metrics {
private Boolean enabled;
}
@Data
public static class AutomaticallyGenerated {
@ToString.Exclude private String key;
private String UUID;
private String appVersion;
}
// TODO: Remove post migration
@Data
@Deprecated(since = "0.45.0")
public static class EnterpriseEdition {
private boolean enabled;
@ToString.Exclude private String key;
private int maxUsers;
private boolean ssoAutoLogin;
private CustomMetadata customMetadata = new CustomMetadata();
@Data
public static class CustomMetadata {
private boolean autoUpdateMetadata;
private String author;
private String creator;
private String producer;
public String getCreator() {
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
}
public String getProducer() {
return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer;
}
}
}
@Data
public static class Mail {
private boolean enabled;
private String host;
private int port;
private String username;
@ToString.Exclude private String password;
private String from;
}
@Data
public static class Premium {
private boolean enabled;
@ToString.Exclude private String key;
private int maxUsers;
private ProFeatures proFeatures = new ProFeatures();
private EnterpriseFeatures enterpriseFeatures = new EnterpriseFeatures();
@Data
public static class ProFeatures {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class CustomMetadata {
private boolean autoUpdateMetadata;
private String author;
private String creator;
private String producer;
public String getCreator() {
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
}
public String getProducer() {
return producer == null || producer.trim().isEmpty()
? "Stirling-PDF"
: producer;
}
}
@Data
public static class GoogleDrive {
private boolean enabled;
private String clientId;
private String apiKey;
private String appId;
public String getClientId() {
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
}
public String getApiKey() {
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
}
public String getAppId() {
return appId == null || appId.trim().isEmpty() ? "" : appId;
}
}
}
@Data
public static class EnterpriseFeatures {
private PersistentMetrics persistentMetrics = new PersistentMetrics();
private Audit audit = new Audit();
@Data
public static class Audit {
private boolean enabled = true;
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
private int retentionDays = 90;
}
@Data
public static class PersistentMetrics {
private boolean enabled;
private int retentionDays;
}
}
}
@Data
public static class ProcessExecutor {
private SessionLimit sessionLimit = new SessionLimit();
private TimeoutMinutes timeoutMinutes = new TimeoutMinutes();
@Data
public static class SessionLimit {
private int libreOfficeSessionLimit;
private int pdfToHtmlSessionLimit;
private int pythonOpenCvSessionLimit;
private int weasyPrintSessionLimit;
private int installAppSessionLimit;
private int calibreSessionLimit;
private int qpdfSessionLimit;
private int tesseractSessionLimit;
private int ghostscriptSessionLimit;
private int ocrMyPdfSessionLimit;
public int getQpdfSessionLimit() {
return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2;
}
public int getTesseractSessionLimit() {
return tesseractSessionLimit > 0 ? tesseractSessionLimit : 1;
}
public int getLibreOfficeSessionLimit() {
return libreOfficeSessionLimit > 0 ? libreOfficeSessionLimit : 1;
}
public int getPdfToHtmlSessionLimit() {
return pdfToHtmlSessionLimit > 0 ? pdfToHtmlSessionLimit : 1;
}
public int getPythonOpenCvSessionLimit() {
return pythonOpenCvSessionLimit > 0 ? pythonOpenCvSessionLimit : 8;
}
public int getWeasyPrintSessionLimit() {
return weasyPrintSessionLimit > 0 ? weasyPrintSessionLimit : 16;
}
public int getInstallAppSessionLimit() {
return installAppSessionLimit > 0 ? installAppSessionLimit : 1;
}
public int getCalibreSessionLimit() {
return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
}
public int getGhostscriptSessionLimit() {
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
}
public int getOcrMyPdfSessionLimit() {
return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2;
}
}
@Data
public static class TimeoutMinutes {
private long libreOfficeTimeoutMinutes;
private long pdfToHtmlTimeoutMinutes;
private long pythonOpenCvTimeoutMinutes;
private long weasyPrintTimeoutMinutes;
private long installAppTimeoutMinutes;
private long calibreTimeoutMinutes;
private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;
private long ocrMyPdfTimeoutMinutes;
public long getTesseractTimeoutMinutes() {
return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30;
}
public long getQpdfTimeoutMinutes() {
return qpdfTimeoutMinutes > 0 ? qpdfTimeoutMinutes : 30;
}
public long getLibreOfficeTimeoutMinutes() {
return libreOfficeTimeoutMinutes > 0 ? libreOfficeTimeoutMinutes : 30;
}
public long getPdfToHtmlTimeoutMinutes() {
return pdfToHtmlTimeoutMinutes > 0 ? pdfToHtmlTimeoutMinutes : 20;
}
public long getPythonOpenCvTimeoutMinutes() {
return pythonOpenCvTimeoutMinutes > 0 ? pythonOpenCvTimeoutMinutes : 30;
}
public long getWeasyPrintTimeoutMinutes() {
return weasyPrintTimeoutMinutes > 0 ? weasyPrintTimeoutMinutes : 30;
}
public long getInstallAppTimeoutMinutes() {
return installAppTimeoutMinutes > 0 ? installAppTimeoutMinutes : 60;
}
public long getCalibreTimeoutMinutes() {
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
}
public long getGhostscriptTimeoutMinutes() {
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
}
public long getOcrMyPdfTimeoutMinutes() {
return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30;
}
}
}
}
@@ -0,0 +1,50 @@
package stirling.software.common.model;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import lombok.AllArgsConstructor;
import lombok.Data;
@AllArgsConstructor
@Data
public class FileInfo {
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private String fileName;
private String filePath;
private LocalDateTime modificationDate;
private long fileSize;
private LocalDateTime creationDate;
// Converts the file path string to a Path object.
public Path getFilePathAsPath() {
return Paths.get(filePath);
}
// Formats the file size into a human-readable string.
public String getFormattedFileSize() {
if (fileSize >= 1024 * 1024 * 1024) {
return String.format(Locale.US, "%.2f GB", fileSize / (1024.0 * 1024 * 1024));
} else if (fileSize >= 1024 * 1024) {
return String.format(Locale.US, "%.2f MB", fileSize / (1024.0 * 1024));
} else if (fileSize >= 1024) {
return String.format(Locale.US, "%.2f KB", fileSize / 1024.0);
} else {
return String.format("%d Bytes", fileSize);
}
}
// Formats the modification date to a string.
public String getFormattedModificationDate() {
return modificationDate.format(DATE_FORMATTER);
}
// Formats the creation date to a string.
public String getFormattedCreationDate() {
return creationDate.format(DATE_FORMATTER);
}
}
@@ -0,0 +1,44 @@
package stirling.software.common.model;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import org.thymeleaf.templateresource.ITemplateResource;
public class InputStreamTemplateResource implements ITemplateResource {
private InputStream inputStream;
private String characterEncoding;
public InputStreamTemplateResource(InputStream inputStream, String characterEncoding) {
this.inputStream = inputStream;
this.characterEncoding = characterEncoding;
}
@Override
public Reader reader() throws IOException {
return new InputStreamReader(inputStream, characterEncoding);
}
@Override
public ITemplateResource relative(String relativeLocation) {
// Implement logic for relative resources, if needed
throw new UnsupportedOperationException("Relative resources not supported");
}
@Override
public String getDescription() {
return "InputStream resource [Stream]";
}
@Override
public String getBaseName() {
return "streamResource";
}
@Override
public boolean exists() {
return inputStream != null;
}
}
@@ -0,0 +1,19 @@
package stirling.software.common.model;
import java.util.Calendar;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PdfMetadata {
private String author;
private String producer;
private String title;
private String creator;
private String subject;
private String keywords;
private Calendar creationDate;
private Calendar modificationDate;
}
@@ -0,0 +1,19 @@
package stirling.software.common.model.api;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class GeneralFile {
@Schema(
description = "The input file",
requiredMode = Schema.RequiredMode.REQUIRED,
format = "binary")
private MultipartFile fileInput;
}
@@ -0,0 +1,25 @@
package stirling.software.common.model.api;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@EqualsAndHashCode
public class PDFFile {
@Schema(
description = "The input PDF file",
contentMediaType = "application/pdf",
format = "binary")
private MultipartFile fileInput;
@Schema(
description = "File ID for server-side files (can be used instead of fileInput)",
example = "a1b2c3d4-5678-90ab-cdef-ghijklmnopqr")
private String fileId;
}
@@ -0,0 +1,41 @@
package stirling.software.common.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class EmlToPdfRequest extends PDFFile {
// fileInput is inherited from PDFFile
@Schema(
description = "Include email attachments in the PDF output",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "false")
private boolean includeAttachments = false;
@Schema(
description = "Maximum attachment size in MB to include (default 10MB, range: 1-100)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "10",
minimum = "1",
maximum = "100")
private int maxAttachmentSizeMB = 10;
@Schema(
description = "Download HTML intermediate file instead of PDF",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "false")
private boolean downloadHtml = false;
@Schema(
description = "Include CC and BCC recipients in header (if available)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "true")
private boolean includeAllRecipients = true;
}
@@ -0,0 +1,19 @@
package stirling.software.common.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class HTMLToPdfRequest extends PDFFile {
@Schema(
description = "Zoom level for displaying the website. Default is '1'.",
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "1")
private float zoom;
}
@@ -0,0 +1,8 @@
package stirling.software.common.model.api.misc;
public enum HighContrastColorCombination {
WHITE_TEXT_ON_BLACK,
BLACK_TEXT_ON_WHITE,
YELLOW_TEXT_ON_BLACK,
GREEN_TEXT_ON_BLACK,
}
@@ -0,0 +1,7 @@
package stirling.software.common.model.api.misc;
public enum ReplaceAndInvert {
HIGH_CONTRAST_COLOR,
CUSTOM_COLOR,
FULL_INVERSION,
}
@@ -0,0 +1,28 @@
package stirling.software.common.model.api.security;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class RedactionArea {
@Schema(description = "The left edge point of the area to be redacted.")
private Double x;
@Schema(description = "The top edge point of the area to be redacted.")
private Double y;
@Schema(description = "The height of the area to be redacted.")
private Double height;
@Schema(description = "The width of the area to be redacted.")
private Double width;
@Schema(description = "The page on which the area should be redacted.")
private Integer page;
@Schema(description = "The color used to redact the specified area.")
private String color;
}
@@ -0,0 +1,66 @@
package stirling.software.common.model.enumeration;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
// Unlimited access
ADMIN("ROLE_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.admin"),
// Unlimited access
USER("ROLE_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.user"),
// 40 API calls Per Day, 40 web calls
LIMITED_API_USER("ROLE_LIMITED_API_USER", 40, 40, "adminUserSettings.apiUser"),
// 20 API calls Per Day, 20 web calls
EXTRA_LIMITED_API_USER("ROLE_EXTRA_LIMITED_API_USER", 20, 20, "adminUserSettings.extraApiUser"),
// 0 API calls per day and 20 web calls
WEB_ONLY_USER("ROLE_WEB_ONLY_USER", 0, 20, "adminUserSettings.webOnlyUser"),
INTERNAL_API_USER(
"STIRLING-PDF-BACKEND-API-USER",
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"adminUserSettings.internalApiUser"),
DEMO_USER("ROLE_DEMO_USER", 100, 100, "adminUserSettings.demoUser");
private final String roleId;
private final int apiCallsPerDay;
private final int webCallsPerDay;
private final String roleName;
public static String getRoleNameByRoleId(String roleId) {
// Using the fromString method to get the Role enum based on the roleId
Role role = fromString(roleId);
// Return the roleName of the found Role enum
return role.getRoleName();
}
// Method to retrieve all role IDs and role names
public static Map<String, String> getAllRoleDetails() {
// Using LinkedHashMap to preserve order
Map<String, String> roleDetails = new LinkedHashMap<>();
for (Role role : Role.values()) {
roleDetails.put(role.getRoleId(), role.getRoleName());
}
return roleDetails;
}
public static Role fromString(String roleId) {
for (Role role : Role.values()) {
if (role.getRoleId().equalsIgnoreCase(roleId)) {
return role;
}
}
throw new IllegalArgumentException("No Role defined for id: " + roleId);
}
}
@@ -0,0 +1,26 @@
package stirling.software.common.model.enumeration;
import lombok.Getter;
@Getter
public enum UsernameAttribute {
MAIL("mail"),
EMAIL("email"),
LOGIN("login"),
PROFILE("profile"),
NAME("name"),
UID("uid"),
USERNAME("username"),
NICKNAME("nickname"),
GIVEN_NAME("given_name"),
MIDDLE_NAME("middle_name"),
FAMILY_NAME("family_name"),
PREFERRED_NAME("preferred_name"),
PREFERRED_USERNAME("preferred_username");
private final String name;
UsernameAttribute(final String name) {
this.name = name;
}
}
@@ -0,0 +1,7 @@
package stirling.software.common.model.exception;
public class UnsupportedClaimException extends RuntimeException {
public UnsupportedClaimException(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package stirling.software.common.model.exception;
public class UnsupportedProviderException extends Exception {
public UnsupportedProviderException(String message) {
super(message);
}
}
@@ -0,0 +1,15 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobProgress {
private String jobId;
private String status;
private int percentComplete;
private String message;
}
@@ -0,0 +1,14 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobResponse<T> {
private boolean async;
private String jobId;
private T result;
}
@@ -0,0 +1,164 @@
package stirling.software.common.model.job;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents the result of a job execution. Used by the TaskManager to store job results. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobResult {
/** The job ID */
private String jobId;
/** Flag indicating if the job is complete */
private boolean complete;
/** Error message if the job failed */
private String error;
/** List of result files for jobs that produce files */
@JsonIgnore private List<ResultFile> resultFiles;
/** Time when the job was created */
private LocalDateTime createdAt;
/** Time when the job was completed */
private LocalDateTime completedAt;
/** The actual result object, if not a file */
private Object result;
/**
* Notes attached to this job for tracking purposes. Uses CopyOnWriteArrayList for thread safety
* when notes are added concurrently.
*/
private final List<String> notes = new CopyOnWriteArrayList<>();
/**
* Create a new JobResult with the given job ID
*
* @param jobId The job ID
* @return A new JobResult
*/
public static JobResult createNew(String jobId) {
return JobResult.builder()
.jobId(jobId)
.complete(false)
.createdAt(LocalDateTime.now())
.build();
}
/**
* Mark this job as complete with a general result
*
* @param result The result object
*/
public void completeWithResult(Object result) {
this.complete = true;
this.result = result;
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as failed with an error message
*
* @param error The error message
*/
public void failWithError(String error) {
this.complete = true;
this.error = error;
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with multiple file results
*
* @param resultFiles The list of result files
*/
public void completeWithFiles(List<ResultFile> resultFiles) {
this.complete = true;
this.resultFiles = new ArrayList<>(resultFiles);
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with a single file result (convenience method)
*
* @param fileId The file ID of the result
* @param fileName The file name
* @param contentType The content type of the file
* @param fileSize The size of the file in bytes
*/
public void completeWithSingleFile(
String fileId, String fileName, String contentType, long fileSize) {
ResultFile resultFile =
ResultFile.builder()
.fileId(fileId)
.fileName(fileName)
.contentType(contentType)
.fileSize(fileSize)
.build();
completeWithFiles(List.of(resultFile));
}
/**
* Check if this job has file results
*
* @return true if this job has file results, false otherwise
*/
public boolean hasFiles() {
return resultFiles != null && !resultFiles.isEmpty();
}
/**
* Check if this job has multiple file results
*
* @return true if this job has multiple file results, false otherwise
*/
public boolean hasMultipleFiles() {
return resultFiles != null && resultFiles.size() > 1;
}
/**
* Get all result files
*
* @return List of result files
*/
public List<ResultFile> getAllResultFiles() {
if (resultFiles != null && !resultFiles.isEmpty()) {
return Collections.unmodifiableList(resultFiles);
}
return Collections.emptyList();
}
/**
* Add a note to this job
*
* @param note The note to add
*/
public void addNote(String note) {
this.notes.add(note);
}
/**
* Get all notes attached to this job
*
* @return An unmodifiable view of the notes list
*/
public List<String> getNotes() {
return Collections.unmodifiableList(notes);
}
}
@@ -0,0 +1,43 @@
package stirling.software.common.model.job;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents statistics about jobs in the system */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobStats {
/** Total number of jobs (active and completed) */
private int totalJobs;
/** Number of active (incomplete) jobs */
private int activeJobs;
/** Number of completed jobs */
private int completedJobs;
/** Number of failed jobs */
private int failedJobs;
/** Number of successful jobs */
private int successfulJobs;
/** Number of jobs with file results */
private int fileResultJobs;
/** The oldest active job's creation timestamp */
private LocalDateTime oldestActiveJobTime;
/** The newest active job's creation timestamp */
private LocalDateTime newestActiveJobTime;
/** The average processing time for completed jobs in milliseconds */
private long averageProcessingTimeMs;
}
@@ -0,0 +1,26 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents a single file result from a job execution */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResultFile {
/** The file ID for accessing the file */
private String fileId;
/** The original file name */
private String fileName;
/** MIME type of the file */
private String contentType;
/** Size of the file in bytes */
private long fileSize;
}
@@ -0,0 +1,86 @@
package stirling.software.common.model.oauth2;
import java.util.ArrayList;
import java.util.Collection;
import lombok.NoArgsConstructor;
import stirling.software.common.model.enumeration.UsernameAttribute;
@NoArgsConstructor
public class GitHubProvider extends Provider {
private static final String NAME = "github";
private static final String CLIENT_NAME = "GitHub";
private static final String AUTHORIZATION_URI = "https://github.com/login/oauth/authorize";
private static final String TOKEN_URI = "https://github.com/login/oauth/access_token";
private static final String USER_INFO_URI = "https://api.github.com/user";
public GitHubProvider(
String clientId,
String clientSecret,
Collection<String> scopes,
UsernameAttribute useAsUsername) {
super(
null,
NAME,
CLIENT_NAME,
clientId,
clientSecret,
scopes,
useAsUsername != null ? useAsUsername : UsernameAttribute.LOGIN,
AUTHORIZATION_URI,
TOKEN_URI,
USER_INFO_URI);
}
@Override
public String getAuthorizationUri() {
return AUTHORIZATION_URI;
}
@Override
public String getTokenUri() {
return TOKEN_URI;
}
@Override
public String getUserInfoUri() {
return USER_INFO_URI;
}
@Override
public String getName() {
return NAME;
}
@Override
public String getClientName() {
return CLIENT_NAME;
}
@Override
public Collection<String> getScopes() {
Collection<String> scopes = super.getScopes();
if (scopes == null || scopes.isEmpty()) {
scopes = new ArrayList<>();
scopes.add("read:user");
}
return scopes;
}
@Override
public String toString() {
return "GitHub [clientId="
+ getClientId()
+ ", clientSecret="
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
+ ", scopes="
+ getScopes()
+ ", useAsUsername="
+ getUseAsUsername()
+ "]";
}
}
@@ -0,0 +1,85 @@
package stirling.software.common.model.oauth2;
import java.util.ArrayList;
import java.util.Collection;
import lombok.NoArgsConstructor;
import stirling.software.common.model.enumeration.UsernameAttribute;
@NoArgsConstructor
public class GoogleProvider extends Provider {
private static final String NAME = "google";
private static final String CLIENT_NAME = "Google";
private static final String AUTHORIZATION_URI = "https://accounts.google.com/o/oauth2/v2/auth";
private static final String TOKEN_URI = "https://www.googleapis.com/oauth2/v4/token";
private static final String USER_INFO_URI =
"https://www.googleapis.com/oauth2/v3/userinfo?alt=json";
public GoogleProvider(
String clientId,
String clientSecret,
Collection<String> scopes,
UsernameAttribute useAsUsername) {
super(
null,
NAME,
CLIENT_NAME,
clientId,
clientSecret,
scopes,
useAsUsername,
AUTHORIZATION_URI,
TOKEN_URI,
USER_INFO_URI);
}
public String getAuthorizationUri() {
return AUTHORIZATION_URI;
}
public String getTokenUri() {
return TOKEN_URI;
}
public String getUserinfoUri() {
return USER_INFO_URI;
}
@Override
public String getName() {
return NAME;
}
@Override
public String getClientName() {
return CLIENT_NAME;
}
@Override
public Collection<String> getScopes() {
Collection<String> scopes = super.getScopes();
if (scopes == null || scopes.isEmpty()) {
scopes = new ArrayList<>();
scopes.add("https://www.googleapis.com/auth/userinfo.email");
scopes.add("https://www.googleapis.com/auth/userinfo.profile");
}
return scopes;
}
@Override
public String toString() {
return "Google [clientId="
+ getClientId()
+ ", clientSecret="
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
+ ", scopes="
+ getScopes()
+ ", useAsUsername="
+ getUseAsUsername()
+ "]";
}
}
@@ -0,0 +1,72 @@
package stirling.software.common.model.oauth2;
import java.util.ArrayList;
import java.util.Collection;
import lombok.NoArgsConstructor;
import stirling.software.common.model.enumeration.UsernameAttribute;
@NoArgsConstructor
public class KeycloakProvider extends Provider {
private static final String NAME = "keycloak";
private static final String CLIENT_NAME = "Keycloak";
public KeycloakProvider(
String issuer,
String clientId,
String clientSecret,
Collection<String> scopes,
UsernameAttribute useAsUsername) {
super(
issuer,
NAME,
CLIENT_NAME,
clientId,
clientSecret,
scopes,
useAsUsername,
null,
null,
null);
}
@Override
public String getName() {
return NAME;
}
@Override
public String getClientName() {
return CLIENT_NAME;
}
@Override
public Collection<String> getScopes() {
Collection<String> scopes = super.getScopes();
if (scopes == null || scopes.isEmpty()) {
scopes = new ArrayList<>();
scopes.add("profile");
scopes.add("email");
}
return scopes;
}
@Override
public String toString() {
return "Keycloak [issuer="
+ getIssuer()
+ ", clientId="
+ getClientId()
+ ", clientSecret="
+ (getClientSecret() != null && !getClientSecret().isBlank() ? "*****" : "NULL")
+ ", scopes="
+ getScopes()
+ ", useAsUsername="
+ getUseAsUsername()
+ "]";
}
}
@@ -0,0 +1,129 @@
package stirling.software.common.model.oauth2;
import static stirling.software.common.model.enumeration.UsernameAttribute.EMAIL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.common.model.enumeration.UsernameAttribute;
import stirling.software.common.model.exception.UnsupportedClaimException;
@Data
@NoArgsConstructor
public class Provider {
public static final String EXCEPTION_MESSAGE = "The attribute %s is not supported for %s.";
private String issuer;
private String name;
private String clientName;
private String clientId;
private String clientSecret;
private Collection<String> scopes;
private UsernameAttribute useAsUsername;
private String authorizationUri;
private String tokenUri;
private String userInfoUri;
public Provider(
String issuer,
String name,
String clientName,
String clientId,
String clientSecret,
Collection<String> scopes,
UsernameAttribute useAsUsername,
String authorizationUri,
String tokenUri,
String userInfoUri) {
this.issuer = issuer;
this.name = name;
this.clientName = clientName;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scopes = scopes == null ? new ArrayList<>() : scopes;
this.useAsUsername =
useAsUsername != null ? validateUsernameAttribute(useAsUsername) : EMAIL;
this.authorizationUri = authorizationUri;
this.tokenUri = tokenUri;
this.userInfoUri = userInfoUri;
}
public void setScopes(String scopes) {
if (scopes != null && !scopes.isBlank()) {
this.scopes = Arrays.stream(scopes.split(",")).map(String::trim).toList();
}
}
private UsernameAttribute validateUsernameAttribute(UsernameAttribute usernameAttribute) {
switch (name) {
case "google" -> {
return validateGoogleUsernameAttribute(usernameAttribute);
}
case "github" -> {
return validateGitHubUsernameAttribute(usernameAttribute);
}
case "keycloak" -> {
return validateKeycloakUsernameAttribute(usernameAttribute);
}
default -> {
return usernameAttribute;
}
}
}
private UsernameAttribute validateKeycloakUsernameAttribute(
UsernameAttribute usernameAttribute) {
switch (usernameAttribute) {
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME, PREFERRED_USERNAME -> {
return usernameAttribute;
}
default ->
throw new UnsupportedClaimException(
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
}
}
private UsernameAttribute validateGoogleUsernameAttribute(UsernameAttribute usernameAttribute) {
switch (usernameAttribute) {
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> {
return usernameAttribute;
}
default ->
throw new UnsupportedClaimException(
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
}
}
private UsernameAttribute validateGitHubUsernameAttribute(UsernameAttribute usernameAttribute) {
switch (usernameAttribute) {
case LOGIN, EMAIL, NAME -> {
return usernameAttribute;
}
default ->
throw new UnsupportedClaimException(
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
}
}
@Override
public String toString() {
return "Provider [name="
+ getName()
+ ", clientName="
+ getClientName()
+ ", clientId="
+ getClientId()
+ ", clientSecret="
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
+ ", scopes="
+ getScopes()
+ ", useAsUsername="
+ getUseAsUsername()
+ "]";
}
}
@@ -0,0 +1,542 @@
package stirling.software.common.service;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.examples.util.DeletingRandomAccessFile;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
import org.apache.pdfbox.io.ScratchFile;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
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.ExceptionUtils;
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
* resources.
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class CustomPDFDocumentFactory {
private final PdfMetadataService pdfMetadataService;
// Memory thresholds and limits
private static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
// Files smaller than this threshold are loaded entirely in memory for better performance.
// These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM.
// No temp files are created for document data, reducing I/O operations but consuming more
// memory.
private static final long LARGE_FILE_THRESHOLD = 50 * 1024 * 1024; // 50 MB
// Files between SMALL and LARGE thresholds use file-based caching with ScratchFile,
// but are loaded directly from byte arrays if provided that way.
// When loading from byte arrays, once size exceeds this threshold, bytes are first
// written to temp files before loading to reduce memory pressure.
private static final long LARGE_FILE_USAGE = 10 * 1024 * 1024;
private static final long EXTREMELY_LARGE_THRESHOLD = 100 * 1024 * 1024; // 100 MB
// Files exceeding this threshold use specialized loading with RandomAccessReadBufferedFile
// which provides buffered access to the file without loading the entire content at once.
// These files are always processed using file-based caching with minimal memory footprint,
// trading some performance for significantly reduced memory usage.
// For extremely large PDFs, this prevents OutOfMemoryErrors at the cost of being more I/O
// bound.
private static final double MIN_FREE_MEMORY_PERCENTAGE = 30.0; // 30%
private static final long MIN_FREE_MEMORY_BYTES = 4L * 1024 * 1024 * 1024; // 4 GB
// Counter for tracking temporary resources
private static final AtomicLong tempCounter = new AtomicLong(0);
/**
* Main entry point for loading a PDF document from a file. Automatically selects the most
* appropriate loading strategy.
*/
public PDDocument load(File file) throws IOException {
return load(file, false);
}
/**
* Main entry point for loading a PDF document from a file with read-only option. Automatically
* selects the most appropriate loading strategy.
*/
public PDDocument load(File file, boolean readOnly) throws IOException {
if (file == null) {
throw ExceptionUtils.createNullArgumentException("File");
}
long fileSize = file.length();
log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024));
PDDocument doc = loadAdaptively(file, fileSize);
if (!readOnly) {
postProcessDocument(doc);
}
return doc;
}
/**
* Main entry point for loading a PDF document from a Path. Automatically selects the most
* appropriate loading strategy.
*/
public PDDocument load(Path path) throws IOException {
return load(path, false);
}
/**
* Main entry point for loading a PDF document from a Path with read-only option. Automatically
* selects the most appropriate loading strategy.
*/
public PDDocument load(Path path, boolean readOnly) throws IOException {
if (path == null) {
throw ExceptionUtils.createNullArgumentException("File");
}
long fileSize = Files.size(path);
log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024));
PDDocument doc = loadAdaptively(path.toFile(), fileSize);
if (!readOnly) {
postProcessDocument(doc);
}
return doc;
}
/** Load a PDF from byte array with automatic optimization. */
public PDDocument load(byte[] input) throws IOException {
return load(input, false);
}
/** Load a PDF from byte array with automatic optimization and read-only option. */
public PDDocument load(byte[] input, boolean readOnly) throws IOException {
if (input == null) {
throw ExceptionUtils.createNullArgumentException("Input bytes");
}
long dataSize = input.length;
log.debug("Loading PDF from byte array, size: {}MB", dataSize / (1024 * 1024));
PDDocument doc = loadAdaptively(input, dataSize);
if (!readOnly) {
postProcessDocument(doc);
}
return doc;
}
/** Load a PDF from InputStream with automatic optimization. */
public PDDocument load(InputStream input) throws IOException {
return load(input, false);
}
/** Load a PDF from InputStream with automatic optimization and read-only option. */
public PDDocument load(InputStream input, boolean readOnly) throws IOException {
if (input == null) {
throw ExceptionUtils.createNullArgumentException("InputStream");
}
// Since we don't know the size upfront, buffer to a temp file
Path tempFile = createTempFile("pdf-stream-");
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile));
if (!readOnly) {
postProcessDocument(doc);
}
return doc;
}
/** Load with password from InputStream */
public PDDocument load(InputStream input, String password) throws IOException {
return load(input, password, false);
}
/** Load with password from InputStream and read-only option */
public PDDocument load(InputStream input, String password, boolean readOnly)
throws IOException {
if (input == null) {
throw ExceptionUtils.createNullArgumentException("InputStream");
}
// Since we don't know the size upfront, buffer to a temp file
Path tempFile = createTempFile("pdf-stream-");
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
PDDocument doc =
loadAdaptivelyWithPassword(tempFile.toFile(), Files.size(tempFile), password);
if (!readOnly) {
postProcessDocument(doc);
}
return doc;
}
/** Load from a file path string */
public PDDocument load(String path) throws IOException {
return load(path, false);
}
/** Load from a file path string with read-only option */
public PDDocument load(String path, boolean readOnly) throws IOException {
return load(new File(path), readOnly);
}
/** Load from a PDFFile object */
public PDDocument load(PDFFile pdfFile) throws IOException {
return load(pdfFile, false);
}
/** Load from a PDFFile object with read-only option */
public PDDocument load(PDFFile pdfFile, boolean readOnly) throws IOException {
return load(pdfFile.getFileInput(), readOnly);
}
/** Load from a MultipartFile */
public PDDocument load(MultipartFile pdfFile) throws IOException {
return load(pdfFile, false);
}
/** Load from a MultipartFile with read-only option */
public PDDocument load(MultipartFile pdfFile, boolean readOnly) throws IOException {
return load(pdfFile.getInputStream(), readOnly);
}
/** Load with password from MultipartFile */
public PDDocument load(MultipartFile fileInput, String password) throws IOException {
return load(fileInput, password, false);
}
/** Load with password from MultipartFile with read-only option */
public PDDocument load(MultipartFile fileInput, String password, boolean readOnly)
throws IOException {
return load(fileInput.getInputStream(), password, readOnly);
}
/**
* Determine the appropriate caching strategy based on file size and available memory. This
* common method is used by both password and non-password loading paths.
*/
public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) {
long maxMemory = Runtime.getRuntime().maxMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
long usedMemory = totalMemory - freeMemory;
// Calculate percentage of free memory
double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100;
long actualFreeMemory = maxMemory - usedMemory;
// Log memory status
log.debug(
"Memory status - Free: {}MB ({}%), Used: {}MB, Max: {}MB",
actualFreeMemory / (1024 * 1024),
String.format("%.2f", freeMemoryPercent),
usedMemory / (1024 * 1024),
maxMemory / (1024 * 1024));
// If free memory is critically low, always use file-based caching
if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE
|| actualFreeMemory < MIN_FREE_MEMORY_BYTES) {
log.debug(
"Low memory detected ({}%), forcing file-based cache",
String.format("%.2f", freeMemoryPercent));
return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly());
} else if (contentSize < SMALL_FILE_THRESHOLD) {
log.debug("Using memory-only cache for small document ({}KB)", contentSize / 1024);
return IOUtils.createMemoryOnlyStreamCache();
} else if (contentSize < LARGE_FILE_THRESHOLD) {
// For medium files (10-50MB), use a mixed approach
log.debug(
"Using mixed memory/file cache for medium document ({}MB)",
contentSize / (1024 * 1024));
return createScratchFileCacheFunction(MemoryUsageSetting.setupMixed(LARGE_FILE_USAGE));
} else {
log.debug("Using file-based cache for large document");
return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly());
}
}
/** Update the existing loadAdaptively method to use the common function */
private PDDocument loadAdaptively(Object source, long contentSize) throws IOException {
// Get the appropriate caching strategy
StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize);
// If small handle as bytes and remove original file
if (contentSize <= SMALL_FILE_THRESHOLD && source instanceof File file) {
source = Files.readAllBytes(file.toPath());
file.delete();
}
PDDocument document;
if (source instanceof File file) {
document = loadFromFile(file, contentSize, cacheFunction);
} else if (source instanceof byte[] bytes) {
document = loadFromBytes(bytes, contentSize, cacheFunction);
} else {
throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
}
configureResourceCacheIfNeeded(document, contentSize);
return document;
}
/**
* Configure resource cache based on content size and memory constraints. Disables resource
* cache for large files or when memory is low to prevent OOM errors.
*/
private void configureResourceCacheIfNeeded(PDDocument document, long contentSize) {
if (contentSize > LARGE_FILE_THRESHOLD) {
document.setResourceCache(null);
} else {
// Check current memory status for smaller files
long maxMemory = Runtime.getRuntime().maxMemory();
long usedMemory =
Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100;
if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE) {
document.setResourceCache(null);
}
}
}
/** Load a PDF with password protection using adaptive loading strategies */
private PDDocument loadAdaptivelyWithPassword(Object source, long contentSize, String password)
throws IOException {
// Get the appropriate caching strategy
StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize);
// If small handle as bytes and remove original file
if (contentSize <= SMALL_FILE_THRESHOLD && source instanceof File file) {
source = Files.readAllBytes(file.toPath());
file.delete();
}
PDDocument document;
if (source instanceof File file) {
document = loadFromFileWithPassword(file, contentSize, cacheFunction, password);
} else if (source instanceof byte[] bytes) {
document = loadFromBytesWithPassword(bytes, contentSize, cacheFunction, password);
} else {
throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
}
configureResourceCacheIfNeeded(document, contentSize);
return document;
}
/** Load a file with password */
private PDDocument loadFromFileWithPassword(
File file, long size, StreamCacheCreateFunction cache, String password)
throws IOException {
return Loader.loadPDF(new DeletingRandomAccessFile(file), password, null, null, cache);
}
/** Load bytes with password */
private PDDocument loadFromBytesWithPassword(
byte[] bytes, long size, StreamCacheCreateFunction cache, String password)
throws IOException {
if (size >= SMALL_FILE_THRESHOLD) {
log.debug("Writing large byte array to temp file for password-protected PDF");
Path tempFile = createTempFile("pdf-bytes-");
Files.write(tempFile, bytes);
return Loader.loadPDF(tempFile.toFile(), password, null, null, cache);
}
return Loader.loadPDF(bytes, password, null, null, cache);
}
private StreamCacheCreateFunction createScratchFileCacheFunction(MemoryUsageSetting settings) {
return () -> {
try {
return new ScratchFile(settings);
} catch (IOException e) {
throw new RuntimeException("ScratchFile initialization failed", e);
}
};
}
private void postProcessDocument(PDDocument doc) throws IOException {
pdfMetadataService.setDefaultMetadata(doc);
removePassword(doc);
}
private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache)
throws IOException {
try {
return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache);
} catch (IOException e) {
ExceptionUtils.logException("PDF loading from file", e);
throw ExceptionUtils.handlePdfException(e);
}
}
private PDDocument loadFromBytes(byte[] bytes, long size, StreamCacheCreateFunction cache)
throws IOException {
if (size >= SMALL_FILE_THRESHOLD) {
log.debug("Writing large byte array to temp file");
Path tempFile = createTempFile("pdf-bytes-");
Files.write(tempFile, bytes);
return loadFromFile(tempFile.toFile(), size, cache);
}
try {
return Loader.loadPDF(bytes, "", null, null, cache);
} catch (IOException e) {
ExceptionUtils.logException("PDF loading from bytes", e);
throw ExceptionUtils.handlePdfException(e);
}
}
public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException {
PDDocument doc = new PDDocument(createScratchFileCacheFunction(settings));
pdfMetadataService.setDefaultMetadata(doc);
return doc;
}
public PDDocument createNewDocument() throws IOException {
return createNewDocument(MemoryUsageSetting.setupTempFileOnly());
}
public byte[] saveToBytes(PDDocument document) throws IOException {
if (document.getNumberOfPages() < 10) { // Simple heuristic
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
document.save(baos);
return baos.toByteArray();
}
} else {
Path tempFile = createTempFile("pdf-save-");
document.save(tempFile.toFile());
return Files.readAllBytes(tempFile);
}
}
// Improved password handling
private void removePassword(PDDocument document) throws IOException {
if (document.isEncrypted()) {
try {
document.setAllSecurityToBeRemoved(true);
} catch (Exception e) {
ExceptionUtils.logException("PDF decryption", e);
throw new IOException("PDF decryption failed", e);
}
}
}
// 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;
}
/** Create a uniquely named temporary directory */
private Path createTempDirectory(String prefix) throws IOException {
return Files.createTempDirectory(prefix + tempCounter.incrementAndGet() + "-");
}
/** Create new document bytes based on an existing document */
public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException {
try (PDDocument document = load(oldDocument)) {
return saveToBytes(document);
}
}
/** Create new document bytes based on an existing document file */
public byte[] createNewBytesBasedOnOldDocument(File oldDocument) throws IOException {
try (PDDocument document = load(oldDocument)) {
return saveToBytes(document);
}
}
/** Create new document bytes based on an existing PDDocument */
public byte[] createNewBytesBasedOnOldDocument(PDDocument oldDocument) throws IOException {
pdfMetadataService.setMetadataToPdf(
oldDocument, pdfMetadataService.extractMetadataFromPdf(oldDocument), true);
return saveToBytes(oldDocument);
}
/** Create a new document based on an existing document bytes */
public PDDocument createNewDocumentBasedOnOldDocument(byte[] oldDocument) throws IOException {
try (PDDocument document = load(oldDocument)) {
return createNewDocumentBasedOnOldDocument(document);
}
}
/** Create a new document based on an existing document file */
public PDDocument createNewDocumentBasedOnOldDocument(File oldDocument) throws IOException {
try (PDDocument document = load(oldDocument)) {
return createNewDocumentBasedOnOldDocument(document);
}
}
/** Create a new document based on an existing PDDocument */
public PDDocument createNewDocumentBasedOnOldDocument(PDDocument oldDocument)
throws IOException {
PDDocument document = createNewDocument();
pdfMetadataService.setMetadataToPdf(
document, pdfMetadataService.extractMetadataFromPdf(oldDocument), true);
return document;
}
/** Load document from a file and convert it to bytes */
public byte[] loadToBytes(File file) throws IOException {
try (PDDocument document = load(file)) {
return saveToBytes(document);
}
}
/** Load document from bytes and convert it back to bytes */
public byte[] loadToBytes(byte[] bytes) throws IOException {
try (PDDocument document = load(bytes)) {
return saveToBytes(document);
}
}
}
@@ -0,0 +1,78 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class FileOrUploadService {
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
public Path resolveFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId);
}
public MultipartFile toMockMultipartFile(String name, byte[] data) throws IOException {
return new CustomMultipartFile(name, data);
}
// Custom implementation of MultipartFile
private static class CustomMultipartFile implements MultipartFile {
private final String name;
private final byte[] content;
public CustomMultipartFile(String name, byte[] content) {
this.name = name;
this.content = content;
}
@Override
public String getName() {
return name;
}
@Override
public String getOriginalFilename() {
return name;
}
@Override
public String getContentType() {
return "application/pdf";
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() throws IOException {
return content;
}
@Override
public java.io.InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
Files.write(dest.toPath(), content);
}
}
}
@@ -0,0 +1,184 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
* system to handle file references.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FileStorage {
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
private final FileOrUploadService fileOrUploadService;
/**
* Store a file and return its unique ID
*
* @param file The file to store
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeFile(MultipartFile file) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Transfer the file to the storage location
file.transferTo(filePath.toFile());
log.debug("Stored file with ID: {}", fileId);
return fileId;
}
/**
* Store a byte array as a file and return its unique ID
*
* @param bytes The byte array to store
* @param originalName The original name of the file (for extension)
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeBytes(byte[] bytes, String originalName) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Write the bytes to the file
Files.write(filePath, bytes);
log.debug("Stored byte array with ID: {}", fileId);
return fileId;
}
/**
* Retrieve a file by its ID as a MultipartFile
*
* @param fileId The ID of the file to retrieve
* @return The file as a MultipartFile
* @throws IOException If the file doesn't exist or can't be read
*/
public MultipartFile retrieveFile(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
byte[] fileData = Files.readAllBytes(filePath);
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
}
/**
* Retrieve a file by its ID as a byte array
*
* @param fileId The ID of the file to retrieve
* @return The file as a byte array
* @throws IOException If the file doesn't exist or can't be read
*/
public byte[] retrieveBytes(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.readAllBytes(filePath);
}
/**
* Delete a file by its ID
*
* @param fileId The ID of the file to delete
* @return true if the file was deleted, false otherwise
*/
public boolean deleteFile(String fileId) {
try {
Path filePath = getFilePath(fileId);
return Files.deleteIfExists(filePath);
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
/**
* Check if a file exists by its ID
*
* @param fileId The ID of the file to check
* @return true if the file exists, false otherwise
*/
public boolean fileExists(String fileId) {
Path filePath = getFilePath(fileId);
return Files.exists(filePath);
}
/**
* Get the size of a file by its ID without loading the content into memory
*
* @param fileId The ID of the file
* @return The size of the file in bytes
* @throws IOException If the file doesn't exist or can't be read
*/
public long getFileSize(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
/**
* Get the path for a file ID
*
* @param fileId The ID of the file
* @return The path to the file
* @throws IllegalArgumentException if fileId contains path traversal characters or resolves
* outside base directory
*/
private Path getFilePath(String fileId) {
// Validate fileId to prevent path traversal
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
// Ensure resolved path is within the base directory
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
}
/**
* Generate a unique file ID
*
* @return A unique file ID
*/
private String generateFileId() {
return UUID.randomUUID().toString();
}
}
@@ -0,0 +1,476 @@
package stirling.software.common.service;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.util.ExecutorFactory;
/** Service for executing jobs asynchronously or synchronously */
@Service
@Slf4j
public class JobExecutorService {
private final TaskManager taskManager;
private final FileStorage fileStorage;
private final HttpServletRequest request;
private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue;
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs;
public JobExecutorService(
TaskManager taskManager,
FileStorage fileStorage,
HttpServletRequest request,
ResourceMonitor resourceMonitor,
JobQueue jobQueue,
@Value("${spring.mvc.async.request-timeout:1200000}") long asyncRequestTimeoutMs,
@Value("${server.servlet.session.timeout:30m}") String sessionTimeout) {
this.taskManager = taskManager;
this.fileStorage = fileStorage;
this.request = request;
this.resourceMonitor = resourceMonitor;
this.jobQueue = jobQueue;
// Parse session timeout and calculate effective timeout once during initialization
long sessionTimeoutMs = parseSessionTimeout(sessionTimeout);
this.effectiveTimeoutMs = Math.min(asyncRequestTimeoutMs, sessionTimeoutMs);
log.debug(
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/**
* Run a job either asynchronously or synchronously
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @return The response
*/
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
/**
* Run a job either asynchronously or synchronously with a custom timeout
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async, Supplier<Object> work, long customTimeoutMs) {
return runJobGeneric(async, work, customTimeoutMs, false, 50);
}
/**
* Run a job either asynchronously or synchronously with custom parameters
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @param queueable Whether this job can be queued when system resources are limited
* @param resourceWeight The resource weight of this job (1-100)
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async,
Supplier<Object> work,
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
String jobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
if (request != null) {
request.setAttribute("jobId", jobId);
// Also track this job ID in the user's session for authorization purposes
// This ensures users can only cancel their own jobs
if (request.getSession() != null) {
@SuppressWarnings("unchecked")
java.util.Set<String> userJobIds =
(java.util.Set<String>) request.getSession().getAttribute("userJobIds");
if (userJobIds == null) {
userJobIds = new java.util.concurrent.ConcurrentSkipListSet<>();
request.getSession().setAttribute("userJobIds", userJobIds);
}
userJobIds.add(jobId);
log.debug("Added job ID {} to user session", jobId);
}
}
// Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
log.debug(
"Running job with ID: {}, async: {}, timeout: {}ms, queueable: {}, weight: {}",
jobId,
async,
timeoutToUse,
queueable,
resourceWeight);
// Check if we need to queue this job based on resource availability
boolean shouldQueue =
queueable
&& async
&& // Only async jobs can be queued
resourceMonitor.shouldQueueJob(resourceWeight);
if (shouldQueue) {
// Queue the job instead of executing immediately
log.debug(
"Queueing job {} due to resource constraints (weight: {})",
jobId,
resourceWeight);
taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager
Supplier<Object> wrappedWork =
() -> {
try {
Object result = work.get();
processJobResult(jobId, result);
return result;
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
throw e;
}
};
// Queue the job and get the future
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob(jobId, resourceWeight, wrappedWork, timeoutToUse);
// Return immediately with job ID
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
executor.execute(
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(jobId, result);
} catch (TimeoutException te) {
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
taskManager.setError(jobId, "Job timed out");
} catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
}
});
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else {
try {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
// If the result is already a ResponseEntity, return it directly
if (result instanceof ResponseEntity) {
return (ResponseEntity<?>) result;
}
// Process different result types
return handleResultForSyncJob(result);
} catch (TimeoutException te) {
log.error("Synchronous job timed out after {} ms", timeoutToUse);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
} catch (Exception e) {
log.error("Error executing synchronous job: {}", e.getMessage(), e);
// Construct a JSON error response
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
}
}
}
/**
* Process the result of an asynchronous job
*
* @param jobId The job ID
* @param result The result
*/
private void processJobResult(String jobId, Object result) {
try {
if (result instanceof byte[]) {
// Store byte array directly to disk to avoid double memory consumption
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
taskManager.setFileResult(jobId, fileId, "result.pdf", "application/pdf");
log.debug("Stored byte[] result with fileId: {}", fileId);
// Let the byte array get collected naturally in the next GC cycle
// We don't need to force System.gc() which can be harmful
} else if (result instanceof ResponseEntity) {
ResponseEntity<?> response = (ResponseEntity<?>) result;
Object body = response.getBody();
if (body instanceof byte[]) {
// Extract filename from content-disposition header if available
String filename = "result.pdf";
String contentType = "application/pdf";
if (response.getHeaders().getContentDisposition() != null) {
String disposition =
response.getHeaders().getContentDisposition().toString();
if (disposition.contains("filename=")) {
filename =
disposition.substring(
disposition.indexOf("filename=") + 9,
disposition.lastIndexOf("\""));
}
}
if (response.getHeaders().getContentType() != null) {
contentType = response.getHeaders().getContentType().toString();
}
// Store byte array directly to disk
String fileId = fileStorage.storeBytes((byte[]) body, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId);
// Let the GC handle the memory naturally
} else {
// Check if the response body contains a fileId
if (body != null && body.toString().contains("fileId")) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
body.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(body);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = "application/pdf";
try {
java.lang.reflect.Method getOriginalFileName =
body.getClass().getMethod("getOriginalFilename");
String origName = (String) getOriginalFileName.invoke(body);
if (origName != null && !origName.isEmpty()) {
filename = origName;
}
} catch (Exception e) {
log.debug(
"Could not get original filename: {}", e.getMessage());
}
try {
java.lang.reflect.Method getContentType =
body.getClass().getMethod("getContentType");
String ct = (String) getContentType.invoke(body);
if (ct != null && !ct.isEmpty()) {
contentType = ct;
}
} catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage());
}
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from response body: {}", fileId);
taskManager.setComplete(jobId);
return;
}
} catch (Exception e) {
log.debug(
"Failed to extract fileId from response body: {}",
e.getMessage());
}
}
// Store generic result
taskManager.setResult(jobId, body);
}
} else if (result instanceof MultipartFile) {
MultipartFile file = (MultipartFile) result;
String fileId = fileStorage.storeFile(file);
taskManager.setFileResult(
jobId, fileId, file.getOriginalFilename(), file.getContentType());
log.debug("Stored MultipartFile result with fileId: {}", fileId);
} else {
// Check if result has a fileId field
if (result != null) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
result.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(result);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = "application/pdf";
try {
java.lang.reflect.Method getOriginalFileName =
result.getClass().getMethod("getOriginalFilename");
String origName = (String) getOriginalFileName.invoke(result);
if (origName != null && !origName.isEmpty()) {
filename = origName;
}
} catch (Exception e) {
log.debug("Could not get original filename: {}", e.getMessage());
}
try {
java.lang.reflect.Method getContentType =
result.getClass().getMethod("getContentType");
String ct = (String) getContentType.invoke(result);
if (ct != null && !ct.isEmpty()) {
contentType = ct;
}
} catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage());
}
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from result object: {}", fileId);
taskManager.setComplete(jobId);
return;
}
} catch (Exception e) {
log.debug(
"Failed to extract fileId from result object: {}", e.getMessage());
}
}
// Default case: store the result as is
taskManager.setResult(jobId, result);
}
taskManager.setComplete(jobId);
} catch (Exception e) {
log.error("Error processing job result: {}", e.getMessage(), e);
taskManager.setError(jobId, "Error processing result: " + e.getMessage());
}
}
/**
* Handle different result types for synchronous jobs
*
* @param result The result object
* @return The appropriate ResponseEntity
* @throws IOException If there is an error processing the result
*/
private ResponseEntity<?> handleResultForSyncJob(Object result) throws IOException {
if (result instanceof byte[]) {
// Return byte array as PDF
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
.body(result);
} else if (result instanceof MultipartFile) {
// Return MultipartFile content
MultipartFile file = (MultipartFile) result;
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.getContentType()))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\""
+ file.getOriginalFilename()
+ "\"")
.body(file.getBytes());
} else {
// Default case: return as JSON
return ResponseEntity.ok(result);
}
}
/**
* Parse session timeout string (e.g., "30m", "1h") to milliseconds
*
* @param timeout The timeout string
* @return The timeout in milliseconds
*/
private long parseSessionTimeout(String timeout) {
if (timeout == null || timeout.isEmpty()) {
return 30 * 60 * 1000; // Default: 30 minutes
}
try {
String value = timeout.replaceAll("[^\\d.]", "");
String unit = timeout.replaceAll("[\\d.]", "");
double numericValue = Double.parseDouble(value);
return switch (unit.toLowerCase()) {
case "s" -> (long) (numericValue * 1000);
case "m" -> (long) (numericValue * 60 * 1000);
case "h" -> (long) (numericValue * 60 * 60 * 1000);
case "d" -> (long) (numericValue * 24 * 60 * 60 * 1000);
default -> (long) (numericValue * 60 * 1000); // Default to minutes
};
} catch (Exception e) {
log.warn("Could not parse session timeout '{}', using default", timeout);
return 30 * 60 * 1000; // Default: 30 minutes
}
}
/**
* Execute a supplier with a timeout
*
* @param supplier The supplier to execute
* @param timeoutMs The timeout in milliseconds
* @return The result from the supplier
* @throws TimeoutException If the execution times out
* @throws Exception If the supplier throws an exception
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
throws TimeoutException, Exception {
// Use the same executor as other async jobs for consistency
// This ensures all operations run on the same thread pool
java.util.concurrent.CompletableFuture<T> future =
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.TimeoutException e) {
future.cancel(true);
throw new TimeoutException("Execution timed out after " + timeoutMs + " ms");
} catch (java.util.concurrent.ExecutionException e) {
throw (Exception) e.getCause();
} catch (java.util.concurrent.CancellationException e) {
throw new Exception("Execution was cancelled", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new Exception("Execution was interrupted", e);
}
}
}
@@ -0,0 +1,495 @@
package stirling.software.common.service;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.*;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.SmartLifecycle;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.common.util.SpringContextHolder;
/**
* Manages a queue of jobs with dynamic sizing based on system resources. Used when system resources
* are limited to prevent overloading.
*/
@Service
@Slf4j
public class JobQueue implements SmartLifecycle {
private volatile boolean running = false;
private final ResourceMonitor resourceMonitor;
@Value("${stirling.job.queue.base-capacity:10}")
private int baseQueueCapacity = 10;
@Value("${stirling.job.queue.min-capacity:2}")
private int minQueueCapacity = 2;
@Value("${stirling.job.queue.check-interval-ms:1000}")
private long queueCheckIntervalMs = 1000;
@Value("${stirling.job.queue.max-wait-time-ms:600000}")
private long maxWaitTimeMs = 600000; // 10 minutes
private volatile BlockingQueue<QueuedJob> jobQueue;
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final Object queueLock = new Object(); // Lock for synchronizing queue operations
private boolean shuttingDown = false;
@Getter private int rejectedJobs = 0;
@Getter private int totalQueuedJobs = 0;
@Getter private int currentQueueSize = 0;
/** Represents a job waiting in the queue. */
@Data
@AllArgsConstructor
private static class QueuedJob {
private final String jobId;
private final int resourceWeight;
private final Supplier<Object> work;
private final long timeoutMs;
private final Instant queuedAt;
private CompletableFuture<ResponseEntity<?>> future;
private volatile boolean cancelled = false;
}
public JobQueue(ResourceMonitor resourceMonitor) {
this.resourceMonitor = resourceMonitor;
// Initialize with dynamic capacity
int capacity =
resourceMonitor.calculateDynamicQueueCapacity(baseQueueCapacity, minQueueCapacity);
this.jobQueue = new LinkedBlockingQueue<>(capacity);
}
// Remove @PostConstruct to let SmartLifecycle control startup
private void initializeSchedulers() {
log.debug(
"Starting job queue with base capacity {}, min capacity {}",
baseQueueCapacity,
minQueueCapacity);
// Periodically process the job queue
scheduler.scheduleWithFixedDelay(
this::processQueue, 0, queueCheckIntervalMs, TimeUnit.MILLISECONDS);
// Periodically update queue capacity based on resource usage
scheduler.scheduleWithFixedDelay(
this::updateQueueCapacity,
10000, // Initial delay
30000, // 30 second interval
TimeUnit.MILLISECONDS);
}
// Remove @PreDestroy to let SmartLifecycle control shutdown
private void shutdownSchedulers() {
log.info("Shutting down job queue");
shuttingDown = true;
// Complete any futures that are still waiting
jobMap.forEach(
(id, job) -> {
if (!job.future.isDone()) {
job.future.completeExceptionally(
new RuntimeException("Server shutting down, job cancelled"));
}
});
// Shutdown schedulers and wait for termination
try {
scheduler.shutdown();
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
jobExecutor.shutdown();
if (!jobExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
jobExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
scheduler.shutdownNow();
jobExecutor.shutdownNow();
}
log.info(
"Job queue shutdown complete. Stats: total={}, rejected={}",
totalQueuedJobs,
rejectedJobs);
}
// SmartLifecycle methods
@Override
public void start() {
log.info("Starting JobQueue lifecycle");
if (!running) {
initializeSchedulers();
running = true;
}
}
@Override
public void stop() {
log.info("Stopping JobQueue lifecycle");
shutdownSchedulers();
running = false;
}
@Override
public boolean isRunning() {
return running;
}
@Override
public int getPhase() {
// Start earlier than most components, but shutdown later
return 10;
}
@Override
public boolean isAutoStartup() {
return true;
}
/**
* Queues a job for execution when resources permit.
*
* @param jobId The job ID
* @param resourceWeight The resource weight of the job (1-100)
* @param work The work to be done
* @param timeoutMs The timeout in milliseconds
* @return A CompletableFuture that will complete when the job is executed
*/
public CompletableFuture<ResponseEntity<?>> queueJob(
String jobId, int resourceWeight, Supplier<Object> work, long timeoutMs) {
// Create a CompletableFuture to track this job's completion
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
// Create the queued job
QueuedJob job =
new QueuedJob(jobId, resourceWeight, work, timeoutMs, Instant.now(), future, false);
// Store in our map for lookup
jobMap.put(jobId, job);
// Update stats
totalQueuedJobs++;
// Synchronize access to the queue
synchronized (queueLock) {
currentQueueSize = jobQueue.size();
// Try to add to the queue
try {
boolean added = jobQueue.offer(job, 5, TimeUnit.SECONDS);
if (!added) {
log.warn("Queue full, rejecting job {}", jobId);
rejectedJobs++;
future.completeExceptionally(
new RuntimeException("Job queue full, please try again later"));
jobMap.remove(jobId);
return future;
}
log.debug(
"Job {} queued for execution (weight: {}, queue size: {})",
jobId,
resourceWeight,
jobQueue.size());
return future;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
future.completeExceptionally(new RuntimeException("Job queue interrupted"));
jobMap.remove(jobId);
return future;
}
}
}
/**
* Gets the current capacity of the job queue.
*
* @return The current capacity
*/
public int getQueueCapacity() {
synchronized (queueLock) {
return ((LinkedBlockingQueue<QueuedJob>) jobQueue).remainingCapacity()
+ jobQueue.size();
}
}
/** Updates the capacity of the job queue based on available system resources. */
private void updateQueueCapacity() {
try {
// Calculate new capacity once and cache the result
int newCapacity =
resourceMonitor.calculateDynamicQueueCapacity(
baseQueueCapacity, minQueueCapacity);
int currentCapacity = getQueueCapacity();
if (newCapacity != currentCapacity) {
log.debug(
"Updating job queue capacity from {} to {}", currentCapacity, newCapacity);
synchronized (queueLock) {
// Double-check that capacity still needs to be updated
// Use the cached currentCapacity to avoid calling getQueueCapacity() again
if (newCapacity != currentCapacity) {
// Create new queue with updated capacity
BlockingQueue<QueuedJob> newQueue = new LinkedBlockingQueue<>(newCapacity);
// Transfer jobs from old queue to new queue
jobQueue.drainTo(newQueue);
jobQueue = newQueue;
currentQueueSize = jobQueue.size();
}
}
}
} catch (Exception e) {
log.error("Error updating queue capacity: {}", e.getMessage(), e);
}
}
/** Processes jobs in the queue, executing them when resources permit. */
private void processQueue() {
// Jobs to execute after releasing the lock
java.util.List<QueuedJob> jobsToExecute = new java.util.ArrayList<>();
// First synchronized block: poll jobs from the queue and prepare them for execution
synchronized (queueLock) {
if (shuttingDown || jobQueue.isEmpty()) {
return;
}
try {
// Get current resource status
ResourceMonitor.ResourceStatus status = resourceMonitor.getCurrentStatus().get();
// Check if we should execute any jobs
boolean canExecuteJobs = (status != ResourceMonitor.ResourceStatus.CRITICAL);
if (!canExecuteJobs) {
// Under critical load, don't execute any jobs
log.debug("System under critical load, delaying job execution");
return;
}
// Get jobs from the queue, up to a limit based on resource availability
int jobsToProcess =
Math.max(
1,
switch (status) {
case OK -> 3;
case WARNING -> 1;
case CRITICAL -> 0;
});
for (int i = 0; i < jobsToProcess && !jobQueue.isEmpty(); i++) {
QueuedJob job = jobQueue.poll();
if (job == null) break;
// Check if it's been waiting too long
long waitTimeMs = Instant.now().toEpochMilli() - job.queuedAt.toEpochMilli();
if (waitTimeMs > maxWaitTimeMs) {
log.warn(
"Job {} exceeded maximum wait time ({} ms), executing anyway",
job.jobId,
waitTimeMs);
// Add a specific status to the job context that can be tracked
// This will be visible in the job status API
try {
TaskManager taskManager =
SpringContextHolder.getBean(TaskManager.class);
if (taskManager != null) {
taskManager.addNote(
job.jobId,
"QUEUED_TIMEOUT: Job waited in queue for "
+ (waitTimeMs / 1000)
+ " seconds, exceeding the maximum wait time of "
+ (maxWaitTimeMs / 1000)
+ " seconds.");
}
} catch (Exception e) {
log.error(
"Failed to add timeout note to job {}: {}",
job.jobId,
e.getMessage());
}
}
// Remove from our map
jobMap.remove(job.jobId);
currentQueueSize = jobQueue.size();
// Add to the list of jobs to execute outside the synchronized block
jobsToExecute.add(job);
}
} catch (Exception e) {
log.error("Error processing job queue: {}", e.getMessage(), e);
}
}
// Now execute the jobs outside the synchronized block to avoid holding the lock
for (QueuedJob job : jobsToExecute) {
executeJob(job);
}
}
/**
* Executes a job from the queue.
*
* @param job The job to execute
*/
private void executeJob(QueuedJob job) {
if (job.cancelled) {
log.debug("Job {} was cancelled, not executing", job.jobId);
return;
}
jobExecutor.execute(
() -> {
log.debug("Executing queued job {} (queued at {})", job.jobId, job.queuedAt);
try {
// Execute with timeout
Object result = executeWithTimeout(job.work, job.timeoutMs);
// Process the result
if (result instanceof ResponseEntity) {
job.future.complete((ResponseEntity<?>) result);
} else {
job.future.complete(ResponseEntity.ok(result));
}
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", job.jobId, e.getMessage(), e);
job.future.completeExceptionally(e);
}
});
}
/**
* Execute a supplier with a timeout.
*
* @param supplier The supplier to execute
* @param timeoutMs The timeout in milliseconds
* @return The result from the supplier
* @throws Exception If there is an execution error
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs) throws Exception {
CompletableFuture<T> future = CompletableFuture.supplyAsync(supplier);
try {
if (timeoutMs <= 0) {
// No timeout
return future.join();
} else {
// With timeout
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
}
} catch (TimeoutException e) {
future.cancel(true);
throw new TimeoutException("Job timed out after " + timeoutMs + "ms");
} catch (ExecutionException e) {
throw (Exception) e.getCause();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InterruptedException("Job was interrupted");
}
}
/**
* Checks if a job is queued.
*
* @param jobId The job ID
* @return true if the job is queued
*/
public boolean isJobQueued(String jobId) {
return jobMap.containsKey(jobId);
}
/**
* Gets the current position of a job in the queue.
*
* @param jobId The job ID
* @return The position (0-based) or -1 if not found
*/
public int getJobPosition(String jobId) {
if (!jobMap.containsKey(jobId)) {
return -1;
}
// Count positions
int position = 0;
for (QueuedJob job : jobQueue) {
if (job.jobId.equals(jobId)) {
return position;
}
position++;
}
// If we didn't find it in the queue but it's in the map,
// it might be executing already
return -1;
}
/**
* Cancels a queued job.
*
* @param jobId The job ID
* @return true if the job was cancelled, false if not found
*/
public boolean cancelJob(String jobId) {
QueuedJob job = jobMap.remove(jobId);
if (job != null) {
job.cancelled = true;
job.future.completeExceptionally(new RuntimeException("Job cancelled by user"));
// Try to remove from queue if it's still there
jobQueue.remove(job);
currentQueueSize = jobQueue.size();
log.debug("Job {} cancelled", jobId);
return true;
}
return false;
}
/**
* Get queue statistics.
*
* @return A map containing queue statistics
*/
public Map<String, Object> getQueueStats() {
return Map.of(
"queuedJobs", jobQueue.size(),
"queueCapacity", getQueueCapacity(),
"totalQueuedJobs", totalQueuedJobs,
"rejectedJobs", rejectedJobs,
"resourceStatus", resourceMonitor.getCurrentStatus().get().name());
}
}
@@ -0,0 +1,113 @@
package stirling.software.common.service;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.PdfMetadata;
@Service
public class PdfMetadataService {
private final ApplicationProperties applicationProperties;
private final String stirlingPDFLabel;
private final UserServiceInterface userService;
private final boolean runningProOrHigher;
public PdfMetadataService(
ApplicationProperties applicationProperties,
@Qualifier("StirlingPDFLabel") String stirlingPDFLabel,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
@Autowired(required = false) UserServiceInterface userService) {
this.applicationProperties = applicationProperties;
this.stirlingPDFLabel = stirlingPDFLabel;
this.userService = userService;
this.runningProOrHigher = runningProOrHigher;
}
public PdfMetadata extractMetadataFromPdf(PDDocument pdf) {
return PdfMetadata.builder()
.author(pdf.getDocumentInformation().getAuthor())
.producer(pdf.getDocumentInformation().getProducer())
.title(pdf.getDocumentInformation().getTitle())
.creator(pdf.getDocumentInformation().getCreator())
.subject(pdf.getDocumentInformation().getSubject())
.keywords(pdf.getDocumentInformation().getKeywords())
.creationDate(pdf.getDocumentInformation().getCreationDate())
.modificationDate(pdf.getDocumentInformation().getModificationDate())
.build();
}
public void setDefaultMetadata(PDDocument pdf) {
PdfMetadata metadata = extractMetadataFromPdf(pdf);
setMetadataToPdf(pdf, metadata);
}
public void setMetadataToPdf(PDDocument pdf, PdfMetadata pdfMetadata) {
setMetadataToPdf(pdf, pdfMetadata, false);
}
public void setMetadataToPdf(PDDocument pdf, PdfMetadata pdfMetadata, boolean newlyCreated) {
if (newlyCreated || pdfMetadata.getCreationDate() == null) {
setNewDocumentMetadata(pdf, pdfMetadata);
}
setCommonMetadata(pdf, pdfMetadata);
}
private void setNewDocumentMetadata(PDDocument pdf, PdfMetadata pdfMetadata) {
String creator = stirlingPDFLabel;
if (applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.isAutoUpdateMetadata()
&& runningProOrHigher) {
creator =
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getCreator();
pdf.getDocumentInformation().setProducer(stirlingPDFLabel);
}
pdf.getDocumentInformation().setCreator(creator);
pdf.getDocumentInformation().setCreationDate(Calendar.getInstance());
}
private void setCommonMetadata(PDDocument pdf, PdfMetadata pdfMetadata) {
String title = pdfMetadata.getTitle();
pdf.getDocumentInformation().setTitle(title);
pdf.getDocumentInformation().setProducer(stirlingPDFLabel);
pdf.getDocumentInformation().setSubject(pdfMetadata.getSubject());
pdf.getDocumentInformation().setKeywords(pdfMetadata.getKeywords());
pdf.getDocumentInformation().setModificationDate(Calendar.getInstance());
String author = pdfMetadata.getAuthor();
if (applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.isAutoUpdateMetadata()
&& runningProOrHigher) {
author =
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getAuthor();
if (userService != null) {
author = author.replace("username", userService.getCurrentUsername());
}
}
pdf.getDocumentInformation().setAuthor(author);
}
}
@@ -0,0 +1,424 @@
package stirling.software.common.service;
import java.io.File;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.lang.management.RuntimeMXBean;
import java.lang.management.ThreadMXBean;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import com.posthog.java.PostHog;
import stirling.software.common.model.ApplicationProperties;
@Service
public class PostHogService {
private final PostHog postHog;
private final String uniqueId;
private final String appVersion;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
private final Environment env;
private boolean configDirMounted;
public PostHogService(
PostHog postHog,
@Qualifier("UUID") String uuid,
@Qualifier("configDirMounted") boolean configDirMounted,
@Qualifier("appVersion") String appVersion,
ApplicationProperties applicationProperties,
@Autowired(required = false) UserServiceInterface userService,
Environment env) {
this.postHog = postHog;
this.uniqueId = uuid;
this.appVersion = appVersion;
this.applicationProperties = applicationProperties;
this.userService = userService;
this.env = env;
this.configDirMounted = configDirMounted;
captureSystemInfo();
}
private void captureSystemInfo() {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
return;
}
try {
postHog.capture(uniqueId, "system_info_captured", captureServerMetrics());
} catch (Exception e) {
// Handle exceptions
}
}
public void captureEvent(String eventName, Map<String, Object> properties) {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
return;
}
properties.put("app_version", appVersion);
postHog.capture(uniqueId, eventName, properties);
}
public Map<String, Object> captureServerMetrics() {
Map<String, Object> metrics = new HashMap<>();
try {
// Application version
metrics.put("app_version", appVersion);
String deploymentType = "JAR"; // default
if ("true".equalsIgnoreCase(env.getProperty("BROWSER_OPEN"))) {
deploymentType = "EXE";
} else if (isRunningInDocker()) {
deploymentType = "DOCKER";
}
metrics.put("deployment_type", deploymentType);
metrics.put("mounted_config_dir", configDirMounted);
// System info
metrics.put("os_name", System.getProperty("os.name"));
metrics.put("os_version", System.getProperty("os.version"));
metrics.put("java_version", System.getProperty("java.version"));
metrics.put("user_name", System.getProperty("user.name"));
metrics.put("user_home", System.getProperty("user.home"));
metrics.put("user_dir", System.getProperty("user.dir"));
// CPU and Memory
metrics.put("cpu_cores", Runtime.getRuntime().availableProcessors());
metrics.put("total_memory", Runtime.getRuntime().totalMemory());
metrics.put("free_memory", Runtime.getRuntime().freeMemory());
// Network and Server Identity
InetAddress localHost = InetAddress.getLocalHost();
metrics.put("ip_address", localHost.getHostAddress());
metrics.put("hostname", localHost.getHostName());
metrics.put("mac_address", getMacAddress());
// JVM info
metrics.put("jvm_vendor", System.getProperty("java.vendor"));
metrics.put("jvm_version", System.getProperty("java.vm.version"));
// Locale and Timezone
metrics.put("system_language", System.getProperty("user.language"));
metrics.put("system_country", System.getProperty("user.country"));
metrics.put("timezone", TimeZone.getDefault().getID());
metrics.put("locale", Locale.getDefault().toString());
// Disk info
File root = new File(".");
metrics.put("total_disk_space", root.getTotalSpace());
metrics.put("free_disk_space", root.getFreeSpace());
// Process info
metrics.put("process_id", ProcessHandle.current().pid());
// JVM metrics
RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
metrics.put("jvm_uptime_ms", runtimeMXBean.getUptime());
metrics.put("jvm_start_time", runtimeMXBean.getStartTime());
// Memory metrics
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
metrics.put("heap_memory_usage", memoryMXBean.getHeapMemoryUsage().getUsed());
metrics.put("non_heap_memory_usage", memoryMXBean.getNonHeapMemoryUsage().getUsed());
// CPU metrics
OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
metrics.put("system_load_average", osMXBean.getSystemLoadAverage());
// Thread metrics
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
metrics.put("thread_count", threadMXBean.getThreadCount());
metrics.put("daemon_thread_count", threadMXBean.getDaemonThreadCount());
metrics.put("peak_thread_count", threadMXBean.getPeakThreadCount());
// Garbage collection metrics
for (GarbageCollectorMXBean gcBean : ManagementFactory.getGarbageCollectorMXBeans()) {
metrics.put("gc_" + gcBean.getName() + "_count", gcBean.getCollectionCount());
metrics.put("gc_" + gcBean.getName() + "_time", gcBean.getCollectionTime());
}
// Network interfaces
metrics.put("network_interfaces", getNetworkInterfacesInfo());
// Docker detection and stats
boolean isDocker = isRunningInDocker();
if (isDocker) {
metrics.put("docker_metrics", getDockerMetrics());
}
metrics.put("application_properties", captureApplicationProperties());
if (userService != null) {
metrics.put("total_users_created", userService.getTotalUsersCount());
}
} catch (Exception e) {
metrics.put("error", e.getMessage());
}
return metrics;
}
private boolean isRunningInDocker() {
return Files.exists(Paths.get("/.dockerenv"));
}
private Map<String, Object> getDockerMetrics() {
Map<String, Object> dockerMetrics = new HashMap<>();
// Network-related Docker info
dockerMetrics.put("docker_network_mode", System.getenv("DOCKER_NETWORK_MODE"));
// Container name (if set)
String containerName = System.getenv("CONTAINER_NAME");
if (containerName != null && !containerName.isEmpty()) {
dockerMetrics.put("container_name", containerName);
}
// Docker compose information
String composeProject = System.getenv("COMPOSE_PROJECT_NAME");
String composeService = System.getenv("COMPOSE_SERVICE_NAME");
if (composeProject != null && composeService != null) {
dockerMetrics.put("compose_project", composeProject);
dockerMetrics.put("compose_service", composeService);
}
// Kubernetes-specific info (if running in K8s)
String k8sPodName = System.getenv("KUBERNETES_POD_NAME");
if (k8sPodName != null) {
dockerMetrics.put("k8s_pod_name", k8sPodName);
dockerMetrics.put("k8s_namespace", System.getenv("KUBERNETES_NAMESPACE"));
dockerMetrics.put("k8s_node_name", System.getenv("KUBERNETES_NODE_NAME"));
}
// New environment variables
dockerMetrics.put("version_tag", System.getenv("VERSION_TAG"));
dockerMetrics.put("additional_features_off", System.getenv("ADDITIONAL_FEATURES_OFF"));
dockerMetrics.put("fat_docker", System.getenv("FAT_DOCKER"));
return dockerMetrics;
}
private void addIfNotEmpty(Map<String, Object> map, String key, Object value) {
if (value != null) {
if (value instanceof String strValue) {
if (!StringUtils.isBlank(strValue)) {
map.put(key, strValue.trim());
}
} else {
map.put(key, value);
}
}
}
public Map<String, Object> captureApplicationProperties() {
Map<String, Object> properties = new HashMap<>();
// Capture Legal properties
addIfNotEmpty(
properties,
"legal_termsAndConditions",
applicationProperties.getLegal().getTermsAndConditions());
addIfNotEmpty(
properties,
"legal_privacyPolicy",
applicationProperties.getLegal().getPrivacyPolicy());
addIfNotEmpty(
properties,
"legal_accessibilityStatement",
applicationProperties.getLegal().getAccessibilityStatement());
addIfNotEmpty(
properties,
"legal_cookiePolicy",
applicationProperties.getLegal().getCookiePolicy());
addIfNotEmpty(
properties, "legal_impressum", applicationProperties.getLegal().getImpressum());
// Capture Security properties
addIfNotEmpty(
properties,
"security_enableLogin",
applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(
properties,
"security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled());
addIfNotEmpty(
properties,
"security_loginAttemptCount",
applicationProperties.getSecurity().getLoginAttemptCount());
addIfNotEmpty(
properties,
"security_loginResetTimeMinutes",
applicationProperties.getSecurity().getLoginResetTimeMinutes());
addIfNotEmpty(
properties,
"security_loginMethod",
applicationProperties.getSecurity().getLoginMethod());
// Capture OAuth2 properties (excluding sensitive information)
addIfNotEmpty(
properties,
"security_oauth2_enabled",
applicationProperties.getSecurity().getOauth2().getEnabled());
if (applicationProperties.getSecurity().getOauth2().getEnabled()) {
addIfNotEmpty(
properties,
"security_oauth2_autoCreateUser",
applicationProperties.getSecurity().getOauth2().getAutoCreateUser());
addIfNotEmpty(
properties,
"security_oauth2_blockRegistration",
applicationProperties.getSecurity().getOauth2().getBlockRegistration());
addIfNotEmpty(
properties,
"security_oauth2_useAsUsername",
applicationProperties.getSecurity().getOauth2().getUseAsUsername());
addIfNotEmpty(
properties,
"security_oauth2_provider",
applicationProperties.getSecurity().getOauth2().getProvider());
}
// Capture System properties
addIfNotEmpty(
properties,
"system_defaultLocale",
applicationProperties.getSystem().getDefaultLocale());
addIfNotEmpty(
properties,
"system_googlevisibility",
applicationProperties.getSystem().getGooglevisibility());
addIfNotEmpty(
properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate());
addIfNotEmpty(
properties,
"system_showUpdateOnlyAdmin",
applicationProperties.getSystem().getShowUpdateOnlyAdmin());
addIfNotEmpty(
properties,
"system_customHTMLFiles",
applicationProperties.getSystem().isCustomHTMLFiles());
addIfNotEmpty(
properties,
"system_tessdataDir",
applicationProperties.getSystem().getTessdataDir());
addIfNotEmpty(
properties,
"system_enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
addIfNotEmpty(
properties,
"system_enableAnalytics",
applicationProperties.getSystem().isAnalyticsEnabled());
// Capture UI properties
addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName());
addIfNotEmpty(
properties,
"ui_homeDescription",
applicationProperties.getUi().getHomeDescription());
addIfNotEmpty(
properties, "ui_appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
// Capture Metrics properties
addIfNotEmpty(
properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled());
// Capture EnterpriseEdition properties
addIfNotEmpty(
properties,
"enterpriseEdition_enabled",
applicationProperties.getPremium().isEnabled());
if (applicationProperties.getPremium().isEnabled()) {
addIfNotEmpty(
properties,
"enterpriseEdition_customMetadata_autoUpdateMetadata",
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.isAutoUpdateMetadata());
addIfNotEmpty(
properties,
"enterpriseEdition_customMetadata_author",
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getAuthor());
addIfNotEmpty(
properties,
"enterpriseEdition_customMetadata_creator",
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getCreator());
addIfNotEmpty(
properties,
"enterpriseEdition_customMetadata_producer",
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getProducer());
}
// Capture AutoPipeline properties
addIfNotEmpty(
properties,
"autoPipeline_outputFolder",
applicationProperties.getAutoPipeline().getOutputFolder());
return properties;
}
private String getMacAddress() {
try {
Enumeration<NetworkInterface> networkInterfaces =
NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface ni = networkInterfaces.nextElement();
byte[] hardwareAddress = ni.getHardwareAddress();
if (hardwareAddress != null) {
String[] hexadecimal = new String[hardwareAddress.length];
for (int i = 0; i < hardwareAddress.length; i++) {
hexadecimal[i] = String.format("%02X", hardwareAddress[i]);
}
return String.join("-", hexadecimal);
}
}
} catch (Exception e) {
// Handle exception
}
return "Unknown";
}
private Map<String, String> getNetworkInterfacesInfo() {
Map<String, String> interfacesInfo = new HashMap<>();
try {
Enumeration<NetworkInterface> nets = NetworkInterface.getNetworkInterfaces();
while (nets.hasMoreElements()) {
NetworkInterface netint = nets.nextElement();
interfacesInfo.put(netint.getName(), netint.getDisplayName());
}
} catch (Exception e) {
interfacesInfo.put("error", e.getMessage());
}
return interfacesInfo;
}
}
@@ -0,0 +1,279 @@
package stirling.software.common.service;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
/**
* Monitors system resources (CPU, memory) to inform job scheduling decisions. Provides information
* about available resources to prevent overloading the system.
*/
@Service
@Slf4j
public class ResourceMonitor {
@Value("${stirling.resource.memory.critical-threshold:0.9}")
private double memoryCriticalThreshold = 0.9; // 90% usage is critical
@Value("${stirling.resource.memory.high-threshold:0.75}")
private double memoryHighThreshold = 0.75; // 75% usage is high
@Value("${stirling.resource.cpu.critical-threshold:0.9}")
private double cpuCriticalThreshold = 0.9; // 90% usage is critical
@Value("${stirling.resource.cpu.high-threshold:0.75}")
private double cpuHighThreshold = 0.75; // 75% usage is high
@Value("${stirling.resource.monitor.interval-ms:60000}")
private long monitorIntervalMs = 60000; // 60 seconds
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
@Getter
private final AtomicReference<ResourceStatus> currentStatus =
new AtomicReference<>(ResourceStatus.OK);
@Getter
private final AtomicReference<ResourceMetrics> latestMetrics =
new AtomicReference<>(new ResourceMetrics());
/** Represents the current status of system resources. */
public enum ResourceStatus {
/** Resources are available, normal operations can proceed */
OK,
/** Resources are under strain, consider queueing high-resource operations */
WARNING,
/** Resources are critically low, queue all operations */
CRITICAL
}
/** Detailed metrics about system resources. */
@Getter
public static class ResourceMetrics {
private final double cpuUsage;
private final double memoryUsage;
private final long freeMemoryBytes;
private final long totalMemoryBytes;
private final long maxMemoryBytes;
private final Instant timestamp;
public ResourceMetrics() {
this(0, 0, 0, 0, 0, Instant.now());
}
public ResourceMetrics(
double cpuUsage,
double memoryUsage,
long freeMemoryBytes,
long totalMemoryBytes,
long maxMemoryBytes,
Instant timestamp) {
this.cpuUsage = cpuUsage;
this.memoryUsage = memoryUsage;
this.freeMemoryBytes = freeMemoryBytes;
this.totalMemoryBytes = totalMemoryBytes;
this.maxMemoryBytes = maxMemoryBytes;
this.timestamp = timestamp;
}
/**
* Gets the age of these metrics.
*
* @return Duration since these metrics were collected
*/
public Duration getAge() {
return Duration.between(timestamp, Instant.now());
}
/**
* Check if these metrics are stale (older than threshold).
*
* @param thresholdMs Staleness threshold in milliseconds
* @return true if metrics are stale
*/
public boolean isStale(long thresholdMs) {
return getAge().toMillis() > thresholdMs;
}
}
@PostConstruct
public void initialize() {
log.debug("Starting resource monitoring with interval of {}ms", monitorIntervalMs);
scheduler.scheduleAtFixedRate(
this::updateResourceMetrics, 0, monitorIntervalMs, TimeUnit.MILLISECONDS);
}
@PreDestroy
public void shutdown() {
log.info("Shutting down resource monitoring");
scheduler.shutdownNow();
}
/** Updates the resource metrics by sampling current system state. */
private void updateResourceMetrics() {
try {
// Get CPU usage
double cpuUsage = osMXBean.getSystemLoadAverage() / osMXBean.getAvailableProcessors();
if (cpuUsage < 0) cpuUsage = getAlternativeCpuLoad(); // Fallback if not available
// Get memory usage
long heapUsed = memoryMXBean.getHeapMemoryUsage().getUsed();
long nonHeapUsed = memoryMXBean.getNonHeapMemoryUsage().getUsed();
long totalUsed = heapUsed + nonHeapUsed;
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
double memoryUsage = (double) totalUsed / maxMemory;
// Create new metrics
ResourceMetrics metrics =
new ResourceMetrics(
cpuUsage,
memoryUsage,
freeMemory,
totalMemory,
maxMemory,
Instant.now());
latestMetrics.set(metrics);
// Determine system status
ResourceStatus newStatus;
if (cpuUsage > cpuCriticalThreshold || memoryUsage > memoryCriticalThreshold) {
newStatus = ResourceStatus.CRITICAL;
} else if (cpuUsage > cpuHighThreshold || memoryUsage > memoryHighThreshold) {
newStatus = ResourceStatus.WARNING;
} else {
newStatus = ResourceStatus.OK;
}
// Update status if it changed
ResourceStatus oldStatus = currentStatus.getAndSet(newStatus);
if (oldStatus != newStatus) {
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));
}
} catch (Exception e) {
log.error("Error updating resource metrics: {}", e.getMessage(), e);
}
}
/**
* Alternative method to estimate CPU load if getSystemLoadAverage() is not available. This is a
* fallback and less accurate than the official JMX method.
*
* @return Estimated CPU load as a value between 0.0 and 1.0
*/
private double getAlternativeCpuLoad() {
try {
// Try to get CPU time if available through reflection
// This is a fallback since we can't directly cast to platform-specific classes
try {
java.lang.reflect.Method m =
osMXBean.getClass().getDeclaredMethod("getProcessCpuLoad");
m.setAccessible(true);
return (double) m.invoke(osMXBean);
} catch (Exception e) {
// Try the older method
try {
java.lang.reflect.Method m =
osMXBean.getClass().getDeclaredMethod("getSystemCpuLoad");
m.setAccessible(true);
return (double) m.invoke(osMXBean);
} catch (Exception e2) {
log.trace(
"Could not get CPU load through reflection, assuming moderate load (0.5)");
return 0.5;
}
}
} catch (Exception e) {
log.trace("Could not get CPU load, assuming moderate load (0.5)");
return 0.5; // Default to moderate load
}
}
/**
* Calculates the dynamic job queue capacity based on current resource usage.
*
* @param baseCapacity The base capacity when system is under minimal load
* @param minCapacity The minimum capacity to maintain even under high load
* @return The calculated job queue capacity
*/
public int calculateDynamicQueueCapacity(int baseCapacity, int minCapacity) {
ResourceMetrics metrics = latestMetrics.get();
ResourceStatus status = currentStatus.get();
// Simple linear reduction based on memory and CPU load
double capacityFactor =
switch (status) {
case OK -> 1.0;
case WARNING -> 0.6;
case CRITICAL -> 0.3;
};
// Apply additional reduction based on specific memory pressure
if (metrics.memoryUsage > 0.8) {
capacityFactor *= 0.5; // Further reduce capacity under memory pressure
}
// Calculate capacity with minimum safeguard
int capacity = (int) Math.max(minCapacity, Math.ceil(baseCapacity * capacityFactor));
log.debug(
"Dynamic queue capacity: {} (base: {}, factor: {:.2f}, status: {})",
capacity,
baseCapacity,
capacityFactor,
status);
return capacity;
}
/**
* Checks if a job with the given weight can be executed immediately or should be queued based
* on current resource availability.
*
* @param resourceWeight The resource weight of the job (1-100)
* @return true if the job should be queued, false if it can run immediately
*/
public boolean shouldQueueJob(int resourceWeight) {
ResourceStatus status = currentStatus.get();
// Always run lightweight jobs (weight < 20) unless critical
if (resourceWeight < 20 && status != ResourceStatus.CRITICAL) {
return false;
}
// Medium weight jobs run immediately if resources are OK
if (resourceWeight < 60 && status == ResourceStatus.OK) {
return false;
}
// Heavy jobs (weight >= 60) and any job during WARNING/CRITICAL should be queued
return true;
}
}
@@ -0,0 +1,466 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.ZipSecurity;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
/** Manages async tasks and their results */
@Service
@Slf4j
public class TaskManager {
private final Map<String, JobResult> jobResults = new ConcurrentHashMap<>();
@Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30;
private final FileStorage fileStorage;
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor();
/** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) {
this.fileStorage = fileStorage;
// Schedule periodic cleanup of old job results
cleanupExecutor.scheduleAtFixedRate(
this::cleanupOldJobs,
10, // Initial delay
10, // Interval
TimeUnit.MINUTES);
log.debug(
"Task manager initialized with job result expiry of {} minutes",
jobResultExpiryMinutes);
}
/**
* Create a new task with the given job ID
*
* @param jobId The job ID
*/
public void createTask(String jobId) {
jobResults.put(jobId, JobResult.createNew(jobId));
log.debug("Created task with job ID: {}", jobId);
}
/**
* Set the result of a task as a general object
*
* @param jobId The job ID
* @param result The result object
*/
public void setResult(String jobId, Object result) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithResult(result);
log.debug("Set result for job ID: {}", jobId);
}
/**
* Set the result of a task as a file
*
* @param jobId The job ID
* @param fileId The file ID
* @param originalFileName The original file name
* @param contentType The content type of the file
*/
public void setFileResult(
String jobId, String fileId, String originalFileName, String contentType) {
JobResult jobResult = getOrCreateJobResult(jobId);
// Check if this is a ZIP file that should be extracted
if (isZipFile(contentType, originalFileName)) {
try {
List<ResultFile> extractedFiles =
extractZipToIndividualFiles(fileId, originalFileName);
if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files extracted from ZIP",
jobId,
extractedFiles.size());
return;
}
} catch (Exception e) {
log.warn(
"Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
jobId,
e.getMessage());
}
}
// Handle as single file using new ResultFile approach
try {
long fileSize = fileStorage.getFileSize(fileId);
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, fileSize);
log.debug("Set single file result for job ID: {} with file ID: {}", jobId, fileId);
} catch (Exception e) {
log.warn(
"Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage());
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0);
}
}
/**
* Set the result of a task as multiple files
*
* @param jobId The job ID
* @param resultFiles The list of result files
*/
public void setMultipleFileResults(String jobId, List<ResultFile> resultFiles) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFiles(resultFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files",
jobId,
resultFiles.size());
}
/**
* Set an error for a task
*
* @param jobId The job ID
* @param error The error message
*/
public void setError(String jobId, String error) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.failWithError(error);
log.debug("Set error for job ID: {}: {}", jobId, error);
}
/**
* Mark a task as complete
*
* @param jobId The job ID
*/
public void setComplete(String jobId) {
JobResult jobResult = getOrCreateJobResult(jobId);
if (jobResult.getResult() == null
&& !jobResult.hasFiles()
&& jobResult.getError() == null) {
// If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully");
}
log.debug("Marked job ID: {} as complete", jobId);
}
/**
* Check if a task is complete
*
* @param jobId The job ID
* @return true if the task is complete, false otherwise
*/
public boolean isComplete(String jobId) {
JobResult result = jobResults.get(jobId);
return result != null && result.isComplete();
}
/**
* Get the result of a task
*
* @param jobId The job ID
* @return The result object, or null if the task doesn't exist or is not complete
*/
public JobResult getJobResult(String jobId) {
return jobResults.get(jobId);
}
/**
* Add a note to a task. Notes are informational messages that can be attached to a job for
* tracking purposes.
*
* @param jobId The job ID
* @param note The note to add
* @return true if the note was added successfully, false if the job doesn't exist
*/
public boolean addNote(String jobId, String note) {
JobResult jobResult = jobResults.get(jobId);
if (jobResult != null) {
jobResult.addNote(note);
log.debug("Added note to job ID: {}: {}", jobId, note);
return true;
}
log.warn("Attempted to add note to non-existent job ID: {}", jobId);
return false;
}
/**
* Get statistics about all jobs in the system
*
* @return Job statistics
*/
public JobStats getJobStats() {
int totalJobs = jobResults.size();
int activeJobs = 0;
int completedJobs = 0;
int failedJobs = 0;
int successfulJobs = 0;
int fileResultJobs = 0;
LocalDateTime oldestActiveJobTime = null;
LocalDateTime newestActiveJobTime = null;
long totalProcessingTimeMs = 0;
for (JobResult result : jobResults.values()) {
if (result.isComplete()) {
completedJobs++;
// Calculate processing time for completed jobs
if (result.getCreatedAt() != null && result.getCompletedAt() != null) {
long processingTimeMs =
java.time.Duration.between(
result.getCreatedAt(), result.getCompletedAt())
.toMillis();
totalProcessingTimeMs += processingTimeMs;
}
if (result.getError() != null) {
failedJobs++;
} else {
successfulJobs++;
if (result.hasFiles()) {
fileResultJobs++;
}
}
} else {
activeJobs++;
// Track oldest and newest active jobs
if (result.getCreatedAt() != null) {
if (oldestActiveJobTime == null
|| result.getCreatedAt().isBefore(oldestActiveJobTime)) {
oldestActiveJobTime = result.getCreatedAt();
}
if (newestActiveJobTime == null
|| result.getCreatedAt().isAfter(newestActiveJobTime)) {
newestActiveJobTime = result.getCreatedAt();
}
}
}
}
// Calculate average processing time
long averageProcessingTimeMs =
completedJobs > 0 ? totalProcessingTimeMs / completedJobs : 0;
return JobStats.builder()
.totalJobs(totalJobs)
.activeJobs(activeJobs)
.completedJobs(completedJobs)
.failedJobs(failedJobs)
.successfulJobs(successfulJobs)
.fileResultJobs(fileResultJobs)
.oldestActiveJobTime(oldestActiveJobTime)
.newestActiveJobTime(newestActiveJobTime)
.averageProcessingTimeMs(averageProcessingTimeMs)
.build();
}
/**
* Get or create a job result
*
* @param jobId The job ID
* @return The job result
*/
private JobResult getOrCreateJobResult(String jobId) {
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
}
/** Clean up old completed job results */
public void cleanupOldJobs() {
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
// Remove completed jobs that are older than the expiry threshold
if (result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
// Clean up file results
cleanupJobFiles(result, entry.getKey());
// Remove the job result
jobResults.remove(entry.getKey());
removedCount++;
}
}
if (removedCount > 0) {
log.info("Cleaned up {} expired job results", removedCount);
}
} catch (Exception e) {
log.error("Error during job cleanup: {}", e.getMessage(), e);
}
}
/** Shutdown the cleanup executor */
@PreDestroy
public void shutdown() {
try {
log.info("Shutting down job result cleanup executor");
cleanupExecutor.shutdown();
if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
cleanupExecutor.shutdownNow();
}
}
/** Check if a file is a ZIP file based on content type and filename */
private boolean isZipFile(String contentType, String fileName) {
if (contentType != null
&& (contentType.equals("application/zip")
|| contentType.equals("application/x-zip-compressed"))) {
return true;
}
if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
return true;
}
return false;
}
/** Extract a ZIP file into individual files and store them */
private List<ResultFile> extractZipToIndividualFiles(
String zipFileId, String originalZipFileName) throws IOException {
List<ResultFile> extractedFiles = new ArrayList<>();
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(
new ByteArrayInputStream(zipFile.getBytes()))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
// Use buffered reading for memory safety
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = zipIn.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
byte[] fileContent = out.toByteArray();
String contentType = determineContentType(entry.getName());
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
ResultFile resultFile =
ResultFile.builder()
.fileId(individualFileId)
.fileName(entry.getName())
.contentType(contentType)
.fileSize(fileContent.length)
.build();
extractedFiles.add(resultFile);
log.debug(
"Extracted file: {} (size: {} bytes)",
entry.getName(),
fileContent.length);
}
zipIn.closeEntry();
}
}
// Clean up the original ZIP file after extraction
try {
fileStorage.deleteFile(zipFileId);
log.debug("Cleaned up original ZIP file: {}", zipFileId);
} catch (Exception e) {
log.warn("Failed to clean up original ZIP file {}: {}", zipFileId, e.getMessage());
}
return extractedFiles;
}
/** Determine content type based on file extension */
private String determineContentType(String fileName) {
if (fileName == null) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".pdf")) {
return MediaType.APPLICATION_PDF_VALUE;
} else if (lowerName.endsWith(".txt")) {
return MediaType.TEXT_PLAIN_VALUE;
} else if (lowerName.endsWith(".json")) {
return MediaType.APPLICATION_JSON_VALUE;
} else if (lowerName.endsWith(".xml")) {
return MediaType.APPLICATION_XML_VALUE;
} else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) {
return MediaType.IMAGE_JPEG_VALUE;
} else if (lowerName.endsWith(".png")) {
return MediaType.IMAGE_PNG_VALUE;
} else {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
/** Clean up files associated with a job result */
private void cleanupJobFiles(JobResult result, String jobId) {
// Clean up all result files
if (result.hasFiles()) {
for (ResultFile resultFile : result.getAllResultFiles()) {
try {
fileStorage.deleteFile(resultFile.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file {} for job {}: {}",
resultFile.getFileId(),
jobId,
e.getMessage());
}
}
}
}
/** Find the ResultFile metadata for a given file ID by searching through all job results */
public ResultFile findResultFileByFileId(String fileId) {
for (JobResult jobResult : jobResults.values()) {
if (jobResult.hasFiles()) {
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
if (fileId.equals(resultFile.getFileId())) {
return resultFile;
}
}
}
}
return null;
}
}
@@ -0,0 +1,449 @@
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-")
|| "proc".equals(fileName)
|| "sys".equals(fileName)
|| "dev".equals(fileName)
|| "hsperfdata_stirlingpdfuser".equals(fileName)
|| fileName.startsWith("hsperfdata_")
|| ".pdfbox.cache".equals(fileName);
@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);
if (registeredDeletedCount > 0
|| unregisteredDeletedCount > 0
|| directoriesDeletedCount > 0) {
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() {
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.debug(
"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);
}
}
}
@@ -0,0 +1,9 @@
package stirling.software.common.service;
public interface UserServiceInterface {
String getApiKeyForUser(String username);
String getCurrentUsername();
long getTotalUsersCount();
}
@@ -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;
}
}
}
@@ -0,0 +1,50 @@
package stirling.software.common.util;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PageMode;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class AttachmentUtils {
/**
* Sets the PDF catalog viewer preferences to display attachments in the viewer.
*
* @param document The <code>PDDocument</code> to modify.
* @param pageMode The <code>PageMode</code> to set for the PDF viewer. <code>PageMode</code>
* values: <code>UseNone</code>, <code>UseOutlines</code>, <code>UseThumbs</code>, <code>
* FullScreen</code>, <code>UseOC</code>, <code>UseAttachments</code>.
*/
public static void setCatalogViewerPreferences(PDDocument document, PageMode pageMode) {
try {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog != null) {
COSDictionary catalogDict = catalog.getCOSObject();
catalog.setPageMode(pageMode);
catalogDict.setName(COSName.PAGE_MODE, pageMode.stringValue());
COSDictionary viewerPrefs =
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
if (viewerPrefs == null) {
viewerPrefs = new COSDictionary();
catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
}
viewerPrefs.setName(
COSName.getPDFName("NonFullScreenPageMode"), pageMode.stringValue());
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
log.info(
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
}
} catch (Exception e) {
log.error("Failed to set catalog viewer preferences for attachments", e);
}
}
}
@@ -0,0 +1,59 @@
package stirling.software.common.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
public class CheckProgramInstall {
private static final List<String> PYTHON_COMMANDS = Arrays.asList("python3", "python");
private static boolean pythonAvailableChecked = false;
private static String availablePythonCommand = null;
/**
* Checks which Python command is available and returns it.
*
* @return The available Python command ("python3" or "python"), or null if neither is
* available.
*/
public static String getAvailablePythonCommand() {
if (!pythonAvailableChecked) {
availablePythonCommand =
PYTHON_COMMANDS.stream()
.filter(CheckProgramInstall::checkPythonVersion)
.findFirst()
.orElse(null);
pythonAvailableChecked = true;
}
return availablePythonCommand;
}
/**
* Checks if the specified command is available by running the command with --version.
*
* @param pythonCommand The Python command to check.
* @return true if the command is available, false otherwise.
*/
private static boolean checkPythonVersion(String pythonCommand) {
try {
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)
.runCommandWithOutputHandling(
Arrays.asList(pythonCommand, "--version"));
return true; // Command succeeded, Python is available
} catch (IOException | InterruptedException e) {
return false; // Command failed, Python is not available
}
}
/**
* Checks if any Python command is available.
*
* @return true if any Python command is available, false otherwise.
*/
public static boolean isPythonAvailable() {
return getAvailablePythonCommand() != null;
}
}
@@ -0,0 +1,21 @@
package stirling.software.common.util;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;
public class CustomHtmlSanitizer {
private static final PolicyFactory POLICY =
Sanitizers.FORMATTING
.and(Sanitizers.BLOCKS)
.and(Sanitizers.STYLES)
.and(Sanitizers.LINKS)
.and(Sanitizers.TABLES)
.and(Sanitizers.IMAGES)
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
public static String sanitize(String html) {
String htmlAfter = POLICY.sanitize(html);
return htmlAfter;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
package stirling.software.common.util;
import java.io.PrintWriter;
import java.io.StringWriter;
import org.springframework.ui.Model;
import org.springframework.web.servlet.ModelAndView;
public class ErrorUtils {
public static Model exceptionToModel(Model model, Exception ex) {
StringWriter sw = new StringWriter();
ex.printStackTrace(new PrintWriter(sw));
String stackTrace = sw.toString();
model.addAttribute("errorMessage", ex.getMessage());
model.addAttribute("stackTrace", stackTrace);
return model;
}
public static ModelAndView exceptionToModelView(Model model, Exception ex) {
StringWriter sw = new StringWriter();
ex.printStackTrace(new PrintWriter(sw));
String stackTrace = sw.toString();
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("errorMessage", ex.getMessage());
modelAndView.addObject("stackTrace", stackTrace);
return modelAndView;
}
}
@@ -0,0 +1,327 @@
package stirling.software.common.util;
import java.io.IOException;
import java.text.MessageFormat;
import lombok.extern.slf4j.Slf4j;
/**
* Utility class for handling exceptions with internationalized error messages. Provides consistent
* error handling and user-friendly messages across the application.
*/
@Slf4j
public class ExceptionUtils {
/**
* Create an IOException with internationalized message for PDF corruption.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfCorruptedException(Exception cause) {
return createPdfCorruptedException(null, cause);
}
/**
* Create an IOException with internationalized message for PDF corruption with context.
*
* @param context additional context (e.g., "during merge", "during image extraction")
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfCorruptedException(String context, Exception cause) {
String message;
if (context != null && !context.isEmpty()) {
message =
String.format(
"Error %s: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.",
context);
} else {
message =
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
}
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for multiple corrupted PDFs.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createMultiplePdfCorruptedException(Exception cause) {
String message =
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for PDF encryption issues.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfEncryptionException(Exception cause) {
String message =
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for PDF password issues.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfPasswordException(Exception cause) {
String message =
"The PDF Document is passworded and either the password was not provided or was incorrect";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for file processing errors.
*
* @param operation the operation being performed (e.g., "merge", "split", "convert")
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createFileProcessingException(String operation, Exception cause) {
String message =
String.format(
"An error occurred while processing the file during %s operation: %s",
operation, cause.getMessage());
return new IOException(message, cause);
}
/**
* Create a generic IOException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param cause the original exception
* @param args optional arguments for the message
* @return IOException with user-friendly message
*/
public static IOException createIOException(
String messageKey, String defaultMessage, Exception cause, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new IOException(message, cause);
}
/**
* Create a generic RuntimeException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param cause the original exception
* @param args optional arguments for the message
* @return RuntimeException with user-friendly message
*/
public static RuntimeException createRuntimeException(
String messageKey, String defaultMessage, Exception cause, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new RuntimeException(message, cause);
}
/**
* Create an IllegalArgumentException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param args optional arguments for the message
* @return IllegalArgumentException with user-friendly message
*/
public static IllegalArgumentException createIllegalArgumentException(
String messageKey, String defaultMessage, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new IllegalArgumentException(message);
}
/** Create file validation exceptions. */
public static IllegalArgumentException createHtmlFileRequiredException() {
return createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "HTML or ZIP");
}
public static IllegalArgumentException createPdfFileRequiredException() {
return createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "PDF");
}
public static IllegalArgumentException createInvalidPageSizeException(String size) {
return createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "page size", size);
}
/** Create OCR-related exceptions. */
public static IOException createOcrLanguageRequiredException() {
return createIOException(
"error.optionsNotSpecified", "{0} options are not specified", null, "OCR language");
}
public static IOException createOcrInvalidLanguagesException() {
return createIOException(
"error.invalidFormat",
"Invalid {0} format: {1}",
null,
"OCR languages",
"none of the selected languages are valid");
}
public static IOException createOcrToolsUnavailableException() {
return createIOException(
"error.toolNotInstalled", "{0} is not installed", null, "OCR tools");
}
/** Create system requirement exceptions. */
public static IOException createPythonRequiredForWebpException() {
return createIOException(
"error.toolRequired", "{0} is required for {1}", null, "Python", "WebP conversion");
}
/** Create file operation exceptions. */
public static IOException createFileNotFoundException(String fileId) {
return createIOException("error.fileNotFound", "File not found with ID: {0}", null, fileId);
}
public static RuntimeException createPdfaConversionFailedException() {
return createRuntimeException(
"error.conversionFailed", "{0} conversion failed", null, "PDF/A");
}
public static IllegalArgumentException createInvalidComparatorException() {
return createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"comparator",
"only 'greater', 'equal', and 'less' are supported");
}
/** Create compression-related exceptions. */
public static RuntimeException createMd5AlgorithmException(Exception cause) {
return createRuntimeException(
"error.algorithmNotAvailable", "{0} algorithm not available", cause, "MD5");
}
public static IllegalArgumentException createCompressionOptionsException() {
return createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"compression (expected output size and optimize level)");
}
public static IOException createGhostscriptCompressionException() {
return createIOException(
"error.commandFailed", "{0} command failed", null, "Ghostscript compression");
}
public static IOException createGhostscriptCompressionException(Exception cause) {
return createIOException(
"error.commandFailed", "{0} command failed", cause, "Ghostscript compression");
}
public static IOException createQpdfCompressionException(Exception cause) {
return createIOException("error.commandFailed", "{0} command failed", cause, "QPDF");
}
/**
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
*
* @param e the exception to check
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
* message
*/
public static IOException handlePdfException(IOException e) {
return handlePdfException(e, null);
}
/**
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
*
* @param e the exception to check
* @param context additional context for the error
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
* message
*/
public static IOException handlePdfException(IOException e, String context) {
if (PdfErrorUtils.isCorruptedPdfError(e)) {
return createPdfCorruptedException(context, e);
}
if (isEncryptionError(e)) {
return createPdfEncryptionException(e);
}
if (isPasswordError(e)) {
return createPdfPasswordException(e);
}
return e; // Return original exception if no specific handling needed
}
/**
* Check if an exception indicates a PDF encryption/decryption error.
*
* @param e the exception to check
* @return true if it's an encryption error, false otherwise
*/
public static boolean isEncryptionError(IOException e) {
String message = e.getMessage();
if (message == null) return false;
return message.contains("BadPaddingException")
|| message.contains("Given final block not properly padded")
|| message.contains("AES initialization vector not fully read")
|| message.contains("Failed to decrypt");
}
/**
* Check if an exception indicates a PDF password error.
*
* @param e the exception to check
* @return true if it's a password error, false otherwise
*/
public static boolean isPasswordError(IOException e) {
String message = e.getMessage();
if (message == null) return false;
return message.contains("password is incorrect")
|| message.contains("Password is not provided")
|| message.contains("PDF contains an encryption dictionary");
}
/**
* Log an exception with appropriate level based on its type.
*
* @param operation the operation being performed
* @param e the exception that occurred
*/
public static void logException(String operation, Exception e) {
if (PdfErrorUtils.isCorruptedPdfError(e)) {
log.warn("PDF corruption detected during {}: {}", operation, e.getMessage());
} else if (e instanceof IOException
&& (isEncryptionError((IOException) e) || isPasswordError((IOException) e))) {
log.info("PDF security issue during {}: {}", operation, e.getMessage());
} else {
log.error("Unexpected error during {}", operation, e);
}
}
/** Create common validation exceptions. */
public static IllegalArgumentException createInvalidArgumentException(String argumentName) {
return createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", argumentName);
}
public static IllegalArgumentException createInvalidArgumentException(
String argumentName, String value) {
return createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", argumentName, value);
}
public static IllegalArgumentException createNullArgumentException(String argumentName) {
return createIllegalArgumentException(
"error.argumentRequired", "{0} must not be null", argumentName);
}
}
@@ -0,0 +1,31 @@
package stirling.software.common.util;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ExecutorFactory {
/**
* Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a
* cached thread pool on older Java versions.
*/
public static ExecutorService newVirtualOrCachedThreadExecutor() {
try {
ExecutorService executor =
(ExecutorService)
Executors.class
.getMethod("newVirtualThreadPerTaskExecutor")
.invoke(null);
return executor;
} catch (NoSuchMethodException e) {
log.debug("Virtual threads not available; falling back to cached thread pool.");
} catch (Exception e) {
log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e);
}
return Executors.newCachedThreadPool();
}
}
@@ -0,0 +1,203 @@
package stirling.software.common.util;
import static java.nio.file.StandardWatchEventKinds.*;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
@Component
@Slf4j
public class FileMonitor {
private final Map<Path, WatchKey> path2KeyMapping;
private final Set<Path> newlyDiscoveredFiles;
private final ConcurrentHashMap.KeySetView<Path, Boolean> readyForProcessingFiles;
private final WatchService watchService;
private final Predicate<Path> pathFilter;
private final Path rootDir;
private Set<Path> stagingFiles;
/**
* @param rootDirectory the root directory to monitor
* @param pathFilter the filter to apply to the paths, return true if the path should be
* monitored, false otherwise
*/
public FileMonitor(
@Qualifier("directoryFilter") Predicate<Path> pathFilter,
RuntimePathConfig runtimePathConfig)
throws IOException {
this.newlyDiscoveredFiles = new HashSet<>();
this.path2KeyMapping = new HashMap<>();
this.stagingFiles = new HashSet<>();
this.pathFilter = pathFilter;
this.readyForProcessingFiles = ConcurrentHashMap.newKeySet();
this.watchService = FileSystems.getDefault().newWatchService();
log.info("Monitoring directory: {}", runtimePathConfig.getPipelineWatchedFoldersPath());
this.rootDir = Path.of(runtimePathConfig.getPipelineWatchedFoldersPath());
}
private boolean shouldNotProcess(Path path) {
return !pathFilter.test(path);
}
private void recursivelyRegisterEntry(Path dir) throws IOException {
WatchKey key = dir.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
path2KeyMapping.put(dir, key);
log.info("Registered directory: {}", dir);
try (Stream<Path> directoryVisitor = Files.walk(dir, 1)) {
final Iterator<Path> iterator = directoryVisitor.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(dir) || shouldNotProcess(path)) continue;
if (Files.isDirectory(path)) {
recursivelyRegisterEntry(path);
} else if (Files.isRegularFile(path)) {
handleFileCreation(path);
}
}
}
}
@Scheduled(fixedRate = 5000)
public void trackFiles() {
/*
All files observed changes in the last iteration will be considered as staging files.
If those files are not modified in current iteration, they will be considered as ready for processing.
*/
stagingFiles = new HashSet<>(newlyDiscoveredFiles);
readyForProcessingFiles.clear();
if (path2KeyMapping.isEmpty()) {
log.warn("not monitoring any directory, even the root directory itself: {}", rootDir);
if (Files.exists(
rootDir)) { // if the root directory exists, re-register the root directory
try {
recursivelyRegisterEntry(rootDir);
} catch (IOException e) {
log.error("unable to register monitoring", e);
}
}
}
WatchKey key;
while ((key = watchService.poll()) != null) {
final Path watchingDir = (Path) key.watchable();
key.pollEvents()
.forEach(
(evt) -> {
final Path path = (Path) evt.context();
final WatchEvent.Kind<?> kind = evt.kind();
if (shouldNotProcess(path)) return;
try {
if (Files.isDirectory(path)) {
if (kind == ENTRY_CREATE) {
handleDirectoryCreation(path);
}
/*
we don't need to handle directory deletion or modification
- directory deletion will be handled by key.reset()
- directory modification indicates a new file creation or deletion, which is handled by below
*/
}
Path relativePathFromRoot = watchingDir.resolve(path);
if (kind == ENTRY_CREATE) {
handleFileCreation(relativePathFromRoot);
} else if (kind == ENTRY_DELETE) {
handleFileRemoval(relativePathFromRoot);
} else if (kind == ENTRY_MODIFY) {
handleFileModification(relativePathFromRoot);
}
} catch (Exception e) {
log.error("Error while processing file: {}", path, e);
}
});
boolean isKeyValid = key.reset();
if (!isKeyValid) { // key is invalid when the directory itself is no longer exists
path2KeyMapping.remove((Path) key.watchable());
}
}
readyForProcessingFiles.addAll(stagingFiles);
}
private void handleDirectoryCreation(Path dir) throws IOException {
WatchKey key = dir.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
path2KeyMapping.put(dir, key);
}
private void handleFileRemoval(Path path) {
newlyDiscoveredFiles.remove(path);
stagingFiles.remove(path);
}
private void handleFileCreation(Path path) {
newlyDiscoveredFiles.add(path);
stagingFiles.remove(path);
}
private void handleFileModification(Path path) {
// the logic is the same
handleFileCreation(path);
}
/**
* Check if the file is ready for processing.
*
* <p>A file is ready for processing if it is not being modified for 5000ms.
*
* @param path the path of the file
* @return true if the file is ready for processing, false otherwise
*/
public boolean isFileReadyForProcessing(Path path) {
// 1. Check FileMonitor's ready list
boolean isReady = readyForProcessingFiles.contains(path.toAbsolutePath());
// 2. Check last modified timestamp
if (!isReady) {
try {
long lastModified = Files.getLastModifiedTime(path).toMillis();
long currentTime = System.currentTimeMillis();
isReady = (currentTime - lastModified) > 5000;
} catch (IOException e) {
log.info("Timestamp check failed for {}", path, e);
}
}
// 3. Direct file lock check
if (isReady) {
try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw");
FileChannel channel = raf.getChannel()) {
// Try acquiring an exclusive lock
FileLock lock = channel.tryLock();
if (lock == null) {
isReady = false;
} else {
lock.release();
}
} catch (IOException e) {
log.info("File lock detected on {}", path);
isReady = false;
}
}
return isReady;
}
}
@@ -0,0 +1,213 @@
package stirling.software.common.util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import io.github.pixee.security.ZipSecurity;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
public class FileToPdf {
public static byte[] convertHtmlToPdf(
String weasyprintPath,
HTMLToPdfRequest request,
byte[] fileBytes,
String fileName,
boolean disableSanitize,
TempFileManager tempFileManager)
throws IOException, InterruptedException {
try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
try (TempFile tempInputFile =
new TempFile(
tempFileManager,
fileName.toLowerCase().endsWith(".html") ? ".html" : ".zip")) {
if (fileName.toLowerCase().endsWith(".html")) {
String sanitizedHtml =
sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8), disableSanitize);
Files.write(
tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else if (fileName.toLowerCase().endsWith(".zip")) {
Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip(
tempInputFile.getPath(), disableSanitize, tempFileManager);
} else {
throw ExceptionUtils.createHtmlFileRequiredException();
}
List<String> command = new ArrayList<>();
command.add(weasyprintPath);
command.add("-e");
command.add("utf-8");
command.add("-v");
command.add("--pdf-forms");
command.add(tempInputFile.getAbsolutePath());
command.add(tempOutputFile.getAbsolutePath());
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
.runCommandWithOutputHandling(command);
byte[] pdfBytes = Files.readAllBytes(tempOutputFile.getPath());
try {
return pdfBytes;
} catch (Exception e) {
pdfBytes = Files.readAllBytes(tempOutputFile.getPath());
if (pdfBytes.length < 1) {
throw e;
}
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, TempFileManager tempFileManager)
throws IOException {
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.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 sanitizedContent = sanitizeHtmlContent(content, disableSanitize);
Files.write(
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
} else {
Files.copy(zipIn, filePath);
}
}
zipIn.closeEntry();
entry = zipIn.getNextEntry();
}
}
// Repack the sanitized files
zipDirectory(tempUnzippedDir.getPath(), zipFilePath);
} // tempUnzippedDir auto-cleaned
}
private static void zipDirectory(Path sourceDir, Path zipFilePath) throws IOException {
try (ZipOutputStream zos =
new ZipOutputStream(new FileOutputStream(zipFilePath.toFile()))) {
Files.walk(sourceDir)
.filter(path -> !Files.isDirectory(path))
.forEach(
path -> {
ZipEntry zipEntry =
new ZipEntry(sourceDir.relativize(path).toString());
try {
zos.putNextEntry(zipEntry);
Files.copy(path, zos);
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
private static void deleteDirectory(Path dir) throws IOException {
Files.walkFileTree(
dir,
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
}
private static Path unzipAndGetMainHtml(byte[] fileBytes) throws IOException {
Path tempDirectory = Files.createTempDirectory("unzipped_");
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(fileBytes))) {
ZipEntry entry = zipIn.getNextEntry();
while (entry != null) {
Path filePath = tempDirectory.resolve(sanitizeZipFilename(entry.getName()));
if (entry.isDirectory()) {
Files.createDirectories(filePath); // Explicitly create the directory structure
} else {
Files.createDirectories(
filePath.getParent()); // Create parent directories if they don't exist
Files.copy(zipIn, filePath);
}
zipIn.closeEntry();
entry = zipIn.getNextEntry();
}
}
// Search for the main HTML file.
try (Stream<Path> walk = Files.walk(tempDirectory)) {
List<Path> htmlFiles = walk.filter(file -> file.toString().endsWith(".html")).toList();
if (htmlFiles.isEmpty()) {
throw new IOException("No HTML files found in the unzipped directory.");
}
// Prioritize 'index.html' if it exists, otherwise use the first .html file
for (Path htmlFile : htmlFiles) {
if ("index.html".equals(htmlFile.getFileName().toString())) {
return htmlFile;
}
}
return htmlFiles.get(0);
}
}
static String sanitizeZipFilename(String entryName) {
if (entryName == null || entryName.trim().isEmpty()) {
return "";
}
// Remove any drive letters (e.g., "C:\") and leading forward/backslashes
entryName = entryName.replaceAll("^[a-zA-Z]:[\\\\/]+", "");
entryName = entryName.replaceAll("^[\\\\/]+", "");
// Recursively remove path traversal sequences
while (entryName.contains("../") || entryName.contains("..\\")) {
entryName = entryName.replace("../", "").replace("..\\", "");
}
// Normalize all backslashes to forward slashes
entryName = entryName.replaceAll("\\\\", "/");
return entryName;
}
}
@@ -0,0 +1,473 @@
package stirling.software.common.util;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.ResourcePatternUtils;
import org.springframework.web.multipart.MultipartFile;
import com.fathzer.soft.javaluator.DoubleEvaluator;
import io.github.pixee.security.HostValidator;
import io.github.pixee.security.Urls;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
public class GeneralUtils {
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
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)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
return tempFile;
}
public static void deleteDirectory(Path path) throws IOException {
Files.walkFileTree(
path,
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.deleteIfExists(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {
Files.deleteIfExists(dir);
return FileVisitResult.CONTINUE;
}
});
}
public static String convertToFileName(String name) {
String safeName = name.replaceAll("[^a-zA-Z0-9]", "_");
if (safeName.length() > 50) {
safeName = safeName.substring(0, 50);
}
return safeName;
}
// Get resources from a location pattern
public static Resource[] getResourcesFromLocationPattern(
String locationPattern, ResourceLoader resourceLoader) throws Exception {
// Normalize the path for file resources
if (locationPattern.startsWith("file:")) {
String rawPath = locationPattern.substring(5).replace("\\*", "").replace("/*", "");
Path normalizePath = Paths.get(rawPath).normalize();
locationPattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
}
return ResourcePatternUtils.getResourcePatternResolver(resourceLoader)
.getResources(locationPattern);
}
public static boolean isValidURL(String urlStr) {
try {
Urls.create(
urlStr, Urls.HTTP_PROTOCOLS, HostValidator.DENY_COMMON_INFRASTRUCTURE_TARGETS);
return true;
} catch (MalformedURLException e) {
return false;
}
}
public static boolean isURLReachable(String urlStr) {
try {
// Parse the URL
URL url = URI.create(urlStr).toURL();
// Allow only http and https protocols
String protocol = url.getProtocol();
if (!"http".equals(protocol) && !"https".equals(protocol)) {
return false; // Disallow other protocols
}
// Check if the host is a local address
String host = url.getHost();
if (isLocalAddress(host)) {
return false; // Exclude local addresses
}
// Check if the URL is reachable
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
// connection.setConnectTimeout(5000); // Set connection timeout
// connection.setReadTimeout(5000); // Set read timeout
int responseCode = connection.getResponseCode();
return (200 <= responseCode && responseCode <= 399);
} catch (Exception e) {
return false; // Return false in case of any exception
}
}
private static boolean isLocalAddress(String host) {
try {
// Resolve DNS to IP address
InetAddress address = InetAddress.getByName(host);
// Check for local addresses
return address.isAnyLocalAddress()
|| // Matches 0.0.0.0 or similar
address.isLoopbackAddress()
|| // Matches 127.0.0.1 or ::1
address.isSiteLocalAddress()
|| // Matches private IPv4 ranges: 192.168.x.x, 10.x.x.x, 172.16.x.x to
// 172.31.x.x
address.getHostAddress()
.startsWith("fe80:"); // Matches link-local IPv6 addresses
} catch (Exception e) {
return false; // Return false for invalid or unresolved addresses
}
}
public static File multipartToFile(MultipartFile multipart) throws IOException {
Path tempFile = Files.createTempFile("overlay-", ".pdf");
try (InputStream in = multipart.getInputStream();
FileOutputStream out = new FileOutputStream(tempFile.toFile())) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
}
return tempFile.toFile();
}
public static Long convertSizeToBytes(String sizeStr) {
if (sizeStr == null) {
return null;
}
sizeStr = sizeStr.trim().toUpperCase();
sizeStr = sizeStr.replace(",", ".").replace(" ", "");
try {
if (sizeStr.endsWith("KB")) {
return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024);
} else if (sizeStr.endsWith("MB")) {
return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024
* 1024);
} else if (sizeStr.endsWith("GB")) {
return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024
* 1024
* 1024);
} else if (sizeStr.endsWith("B")) {
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
} else {
// Assume MB if no unit is specified
return (long) (Double.parseDouble(sizeStr) * 1024 * 1024);
}
} catch (NumberFormatException e) {
// The numeric part of the input string cannot be parsed, handle this case
}
return null;
}
public static String formatBytes(long bytes) {
if (bytes < 1024) {
return bytes + " B";
} else if (bytes < 1024 * 1024) {
return String.format(Locale.US, "%.2f KB", bytes / 1024.0);
} else if (bytes < 1024 * 1024 * 1024) {
return String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0));
} else {
return String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
}
}
public static List<Integer> parsePageList(String pages, int totalPages, boolean oneBased) {
if (pages == null) {
return List.of(1); // Default to first page if input is null
}
try {
return parsePageList(pages.split(","), totalPages, oneBased);
} catch (NumberFormatException e) {
return List.of(1); // Default to first page if input is invalid
}
}
public static List<Integer> parsePageList(String[] pages, int totalPages) {
return parsePageList(pages, totalPages, false);
}
public static List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) {
List<Integer> result = new ArrayList<>();
int offset = oneBased ? 1 : 0;
for (String page : pages) {
if ("all".equalsIgnoreCase(page)) {
for (int i = 0; i < totalPages; i++) {
result.add(i + offset);
}
} else if (page.contains(",")) {
// Split the string into parts, could be single pages or ranges
String[] parts = page.split(",");
for (String part : parts) {
result.addAll(handlePart(part, totalPages, offset));
}
} else {
result.addAll(handlePart(page, totalPages, offset));
}
}
return result;
}
public static List<Integer> evaluateNFunc(String expression, int maxValue) {
List<Integer> results = new ArrayList<>();
DoubleEvaluator evaluator = new DoubleEvaluator();
// Validate the expression
if (!expression.matches("[0-9n+\\-*/() ]+")) {
throw new IllegalArgumentException("Invalid expression");
}
for (int n = 1; n <= maxValue; n++) {
// Replace 'n' with the current value of n, correctly handling numbers before
// 'n'
String sanitizedExpression = sanitizeNFunction(expression, n);
Double result = evaluator.evaluate(sanitizedExpression);
// Check if the result is null or not within bounds
if (result == null) break;
if (result.intValue() > 0 && result.intValue() <= maxValue)
results.add(result.intValue());
}
return results;
}
private static String sanitizeNFunction(String expression, int nValue) {
String sanitizedExpression = expression.replace(" ", "");
String multiplyByOpeningRoundBracketPattern =
"([0-9n)])\\("; // example: n(n-1), 9(n-1), (n-1)(n-2)
sanitizedExpression =
sanitizedExpression.replaceAll(multiplyByOpeningRoundBracketPattern, "$1*(");
String multiplyByClosingRoundBracketPattern =
"\\)([0-9n)])"; // example: (n-1)n, (n-1)9, (n-1)(n-2)
sanitizedExpression =
sanitizedExpression.replaceAll(multiplyByClosingRoundBracketPattern, ")*$1");
sanitizedExpression = insertMultiplicationBeforeN(sanitizedExpression, nValue);
return sanitizedExpression;
}
private static String insertMultiplicationBeforeN(String expression, int nValue) {
// Insert multiplication between a number and 'n' (e.g., "4n" becomes "4*n")
String withMultiplication = expression.replaceAll("(\\d)n", "$1*n");
withMultiplication = formatConsecutiveNsForNFunction(withMultiplication);
// Now replace 'n' with its current value
return withMultiplication.replace("n", String.valueOf(nValue));
}
private static String formatConsecutiveNsForNFunction(String expression) {
String text = expression;
while (text.matches(".*n{2,}.*")) {
text = text.replaceAll("(?<!n)n{2}", "n*n");
}
return text;
}
private static List<Integer> handlePart(String part, int totalPages, int offset) {
List<Integer> partResult = new ArrayList<>();
// First check for n-syntax because it should not be processed as a range
if (part.contains("n")) {
partResult = evaluateNFunc(part, totalPages);
// Adjust the results according to the offset
for (int i = 0; i < partResult.size(); i++) {
int adjustedValue = partResult.get(i) - 1 + offset;
partResult.set(i, adjustedValue);
}
} else if (part.contains("-")) {
// Process ranges only if it's not n-syntax
String[] rangeParts = part.split("-");
try {
int start = Integer.parseInt(rangeParts[0]);
int end =
(rangeParts.length > 1 && !rangeParts[1].isEmpty())
? Integer.parseInt(rangeParts[1])
: totalPages;
for (int i = start; i <= end; i++) {
if (i >= 1 && i <= totalPages) {
partResult.add(i - 1 + offset);
}
}
} catch (NumberFormatException e) {
// Range is invalid, ignore this part
}
} else {
// This is a single page number
try {
int pageNum = Integer.parseInt(part.trim());
if (pageNum >= 1 && pageNum <= totalPages) {
partResult.add(pageNum - 1 + offset);
}
} catch (NumberFormatException ignored) {
// Ignore invalid numbers
}
}
return partResult;
}
public static boolean createDir(String path) {
Path folder = Paths.get(path);
if (!Files.exists(folder)) {
try {
Files.createDirectories(folder);
} catch (IOException e) {
log.error("exception", e);
return false;
}
}
return true;
}
public static boolean isValidUUID(String uuid) {
if (uuid == null) {
return false;
}
try {
UUID.fromString(uuid);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
/*------------------------------------------------------------------------*
* Internal Implementation Details *
*------------------------------------------------------------------------*/
public static void saveKeyToSettings(String key, Object newValue) throws IOException {
String[] keyArray = key.split("\\.");
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
YamlHelper settingsYaml = new YamlHelper(settingsPath);
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
settingsYaml.saveOverride(settingsPath);
}
public static String generateMachineFingerprint() {
try {
// Get the MAC address
StringBuilder sb = new StringBuilder();
InetAddress ip = InetAddress.getLocalHost();
NetworkInterface network = NetworkInterface.getByInetAddress(ip);
if (network == null) {
Enumeration<NetworkInterface> networks = NetworkInterface.getNetworkInterfaces();
while (networks.hasMoreElements()) {
NetworkInterface net = networks.nextElement();
byte[] mac = net.getHardwareAddress();
if (mac != null) {
for (int i = 0; i < mac.length; i++) {
sb.append(String.format("%02X", mac[i]));
}
break; // Use the first network interface with a MAC address
}
}
} else {
byte[] mac = network.getHardwareAddress();
if (mac != null) {
for (int i = 0; i < mac.length; i++) {
sb.append(String.format("%02X", mac[i]));
}
}
}
// Hash the MAC address for privacy and consistency
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder fingerprint = new StringBuilder();
for (byte b : hash) {
fingerprint.append(String.format("%02x", b));
}
return fingerprint.toString();
} catch (Exception e) {
return "GenericID";
}
}
public static boolean isVersionHigher(String currentVersion, String compareVersion) {
if (currentVersion == null || compareVersion == null) {
return false;
}
// Split versions into components
String[] current = currentVersion.split("\\.");
String[] compare = compareVersion.split("\\.");
// Get the length of the shorter version array
int length = Math.min(current.length, compare.length);
// Compare each component
for (int i = 0; i < length; i++) {
int currentPart = Integer.parseInt(current[i]);
int comparePart = Integer.parseInt(compare[i]);
if (currentPart > comparePart) {
return true;
}
if (currentPart < comparePart) {
return false;
}
}
// If all components so far are equal, the longer version is considered higher
return current.length > compare.length;
}
}
@@ -0,0 +1,122 @@
package stirling.software.common.util;
import java.awt.geom.AffineTransform;
import java.awt.image.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import javax.imageio.ImageIO;
import org.springframework.web.multipart.MultipartFile;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ImageProcessingUtils {
static BufferedImage convertColorType(BufferedImage sourceImage, String colorType) {
BufferedImage convertedImage;
switch (colorType) {
case "greyscale":
convertedImage =
new BufferedImage(
sourceImage.getWidth(),
sourceImage.getHeight(),
BufferedImage.TYPE_BYTE_GRAY);
convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null);
break;
case "blackwhite":
convertedImage =
new BufferedImage(
sourceImage.getWidth(),
sourceImage.getHeight(),
BufferedImage.TYPE_BYTE_BINARY);
convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null);
break;
default: // full color
convertedImage = sourceImage;
break;
}
return convertedImage;
}
public static byte[] getImageData(BufferedImage image) {
DataBuffer dataBuffer = image.getRaster().getDataBuffer();
if (dataBuffer instanceof DataBufferByte dataBufferByte) {
return dataBufferByte.getData();
} else if (dataBuffer instanceof DataBufferInt dataBufferInt) {
int[] intData = dataBufferInt.getData();
ByteBuffer byteBuffer = ByteBuffer.allocate(intData.length * 4);
byteBuffer.asIntBuffer().put(intData);
return byteBuffer.array();
} else {
int width = image.getWidth();
int height = image.getHeight();
byte[] data = new byte[width * height * 3];
int index = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int rgb = image.getRGB(x, y);
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
data[index++] = (byte) (rgb & 0xFF); // Blue
}
}
return data;
}
}
public static double extractImageOrientation(InputStream is) throws IOException {
try {
Metadata metadata = ImageMetadataReader.readMetadata(is);
ExifSubIFDDirectory directory =
metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
if (directory == null) {
return 0;
}
int orientationTag = directory.getInt(ExifSubIFDDirectory.TAG_ORIENTATION);
switch (orientationTag) {
case 1:
return 0;
case 6:
return 90;
case 3:
return 180;
case 8:
return 270;
default:
log.warn("Unknown orientation tag: {}", orientationTag);
return 0;
}
} catch (ImageProcessingException | MetadataException e) {
return 0;
}
}
public static BufferedImage applyOrientation(BufferedImage image, double orientation) {
if (orientation == 0) {
return image;
}
AffineTransform transform =
AffineTransform.getRotateInstance(
Math.toRadians(orientation),
image.getWidth() / 2.0,
image.getHeight() / 2.0);
AffineTransformOp op = new AffineTransformOp(transform, AffineTransformOp.TYPE_BILINEAR);
return op.filter(image, null);
}
public static BufferedImage loadImageWithExifOrientation(MultipartFile file)
throws IOException {
BufferedImage image = ImageIO.read(file.getInputStream());
double orientation = extractImageOrientation(file.getInputStream());
return applyOrientation(image, orientation);
}
}
@@ -0,0 +1,320 @@
package stirling.software.common.util;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
import com.vladsch.flexmark.util.data.MutableDataSet;
import io.github.pixee.security.Filenames;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
@NoArgsConstructor
public class PDFToFile {
public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!"application/pdf".equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
MutableDataSet options =
new MutableDataSet()
.set(
FlexmarkHtmlConverter.MAX_BLANK_LINES,
2) // Control max consecutive blank lines
.set(
FlexmarkHtmlConverter.MAX_TRAILING_BLANK_LINES,
1) // Control trailing blank lines
.set(
FlexmarkHtmlConverter.SETEXT_HEADINGS,
true) // Use Setext headings for h1 and h2
.set(
FlexmarkHtmlConverter.OUTPUT_UNKNOWN_TAGS,
false) // Don't output HTML for unknown tags
.set(
FlexmarkHtmlConverter.TYPOGRAPHIC_QUOTES,
true) // Convert quotation marks
.set(
FlexmarkHtmlConverter.BR_AS_PARA_BREAKS,
true) // Convert <br> to paragraph breaks
.set(FlexmarkHtmlConverter.CODE_INDENT, " "); // Indent for code blocks
FlexmarkHtmlConverter htmlToMarkdownConverter =
FlexmarkHtmlConverter.builder(options).build();
String originalPdfFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
String pdfBaseName = originalPdfFileName;
if (originalPdfFileName.contains(".")) {
pdfBaseName = originalPdfFileName.substring(0, originalPdfFileName.lastIndexOf('.'));
}
Path tempInputFile = null;
Path tempOutputDir = null;
byte[] fileBytes;
String fileName = "temp.file";
try {
tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile);
tempOutputDir = Files.createTempDirectory("output_");
List<String> command =
new ArrayList<>(
Arrays.asList(
"pdftohtml",
"-s",
"-noframes",
"-c",
tempInputFile.toString(),
pdfBaseName));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
.runCommandWithOutputHandling(command, tempOutputDir.toFile());
// Process HTML files to Markdown
File[] outputFiles = Objects.requireNonNull(tempOutputDir.toFile().listFiles());
List<File> markdownFiles = new ArrayList<>();
// Convert HTML files to Markdown
for (File outputFile : outputFiles) {
if (outputFile.getName().endsWith(".html")) {
String html = Files.readString(outputFile.toPath());
String markdown = htmlToMarkdownConverter.convert(html);
String mdFileName = outputFile.getName().replace(".html", ".md");
File mdFile = new File(tempOutputDir.toFile(), mdFileName);
Files.writeString(mdFile.toPath(), markdown);
markdownFiles.add(mdFile);
}
}
// If there's only one markdown file, return it directly
if (markdownFiles.size() == 1) {
fileName = pdfBaseName + ".md";
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
} else {
// Multiple files - create a zip
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files
for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
// Add images and other assets
for (File file : outputFiles) {
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
ZipEntry assetEntry = new ZipEntry(file.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(file.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
}
fileBytes = byteArrayOutputStream.toByteArray();
}
} finally {
if (tempInputFile != null) Files.deleteIfExists(tempInputFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
}
return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!"application/pdf".equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
// Get the original PDF file name without the extension
String originalPdfFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
String pdfBaseName = originalPdfFileName;
if (originalPdfFileName.contains(".")) {
pdfBaseName = originalPdfFileName.substring(0, originalPdfFileName.lastIndexOf('.'));
}
Path tempInputFile = null;
Path tempOutputDir = null;
byte[] fileBytes;
String fileName = "temp.file";
try {
// Save the uploaded file to a temporary location
tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile);
// Prepare the output directory
tempOutputDir = Files.createTempDirectory("output_");
// Run the pdftohtml command with complex output
List<String> command =
new ArrayList<>(
Arrays.asList(
"pdftohtml", "-c", tempInputFile.toString(), pdfBaseName));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
.runCommandWithOutputHandling(command, tempOutputDir.toFile());
// Get output files
File[] outputFiles = Objects.requireNonNull(tempOutputDir.toFile().listFiles());
// Return output files in a ZIP archive
fileName = pdfBaseName + "ToHtml.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
for (File outputFile : outputFiles) {
ZipEntry entry = new ZipEntry(outputFile.getName());
zipOutputStream.putNextEntry(entry);
try (FileInputStream fis = new FileInputStream(outputFile)) {
IOUtils.copy(fis, zipOutputStream);
} catch (IOException e) {
log.error("Exception writing zip entry", e);
}
zipOutputStream.closeEntry();
}
} catch (IOException e) {
log.error("Exception writing zip", e);
}
fileBytes = byteArrayOutputStream.toByteArray();
} finally {
// Clean up the temporary files
if (tempInputFile != null) Files.deleteIfExists(tempInputFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
}
return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
public ResponseEntity<byte[]> processPdfToOfficeFormat(
MultipartFile inputFile, String outputFormat, String libreOfficeFilter)
throws IOException, InterruptedException {
if (!"application/pdf".equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
// Get the original PDF file name without the extension
String originalPdfFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalPdfFileName == null || "".equals(originalPdfFileName.trim())) {
originalPdfFileName = "output.pdf";
}
// Assume file is pdf if no extension
String pdfBaseName = originalPdfFileName;
if (originalPdfFileName.contains(".")) {
pdfBaseName = originalPdfFileName.substring(0, originalPdfFileName.lastIndexOf('.'));
}
// Validate output format
List<String> allowedFormats =
Arrays.asList("doc", "docx", "odt", "ppt", "pptx", "odp", "rtf", "xml", "txt:Text");
if (!allowedFormats.contains(outputFormat)) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
Path tempInputFile = null;
Path tempOutputDir = null;
byte[] fileBytes;
String fileName = "temp.file";
try {
// Save the uploaded file to a temporary location
tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile);
// Prepare the output directory
tempOutputDir = Files.createTempDirectory("output_");
// Run the LibreOffice command
List<String> command =
new ArrayList<>(
Arrays.asList(
"soffice",
"--headless",
"--nologo",
"--infilter=" + libreOfficeFilter,
"--convert-to",
outputFormat,
"--outdir",
tempOutputDir.toString(),
tempInputFile.toString()));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
// Get output files
List<File> outputFiles = Arrays.asList(tempOutputDir.toFile().listFiles());
if (outputFiles.size() == 1) {
// Return single output file
File outputFile = outputFiles.get(0);
if ("txt:Text".equals(outputFormat)) {
outputFormat = "txt";
}
fileName = pdfBaseName + "." + outputFormat;
fileBytes = FileUtils.readFileToByteArray(outputFile);
} else {
// Return output files in a ZIP archive
fileName = pdfBaseName + "To" + outputFormat + ".zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
for (File outputFile : outputFiles) {
ZipEntry entry = new ZipEntry(outputFile.getName());
zipOutputStream.putNextEntry(entry);
try (FileInputStream fis = new FileInputStream(outputFile)) {
IOUtils.copy(fis, zipOutputStream);
} catch (IOException e) {
log.error("Exception writing zip entry", e);
}
zipOutputStream.closeEntry();
}
} catch (IOException e) {
log.error("Exception writing zip", e);
}
fileBytes = byteArrayOutputStream.toByteArray();
}
} finally {
// Clean up the temporary files
Files.deleteIfExists(tempInputFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
}
return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
}
@@ -0,0 +1,55 @@
package stirling.software.common.util;
import java.io.IOException;
/** Utility class for detecting and handling PDF-related errors. */
public class PdfErrorUtils {
/**
* Checks if an IOException indicates a corrupted PDF file.
*
* @param e the IOException to check
* @return true if the error indicates PDF corruption, false otherwise
*/
public static boolean isCorruptedPdfError(IOException e) {
return isCorruptedPdfError(e.getMessage());
}
/**
* Checks if any Exception indicates a corrupted PDF file.
*
* @param e the Exception to check
* @return true if the error indicates PDF corruption, false otherwise
*/
public static boolean isCorruptedPdfError(Exception e) {
return isCorruptedPdfError(e.getMessage());
}
/**
* Checks if an error message indicates a corrupted PDF file.
*
* @param message the error message to check
* @return true if the message indicates PDF corruption, false otherwise
*/
private static boolean isCorruptedPdfError(String message) {
if (message == null) return false;
// Check for common corruption indicators
return message.contains("Missing root object specification")
|| message.contains("Header doesn't contain versioninfo")
|| message.contains("Expected trailer")
|| message.contains("Invalid PDF")
|| message.contains("Corrupted")
|| message.contains("damaged")
|| message.contains("Unknown dir object")
|| message.contains("Can't dereference COSObject")
|| message.contains("parseCOSString string should start with")
|| message.contains("ICCBased colorspace array must have a stream")
|| message.contains("1-based index not found")
|| message.contains("Invalid dictionary, found:")
|| message.contains("AES initialization vector not fully read")
|| message.contains("BadPaddingException")
|| message.contains("Given final block not properly padded")
|| message.contains("End-of-File, expected line");
}
}
@@ -0,0 +1,610 @@
package stirling.software.common.util;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.*;
import javax.imageio.stream.ImageOutputStream;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
public class PdfUtils {
public static PDRectangle textToPageSize(String size) {
switch (size.toUpperCase()) {
case "A0" -> {
return PDRectangle.A0;
}
case "A1" -> {
return PDRectangle.A1;
}
case "A2" -> {
return PDRectangle.A2;
}
case "A3" -> {
return PDRectangle.A3;
}
case "A4" -> {
return PDRectangle.A4;
}
case "A5" -> {
return PDRectangle.A5;
}
case "A6" -> {
return PDRectangle.A6;
}
case "LETTER" -> {
return PDRectangle.LETTER;
}
case "LEGAL" -> {
return PDRectangle.LEGAL;
}
default -> throw ExceptionUtils.createInvalidPageSizeException(size);
}
}
public static List<RenderedImage> getAllImages(PDResources resources) throws IOException {
List<RenderedImage> images = new ArrayList<>();
for (COSName name : resources.getXObjectNames()) {
PDXObject object = resources.getXObject(name);
if (object instanceof PDImageXObject) {
images.add(((PDImageXObject) object).getImage());
} else if (object instanceof PDFormXObject) {
images.addAll(getAllImages(((PDFormXObject) object).getResources()));
}
}
return images;
}
public static boolean hasImages(PDDocument document, String pagesToCheck) throws IOException {
String[] pageOrderArr = pagesToCheck.split(",");
List<Integer> pageList =
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages());
for (int pageNumber : pageList) {
PDPage page = document.getPage(pageNumber);
if (hasImagesOnPage(page)) {
return true;
}
}
return false;
}
public static boolean hasText(PDDocument document, String pageNumbersToCheck, String phrase)
throws IOException {
String[] pageOrderArr = pageNumbersToCheck.split(",");
List<Integer> pageList =
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages());
for (int pageNumber : pageList) {
PDPage page = document.getPage(pageNumber);
if (hasTextOnPage(page, phrase)) {
return true;
}
}
return false;
}
public static boolean hasImagesOnPage(PDPage page) throws IOException {
return getAllImages(page.getResources()).size() > 0;
}
public static boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
PDFTextStripper textStripper = new PDFTextStripper();
PDDocument tempDoc = new PDDocument();
tempDoc.addPage(page);
String pageText = textStripper.getText(tempDoc);
tempDoc.close();
return pageText.contains(phrase);
}
public static byte[] convertFromPdf(
CustomPDFDocumentFactory pdfDocumentFactory,
byte[] inputStream,
String imageType,
ImageType colorType,
boolean singleImage,
int DPI,
String filename)
throws IOException, Exception {
// Validate and limit DPI to prevent excessive memory usage
final int MAX_SAFE_DPI = 500; // Maximum safe DPI to prevent memory issues
if (DPI > MAX_SAFE_DPI) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dpiExceedsLimit",
"DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.",
DPI,
MAX_SAFE_DPI);
}
try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
int pageCount = document.getNumberOfPages();
// Create a ByteArrayOutputStream to save the image(s) to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (singleImage) {
if ("tiff".equals(imageType.toLowerCase())
|| "tif".equals(imageType.toLowerCase())) {
// Write the images to the output stream as a TIFF with multiple frames
ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionType("ZLib");
param.setCompressionQuality(1.0f);
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
writer.setOutput(ios);
writer.prepareWriteSequence(null);
for (int i = 0; i < pageCount; ++i) {
BufferedImage image;
try {
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
writer.writeToSequence(new IIOImage(image, null, null), param);
}
writer.endWriteSequence();
}
writer.dispose();
} else {
// Combine all images into a single big image
// Calculate the combined image dimensions
int maxWidth = 0;
int totalHeight = 0;
BufferedImage pdfSizeImage = null;
int pdfSizeImageIndex = -1;
// Using a map to store the rendered dimensions of each page size
// to avoid rendering the same page sizes multiple times
HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes =
new HashMap<>();
for (int i = 0; i < pageCount; ++i) {
PDPage page = document.getPage(i);
PDRectangle mediaBox = page.getMediaBox();
int rotation = page.getRotation();
PdfRenderSettingsKey settings =
new PdfRenderSettingsKey(
mediaBox.getWidth(), mediaBox.getHeight(), rotation);
PdfImageDimensionValue dimension = pageSizes.get(settings);
if (dimension == null) {
// Render the image to get the dimensions
try {
pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigExceedsArray",
"PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
pdfSizeImageIndex = i;
dimension =
new PdfImageDimensionValue(
pdfSizeImage.getWidth(), pdfSizeImage.getHeight());
pageSizes.put(settings, dimension);
if (pdfSizeImage.getWidth() > maxWidth) {
maxWidth = pdfSizeImage.getWidth();
}
}
totalHeight += dimension.height();
}
// Create a new BufferedImage to store the combined images
BufferedImage combined =
prepareImageForPdfToImage(maxWidth, totalHeight, imageType);
Graphics g = combined.getGraphics();
int currentHeight = 0;
BufferedImage pageImage;
// Check if the first image is the last rendered image
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
for (int i = 0; i < pageCount; ++i) {
if (firstImageAlreadyRendered && i == 0) {
pageImage = pdfSizeImage;
} else {
try {
pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
}
// Calculate the x-coordinate to center the image
int x = (maxWidth - pageImage.getWidth()) / 2;
g.drawImage(pageImage, x, currentHeight, null);
currentHeight += pageImage.getHeight();
}
// Write the image to the output stream
ImageIO.write(combined, imageType, baos);
}
// Log that the image was successfully written to the byte array
log.info("Image successfully written to byte array");
} else {
// Zip the images and return as byte array
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (int i = 0; i < pageCount; ++i) {
BufferedImage image;
try {
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) {
ImageIO.write(image, imageType, baosImage);
// Add the image to the zip file
zos.putNextEntry(
new ZipEntry(
String.format(
filename + "_%d.%s",
i + 1,
imageType.toLowerCase())));
zos.write(baosImage.toByteArray());
}
}
// Log that the images were successfully written to the byte array
log.info("Images successfully written to byte array as a zip");
}
}
return baos.toByteArray();
} catch (IOException e) {
// Log an error message if there is an issue converting the PDF to an image
log.error("Error converting PDF to image", e);
throw e;
}
}
/**
* Converts a given Pdf file to PDF-Image.
*
* @param document to be converted. Note: the caller is responsible for closing the document
* @return converted document to PDF-Image
* @throws IOException if conversion fails
*/
public static PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
PDDocument imageDocument = new PDDocument();
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) {
BufferedImage bim;
try {
bim = pdfRenderer.renderImageWithDPI(page, 300, ImageType.RGB);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigFor300Dpi",
"PDF page {0} is too large to render at 300 DPI. The resulting image would exceed Java's maximum array size. Please use a lower DPI value for PDF-to-image conversion.",
page + 1);
}
throw e;
}
PDPage originalPage = document.getPage(page);
float width = originalPage.getMediaBox().getWidth();
float height = originalPage.getMediaBox().getHeight();
PDPage newPage = new PDPage(new PDRectangle(width, height));
imageDocument.addPage(newPage);
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
PDPageContentStream contentStream =
new PDPageContentStream(imageDocument, newPage, AppendMode.APPEND, true, true);
contentStream.drawImage(pdImage, 0, 0, width, height);
contentStream.close();
}
return imageDocument;
}
private static BufferedImage prepareImageForPdfToImage(
int maxWidth, int height, String imageType) {
BufferedImage combined;
if ("png".equalsIgnoreCase(imageType)) {
combined = new BufferedImage(maxWidth, height, BufferedImage.TYPE_INT_ARGB);
} else {
combined = new BufferedImage(maxWidth, height, BufferedImage.TYPE_INT_RGB);
}
if (!"png".equalsIgnoreCase(imageType)) {
Graphics g = combined.getGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, combined.getWidth(), combined.getHeight());
g.dispose();
}
return combined;
}
public static byte[] imageToPdf(
MultipartFile[] files,
String fitOption,
boolean autoRotate,
String colorType,
CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
try (PDDocument doc = pdfDocumentFactory.createNewDocument()) {
for (MultipartFile file : files) {
String contentType = file.getContentType();
String originalFilename = Filenames.toSimpleFileName(file.getOriginalFilename());
if (originalFilename != null
&& (originalFilename.toLowerCase().endsWith(".tiff")
|| originalFilename.toLowerCase().endsWith(".tif"))) {
ImageReader reader = ImageIO.getImageReadersByFormatName("tiff").next();
reader.setInput(ImageIO.createImageInputStream(file.getInputStream()));
int numPages = reader.getNumImages(true);
for (int i = 0; i < numPages; i++) {
BufferedImage pageImage = reader.read(i);
BufferedImage convertedImage =
ImageProcessingUtils.convertColorType(pageImage, colorType);
PDImageXObject pdImage =
LosslessFactory.createFromImage(doc, convertedImage);
addImageToDocument(doc, pdImage, fitOption, autoRotate);
}
} else {
BufferedImage image = ImageProcessingUtils.loadImageWithExifOrientation(file);
BufferedImage convertedImage =
ImageProcessingUtils.convertColorType(image, colorType);
// Use JPEGFactory if it's JPEG since JPEG is lossy
PDImageXObject pdImage =
(contentType != null && "image/jpeg".equals(contentType))
? JPEGFactory.createFromImage(doc, convertedImage)
: LosslessFactory.createFromImage(doc, convertedImage);
addImageToDocument(doc, pdImage, fitOption, autoRotate);
}
}
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
doc.save(byteArrayOutputStream);
log.debug("PDF successfully saved to byte array");
return byteArrayOutputStream.toByteArray();
}
}
public static void addImageToDocument(
PDDocument doc, PDImageXObject image, String fitOption, boolean autoRotate)
throws IOException {
boolean imageIsLandscape = image.getWidth() > image.getHeight();
PDRectangle pageSize = PDRectangle.A4;
if (autoRotate && imageIsLandscape) {
pageSize = new PDRectangle(pageSize.getHeight(), pageSize.getWidth());
}
if ("fitDocumentToImage".equals(fitOption)) {
pageSize = new PDRectangle(image.getWidth(), image.getHeight());
}
PDPage page = new PDPage(pageSize);
doc.addPage(page);
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
try (PDPageContentStream contentStream =
new PDPageContentStream(doc, page, AppendMode.APPEND, true, true)) {
if ("fillPage".equals(fitOption) || "fitDocumentToImage".equals(fitOption)) {
contentStream.drawImage(image, 0, 0, pageWidth, pageHeight);
} else if ("maintainAspectRatio".equals(fitOption)) {
float imageAspectRatio = (float) image.getWidth() / (float) image.getHeight();
float pageAspectRatio = pageWidth / pageHeight;
float scaleFactor = 1.0f;
if (imageAspectRatio > pageAspectRatio) {
scaleFactor = pageWidth / image.getWidth();
} else {
scaleFactor = pageHeight / image.getHeight();
}
float xPos = (pageWidth - (image.getWidth() * scaleFactor)) / 2;
float yPos = (pageHeight - (image.getHeight() * scaleFactor)) / 2;
contentStream.drawImage(
image,
xPos,
yPos,
image.getWidth() * scaleFactor,
image.getHeight() * scaleFactor);
}
} catch (IOException e) {
log.error("Error adding image to PDF", e);
throw e;
}
}
public static byte[] overlayImage(
CustomPDFDocumentFactory pdfDocumentFactory,
byte[] pdfBytes,
byte[] imageBytes,
float x,
float y,
boolean everyPage)
throws IOException {
PDDocument document = pdfDocumentFactory.load(pdfBytes);
// Get the first page of the PDF
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
// Create an image object from the image bytes
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlayed onto PDF");
if (!everyPage && i == 0) {
break;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
}
}
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
}
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
throws IOException {
PDFTextStripper textStripper = new PDFTextStripper();
String pdfText = "";
if (pagesToCheck == null || "all".equals(pagesToCheck)) {
pdfText = textStripper.getText(pdfDocument);
} else {
// remove whitespaces
pagesToCheck = pagesToCheck.replaceAll("\\s+", "");
String[] splitPoints = pagesToCheck.split(",");
for (String splitPoint : splitPoints) {
if (splitPoint.contains("-")) {
// Handle page ranges
String[] range = splitPoint.split("-");
int startPage = Integer.parseInt(range[0]);
int endPage = Integer.parseInt(range[1]);
for (int i = startPage; i <= endPage; i++) {
textStripper.setStartPage(i);
textStripper.setEndPage(i);
pdfText += textStripper.getText(pdfDocument);
}
} else {
// Handle individual page
int page = Integer.parseInt(splitPoint);
textStripper.setStartPage(page);
textStripper.setEndPage(page);
pdfText += textStripper.getText(pdfDocument);
}
}
}
pdfDocument.close();
return pdfText.contains(text);
}
public boolean pageCount(PDDocument pdfDocument, int pageCount, String comparator)
throws IOException {
int actualPageCount = pdfDocument.getNumberOfPages();
pdfDocument.close();
switch (comparator.toLowerCase()) {
case "greater":
return actualPageCount > pageCount;
case "equal":
return actualPageCount == pageCount;
case "less":
return actualPageCount < pageCount;
default:
throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
}
}
public boolean pageSize(PDDocument pdfDocument, String expectedPageSize) throws IOException {
PDPage firstPage = pdfDocument.getPage(0);
PDRectangle mediaBox = firstPage.getMediaBox();
float actualPageWidth = mediaBox.getWidth();
float actualPageHeight = mediaBox.getHeight();
pdfDocument.close();
// Assumes the expectedPageSize is in the format "widthxheight", e.g. "595x842"
// for A4
String[] dimensions = expectedPageSize.split("x");
float expectedPageWidth = Float.parseFloat(dimensions[0]);
float expectedPageHeight = Float.parseFloat(dimensions[1]);
// Checks if the actual page size matches the expected page size
return actualPageWidth == expectedPageWidth && actualPageHeight == expectedPageHeight;
}
/** Key for storing the dimensions of a rendered image in a map. */
private record PdfRenderSettingsKey(float mediaBoxWidth, float mediaBoxHeight, int rotation) {}
/** Value for storing the dimensions of a rendered image in a map. */
private record PdfImageDimensionValue(int width, int height) {}
}
@@ -0,0 +1,331 @@
package stirling.software.common.util;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.InterruptedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import io.github.pixee.security.BoundedLineReader;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
public class ProcessExecutor {
private static final Map<Processes, ProcessExecutor> instances = new ConcurrentHashMap<>();
private static ApplicationProperties applicationProperties = new ApplicationProperties();
private final Semaphore semaphore;
private final boolean liveUpdates;
private long timeoutDuration;
private ProcessExecutor(int semaphoreLimit, boolean liveUpdates, long timeout) {
this.semaphore = new Semaphore(semaphoreLimit);
this.liveUpdates = liveUpdates;
this.timeoutDuration = timeout;
}
public static ProcessExecutor getInstance(Processes processType) {
return getInstance(processType, true);
}
public static ProcessExecutor getInstance(Processes processType, boolean liveUpdates) {
return instances.computeIfAbsent(
processType,
key -> {
int semaphoreLimit =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getLibreOfficeSessionLimit();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPdfToHtmlSessionLimit();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPythonOpenCvSessionLimit();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getWeasyPrintSessionLimit();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getInstallAppSessionLimit();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getTesseractSessionLimit();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getQpdfSessionLimit();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getCalibreSessionLimit();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getGhostscriptSessionLimit();
case OCR_MY_PDF ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getOcrMyPdfSessionLimit();
};
long timeoutMinutes =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getLibreOfficeTimeoutMinutes();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPdfToHtmlTimeoutMinutes();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPythonOpenCvTimeoutMinutes();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getWeasyPrintTimeoutMinutes();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getInstallAppTimeoutMinutes();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getTesseractTimeoutMinutes();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getQpdfTimeoutMinutes();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getCalibreTimeoutMinutes();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getGhostscriptTimeoutMinutes();
case OCR_MY_PDF ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getOcrMyPdfTimeoutMinutes();
};
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
});
}
public ProcessExecutorResult runCommandWithOutputHandling(List<String> command)
throws IOException, InterruptedException {
return runCommandWithOutputHandling(command, null);
}
public ProcessExecutorResult runCommandWithOutputHandling(
List<String> command, File workingDirectory) throws IOException, InterruptedException {
String messages = "";
int exitCode = 1;
semaphore.acquire();
try {
log.info("Running command: " + String.join(" ", command));
ProcessBuilder processBuilder = new ProcessBuilder(command);
// Use the working directory if it's set
if (workingDirectory != null) {
processBuilder.directory(workingDirectory);
}
Process process = processBuilder.start();
// Read the error stream and standard output stream concurrently
List<String> errorLines = new ArrayList<>();
List<String> outputLines = new ArrayList<>();
Thread errorReaderThread =
new Thread(
() -> {
try (BufferedReader errorReader =
new BufferedReader(
new InputStreamReader(
process.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
errorReader, 5_000_000))
!= null) {
errorLines.add(line);
if (liveUpdates) log.info(line);
}
} catch (InterruptedIOException e) {
log.warn("Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
});
Thread outputReaderThread =
new Thread(
() -> {
try (BufferedReader outputReader =
new BufferedReader(
new InputStreamReader(
process.getInputStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
outputReader, 5_000_000))
!= null) {
outputLines.add(line);
if (liveUpdates) log.info(line);
}
} catch (InterruptedIOException e) {
log.warn("Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
});
errorReaderThread.start();
outputReaderThread.start();
// Wait for the conversion process to complete
boolean finished = process.waitFor(timeoutDuration, TimeUnit.MINUTES);
if (!finished) {
// Terminate the process
process.destroy();
// Interrupt the reader threads
errorReaderThread.interrupt();
outputReaderThread.interrupt();
throw new IOException("Process timeout exceeded.");
}
exitCode = process.exitValue();
// Wait for the reader threads to finish
errorReaderThread.join();
outputReaderThread.join();
boolean isQpdf =
command != null && !command.isEmpty() && command.get(0).contains("qpdf");
if (!outputLines.isEmpty()) {
String outputMessage = String.join("\n", outputLines);
messages += outputMessage;
if (!liveUpdates) {
log.info("Command output:\n" + outputMessage);
}
}
if (!errorLines.isEmpty()) {
String errorMessage = String.join("\n", errorLines);
messages += errorMessage;
if (!liveUpdates) {
log.warn("Command error output:\n" + errorMessage);
}
if (exitCode != 0) {
if (isQpdf && exitCode == 3) {
log.warn("qpdf succeeded with warnings: {}", messages);
} else {
throw new IOException(
"Command process failed with exit code "
+ exitCode
+ ". Error message: "
+ errorMessage);
}
}
}
if (exitCode != 0) {
if (isQpdf && exitCode == 3) {
log.warn("qpdf succeeded with warnings: {}", messages);
} else {
throw new IOException(
"Command process failed with exit code "
+ exitCode
+ "\nLogs: "
+ messages);
}
}
} finally {
semaphore.release();
}
return new ProcessExecutorResult(exitCode, messages);
}
public enum Processes {
LIBRE_OFFICE,
PDFTOHTML,
PYTHON_OPENCV,
WEASYPRINT,
INSTALL_APP,
CALIBRE,
TESSERACT,
QPDF,
GHOSTSCRIPT,
OCR_MY_PDF
}
public class ProcessExecutorResult {
int rc;
String messages;
public ProcessExecutorResult(int rc, String messages) {
this.rc = rc;
this.messages = messages;
}
public int getRc() {
return rc;
}
public void setRc(int rc) {
this.rc = rc;
}
public String getMessages() {
return messages;
}
public void setMessages(String messages) {
this.messages = messages;
}
}
}
@@ -0,0 +1,38 @@
package stirling.software.common.util;
import java.util.List;
public class PropertyConfigs {
public static boolean getBooleanValue(List<String> keys, boolean defaultValue) {
for (String key : keys) {
String value = System.getProperty(key);
if (value == null) value = System.getenv(key);
if (value != null) return Boolean.valueOf(value);
}
return defaultValue;
}
public static String getStringValue(List<String> keys, String defaultValue) {
for (String key : keys) {
String value = System.getProperty(key);
if (value == null) value = System.getenv(key);
if (value != null) return value;
}
return defaultValue;
}
public static boolean getBooleanValue(String key, boolean defaultValue) {
String value = System.getProperty(key);
if (value == null) value = System.getenv(key);
return (value != null) ? Boolean.valueOf(value) : defaultValue;
}
public static String getStringValue(String key, String defaultValue) {
String value = System.getProperty(key);
if (value == null) value = System.getenv(key);
return (value != null) ? value : defaultValue;
}
}
@@ -0,0 +1,29 @@
package stirling.software.common.util;
import static stirling.software.common.util.ValidationUtils.isCollectionEmpty;
import static stirling.software.common.util.ValidationUtils.isStringEmpty;
import stirling.software.common.model.oauth2.Provider;
public class ProviderUtils {
public static boolean validateProvider(Provider provider) {
if (provider == null) {
return false;
}
if (isStringEmpty(provider.getClientId())) {
return false;
}
if (isStringEmpty(provider.getClientSecret())) {
return false;
}
if (isCollectionEmpty(provider.getScopes())) {
return false;
}
return true;
}
}
@@ -0,0 +1,50 @@
package stirling.software.common.util;
public class RequestUriUtils {
public static boolean isStaticResource(String requestURI) {
return isStaticResource("", requestURI);
}
public static boolean isStaticResource(String contextPath, String requestURI) {
return requestURI.startsWith(contextPath + "/css/")
|| requestURI.startsWith(contextPath + "/fonts/")
|| requestURI.startsWith(contextPath + "/js/")
|| requestURI.endsWith(contextPath + "robots.txt")
|| requestURI.startsWith(contextPath + "/images/")
|| requestURI.startsWith(contextPath + "/public/")
|| requestURI.startsWith(contextPath + "/pdfjs/")
|| requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/error")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".ico")
|| requestURI.endsWith(".txt")
|| requestURI.endsWith(".webmanifest")
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
}
public static boolean isTrackableResource(String requestURI) {
return isTrackableResource("", requestURI);
}
public static boolean isTrackableResource(String contextPath, String requestURI) {
return !(requestURI.startsWith("/js")
|| requestURI.startsWith("/v1/api-docs")
|| requestURI.endsWith("robots.txt")
|| requestURI.startsWith("/images")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".ico")
|| requestURI.endsWith(".css")
|| requestURI.endsWith(".txt")
|| requestURI.endsWith(".map")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
|| requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs"));
}
}
@@ -0,0 +1,82 @@
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;
import lombok.extern.slf4j.Slf4j;
/**
* Utility class to access Spring managed beans from non-Spring managed classes. This is especially
* useful for classes that are instantiated by frameworks or created dynamically.
*/
@Component
@Slf4j
public class SpringContextHolder implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringContextHolder.applicationContext = applicationContext;
log.debug("Spring context holder initialized");
}
/**
* Get a Spring bean by class type
*
* @param <T> The bean type
* @param beanClass The bean class
* @return The bean instance, or null if not found
*/
public static <T> T getBean(Class<T> beanClass) {
if (applicationContext == null) {
log.warn(
"Application context not initialized when attempting to get bean of type {}",
beanClass.getName());
return null;
}
try {
return applicationContext.getBean(beanClass);
} catch (BeansException e) {
log.error("Error getting bean of type {}: {}", beanClass.getName(), e.getMessage());
return null;
}
}
/**
* Get a Spring bean by name
*
* @param <T> The bean type
* @param beanName The bean name
* @return The bean instance, or null if not found
*/
public static <T> T getBean(String beanName) {
if (applicationContext == null) {
log.warn(
"Application context not initialized when attempting to get bean '{}'",
beanName);
return null;
}
try {
@SuppressWarnings("unchecked")
T bean = (T) applicationContext.getBean(beanName);
return bean;
} catch (BeansException e) {
log.error("Error getting bean '{}': {}", beanName, e.getMessage());
return null;
}
}
/**
* Check if the application context is initialized
*
* @return true if initialized, false otherwise
*/
public static boolean isInitialized() {
return applicationContext != null;
}
}
@@ -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() + "}";
}
}
@@ -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() + "}";
}
}
@@ -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);
}
}
@@ -0,0 +1,174 @@
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.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();
}
}
@@ -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;
}
}
@@ -0,0 +1,65 @@
package stirling.software.common.util;
import java.awt.*;
public class UIScaling {
private static final double BASE_RESOLUTION_WIDTH = 1920.0;
private static final double BASE_RESOLUTION_HEIGHT = 1080.0;
public static double getWidthScaleFactor() {
Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
return screenSize.getWidth() / BASE_RESOLUTION_WIDTH;
}
public static double getHeightScaleFactor() {
Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
return screenSize.getHeight() / BASE_RESOLUTION_HEIGHT;
}
public static int scaleWidth(int value) {
return (int) Math.round(value * getWidthScaleFactor());
}
public static int scaleHeight(int value) {
return (int) Math.round(value * getHeightScaleFactor());
}
public static Dimension scale(Dimension dim) {
return new Dimension(scaleWidth(dim.width), scaleHeight(dim.height));
}
public static Insets scale(Insets insets) {
return new Insets(
scaleHeight(insets.top),
scaleWidth(insets.left),
scaleHeight(insets.bottom),
scaleWidth(insets.right));
}
public static Font scaleFont(Font font) {
// For fonts, we'll use the smaller scale factor to ensure readability
double scaleFactor = Math.min(getWidthScaleFactor(), getHeightScaleFactor());
return font.deriveFont((float) (font.getSize() * scaleFactor));
}
// Utility method for aspect ratio aware icon scaling
public static Image scaleIcon(Image icon, int targetWidth, int targetHeight) {
if (icon == null) return null;
double widthScale = getWidthScaleFactor();
double heightScale = getHeightScaleFactor();
int scaledWidth = (int) Math.round(targetWidth * widthScale);
int scaledHeight = (int) Math.round(targetHeight * heightScale);
// Maintain aspect ratio for icons
double aspectRatio = (double) icon.getWidth(null) / icon.getHeight(null);
if (scaledWidth / scaledHeight > aspectRatio) {
scaledWidth = (int) (scaledHeight * aspectRatio);
} else {
scaledHeight = (int) (scaledWidth / aspectRatio);
}
return icon.getScaledInstance(scaledWidth, scaledHeight, Image.SCALE_SMOOTH);
}
}
@@ -0,0 +1,34 @@
package stirling.software.common.util;
import java.io.IOException;
import java.net.ServerSocket;
import jakarta.servlet.http.HttpServletRequest;
public class UrlUtils {
public static String getOrigin(HttpServletRequest request) {
String scheme = request.getScheme(); // http or https
String serverName = request.getServerName(); // localhost
int serverPort = request.getServerPort(); // 8080
String contextPath = request.getContextPath(); // /myapp
return scheme + "://" + serverName + ":" + serverPort + contextPath;
}
public static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
public static String findAvailablePort(int startPort) {
int port = startPort;
while (!isPortAvailable(port)) {
port++;
}
return String.valueOf(port);
}
}
@@ -0,0 +1,14 @@
package stirling.software.common.util;
import java.util.Collection;
public class ValidationUtil {
public static boolean isStringEmpty(String input) {
return input == null || input.isBlank();
}
public static boolean isCollectionEmpty(Collection<String> input) {
return input == null || input.isEmpty();
}
}
@@ -0,0 +1,14 @@
package stirling.software.common.util;
import java.util.Collection;
public class ValidationUtils {
public static boolean isStringEmpty(String input) {
return input == null || input.isBlank();
}
public static boolean isCollectionEmpty(Collection<String> input) {
return input == null || input.isEmpty();
}
}
@@ -0,0 +1,67 @@
package stirling.software.common.util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
public class WebResponseUtils {
public static ResponseEntity<byte[]> baosToWebResponse(
ByteArrayOutputStream baos, String docName) throws IOException {
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName);
}
public static ResponseEntity<byte[]> baosToWebResponse(
ByteArrayOutputStream baos, String docName, MediaType mediaType) throws IOException {
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName, mediaType);
}
public static ResponseEntity<byte[]> multiPartFileToWebResponse(MultipartFile file)
throws IOException {
String fileName = Filenames.toSimpleFileName(file.getOriginalFilename());
MediaType mediaType = MediaType.parseMediaType(file.getContentType());
byte[] bytes = file.getBytes();
return bytesToWebResponse(bytes, fileName, mediaType);
}
public static ResponseEntity<byte[]> bytesToWebResponse(
byte[] bytes, String docName, MediaType mediaType) throws IOException {
// Return the PDF as a response
HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
headers.setContentLength(bytes.length);
String encodedDocName =
URLEncoder.encode(docName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
headers.setContentDispositionFormData("attachment", encodedDocName);
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
}
public static ResponseEntity<byte[]> bytesToWebResponse(byte[] bytes, String docName)
throws IOException {
return bytesToWebResponse(bytes, docName, MediaType.APPLICATION_PDF);
}
public static ResponseEntity<byte[]> pdfDocToWebResponse(PDDocument document, String docName)
throws IOException {
// Open Byte Array and save document to it
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
return baosToWebResponse(baos, docName);
}
}
@@ -0,0 +1,479 @@
package stirling.software.common.util;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import org.snakeyaml.engine.v2.api.Dump;
import org.snakeyaml.engine.v2.api.DumpSettings;
import org.snakeyaml.engine.v2.api.LoadSettings;
import org.snakeyaml.engine.v2.api.StreamDataWriter;
import org.snakeyaml.engine.v2.common.FlowStyle;
import org.snakeyaml.engine.v2.common.ScalarStyle;
import org.snakeyaml.engine.v2.composer.Composer;
import org.snakeyaml.engine.v2.nodes.MappingNode;
import org.snakeyaml.engine.v2.nodes.Node;
import org.snakeyaml.engine.v2.nodes.NodeTuple;
import org.snakeyaml.engine.v2.nodes.ScalarNode;
import org.snakeyaml.engine.v2.nodes.SequenceNode;
import org.snakeyaml.engine.v2.nodes.Tag;
import org.snakeyaml.engine.v2.parser.ParserImpl;
import org.snakeyaml.engine.v2.scanner.StreamReader;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class YamlHelper {
// YAML dump settings with comment support and block flow style
private static final DumpSettings DUMP_SETTINGS =
DumpSettings.builder()
.setDumpComments(true)
.setWidth(Integer.MAX_VALUE)
.setDefaultFlowStyle(FlowStyle.BLOCK)
.build();
private final String yamlContent; // Stores the entire YAML content as a string
private LoadSettings loadSettings =
LoadSettings.builder()
.setUseMarks(true)
.setMaxAliasesForCollections(Integer.MAX_VALUE)
.setAllowRecursiveKeys(true)
.setParseComments(true)
.build();
private Path originalFilePath;
private Node updatedRootNode;
// Constructor with custom LoadSettings and YAML string
public YamlHelper(LoadSettings loadSettings, String yamlContent) {
this.loadSettings = loadSettings;
this.yamlContent = yamlContent;
}
// Constructor that reads YAML from a file path
public YamlHelper(Path originalFilePath) throws IOException {
this.yamlContent = Files.readString(originalFilePath);
this.originalFilePath = originalFilePath;
}
/**
* Updates values in the target YAML based on values from the source YAML. It ensures that only
* existing keys in the target YAML are updated.
*
* @return true if at least one key was updated, false otherwise.
*/
public boolean updateValuesFromYaml(YamlHelper sourceYaml, YamlHelper targetYaml) {
boolean updated = false;
Set<String> sourceKeys = sourceYaml.getAllKeys();
Set<String> targetKeys = targetYaml.getAllKeys();
for (String key : sourceKeys) {
String[] keyArray = key.split("\\.");
Object newValue = sourceYaml.getValueByExactKeyPath(keyArray);
Object currentValue = targetYaml.getValueByExactKeyPath(keyArray);
if (newValue != null
&& (!newValue.equals(currentValue) || !sourceKeys.equals(targetKeys))) {
boolean updatedKey = targetYaml.updateValue(Arrays.asList(keyArray), newValue);
if (updatedKey) updated = true;
}
}
return updated;
}
/**
* Updates a value in the YAML structure.
*
* @param keys The hierarchical keys leading to the value.
* @param newValue The new value to set.
* @return true if the value was updated, false otherwise.
*/
public boolean updateValue(List<String> keys, Object newValue) {
return updateValue(getRootNode(), keys, newValue);
}
private boolean updateValue(Node node, List<String> keys, Object newValue) {
if (!(node instanceof MappingNode mappingNode)) return false;
List<NodeTuple> updatedTuples = new ArrayList<>();
boolean updated = false;
for (NodeTuple tuple : mappingNode.getValue()) {
ScalarNode keyNode = (tuple.getKeyNode() instanceof ScalarNode sk) ? sk : null;
if (keyNode == null || !keyNode.getValue().equals(keys.get(0))) {
updatedTuples.add(tuple);
continue;
}
Node valueNode = tuple.getValueNode();
if (keys.size() == 1) {
Tag tag = valueNode.getTag();
Node newValueNode = null;
if (isAnyInteger(newValue)) {
newValueNode =
new ScalarNode(Tag.INT, String.valueOf(newValue), ScalarStyle.PLAIN);
} else if (isFloat(newValue)) {
Object floatValue = Float.valueOf(String.valueOf(newValue));
newValueNode =
new ScalarNode(
Tag.FLOAT, String.valueOf(floatValue), ScalarStyle.PLAIN);
} else if ("true".equals(newValue) || "false".equals(newValue)) {
newValueNode =
new ScalarNode(Tag.BOOL, String.valueOf(newValue), ScalarStyle.PLAIN);
} else if (newValue instanceof List<?> list) {
List<Node> sequenceNodes = new ArrayList<>();
for (Object item : list) {
Object obj = String.valueOf(item);
if (isAnyInteger(item)) {
tag = Tag.INT;
} else if (isFloat(item)) {
obj = Float.valueOf(String.valueOf(item));
tag = Tag.FLOAT;
} else if ("true".equals(item) || "false".equals(item)) {
tag = Tag.BOOL;
} else if (item == null || "null".equals(item)) {
tag = Tag.NULL;
} else {
tag = Tag.STR;
}
sequenceNodes.add(
new ScalarNode(tag, String.valueOf(obj), ScalarStyle.PLAIN));
}
newValueNode = new SequenceNode(Tag.SEQ, sequenceNodes, FlowStyle.FLOW);
} else if (tag == Tag.NULL) {
if ("true".equals(newValue)
|| "false".equals(newValue)
|| newValue instanceof Boolean) {
tag = Tag.BOOL;
}
newValueNode = new ScalarNode(tag, String.valueOf(newValue), ScalarStyle.PLAIN);
} else {
newValueNode = new ScalarNode(tag, String.valueOf(newValue), ScalarStyle.PLAIN);
}
copyComments(valueNode, newValueNode);
updatedTuples.add(new NodeTuple(keyNode, newValueNode));
updated = true;
} else if (valueNode instanceof MappingNode) {
updated = updateValue(valueNode, keys.subList(1, keys.size()), newValue);
updatedTuples.add(tuple);
}
}
if (updated) {
mappingNode.getValue().clear();
mappingNode.getValue().addAll(updatedTuples);
}
setNewNode(node);
return updated;
}
/**
* Fetches a value based on an exact key path.
*
* @param keys The key hierarchy leading to the value.
* @return The value if found, otherwise null.
*/
public Object getValueByExactKeyPath(String... keys) {
return getValueByExactKeyPath(getRootNode(), new ArrayDeque<>(List.of(keys)));
}
private Object getValueByExactKeyPath(Node node, Deque<String> keyQueue) {
if (!(node instanceof MappingNode mappingNode)) return null;
String currentKey = keyQueue.poll();
if (currentKey == null) return null;
for (NodeTuple tuple : mappingNode.getValue()) {
if (tuple.getKeyNode() instanceof ScalarNode keyNode
&& keyNode.getValue().equals(currentKey)) {
if (keyQueue.isEmpty()) {
Node valueNode = tuple.getValueNode();
if (valueNode instanceof ScalarNode scalarValueNode) {
return scalarValueNode.getValue();
} else if (valueNode instanceof MappingNode subMapping) {
return getValueByExactKeyPath(subMapping, keyQueue);
} else if (valueNode instanceof SequenceNode sequenceNode) {
List<Object> valuesList = new ArrayList<>();
for (Node o : sequenceNode.getValue()) {
if (o instanceof ScalarNode scalarValue) {
valuesList.add(scalarValue.getValue());
}
}
return valuesList;
} else {
return null;
}
}
return getValueByExactKeyPath(tuple.getValueNode(), keyQueue);
}
}
return null;
}
private Set<String> cachedKeys;
/**
* Retrieves the set of all keys present in the YAML structure. Keys are returned as
* dot-separated paths for nested keys.
*
* @return A set containing all keys in dot notation.
*/
public Set<String> getAllKeys() {
if (cachedKeys == null) {
cachedKeys = getAllKeys(getRootNode());
}
return cachedKeys;
}
/**
* Collects all keys from the YAML node recursively.
*
* @param node The current YAML node.
* @param currentPath The accumulated path of keys.
* @param allKeys The set storing all collected keys.
*/
private Set<String> getAllKeys(Node node) {
Set<String> allKeys = new LinkedHashSet<>();
collectKeys(node, "", allKeys);
return allKeys;
}
/**
* Recursively traverses the YAML structure to collect all keys.
*
* @param node The current node in the YAML structure.
* @param currentPath The accumulated key path.
* @param allKeys The set storing collected keys.
*/
private void collectKeys(Node node, String currentPath, Set<String> allKeys) {
if (node instanceof MappingNode mappingNode) {
for (NodeTuple tuple : mappingNode.getValue()) {
if (tuple.getKeyNode() instanceof ScalarNode keyNode) {
String newPath =
currentPath.isEmpty()
? keyNode.getValue()
: currentPath + "." + keyNode.getValue();
allKeys.add(newPath);
collectKeys(tuple.getValueNode(), newPath, allKeys);
}
}
}
}
/**
* Retrieves the root node of the YAML document. If a new node was previously set, it is
* returned instead.
*
* @return The root node of the YAML structure.
*/
private Node getRootNode() {
if (this.updatedRootNode != null) {
return this.updatedRootNode;
}
Composer composer = new Composer(loadSettings, getParserImpl());
Optional<Node> rootNodeOpt = composer.getSingleNode();
if (rootNodeOpt.isPresent()) {
return rootNodeOpt.get();
}
return null;
}
/**
* Sets a new root node, allowing modifications to be tracked.
*
* @param newRootNode The modified root node.
*/
public void setNewNode(Node newRootNode) {
this.updatedRootNode = newRootNode;
}
/**
* Retrieves the current root node (either the original or the updated one).
*
* @return The root node.
*/
public Node getUpdatedRootNode() {
if (this.updatedRootNode == null) {
this.updatedRootNode = getRootNode();
}
return this.updatedRootNode;
}
/**
* Initializes the YAML parser.
*
* @return The configured parser.
*/
private ParserImpl getParserImpl() {
return new ParserImpl(loadSettings, getStreamReader());
}
/**
* Creates a stream reader for the YAML content.
*
* @return The configured stream reader.
*/
private StreamReader getStreamReader() {
return new StreamReader(loadSettings, yamlContent);
}
public MappingNode save(Path saveFilePath) throws IOException {
if (!saveFilePath.equals(originalFilePath)) {
Files.writeString(saveFilePath, convertNodeToYaml(getUpdatedRootNode()));
}
return (MappingNode) getUpdatedRootNode();
}
public void saveOverride(Path saveFilePath) throws IOException {
Files.writeString(saveFilePath, convertNodeToYaml(getUpdatedRootNode()));
}
/**
* Converts a YAML node back to a YAML-formatted string.
*
* @param rootNode The root node to be converted.
* @return A YAML-formatted string.
*/
public String convertNodeToYaml(Node rootNode) {
StringWriter writer = new StringWriter();
StreamDataWriter streamDataWriter =
new StreamDataWriter() {
@Override
public void write(String str) {
writer.write(str);
}
@Override
public void write(String str, int off, int len) {
writer.write(str, off, len);
}
};
new Dump(DUMP_SETTINGS).dumpNode(rootNode, streamDataWriter);
return writer.toString();
}
private static boolean isParsable(String value, Function<String, ?> parser) {
try {
parser.apply(value);
return true;
} catch (NumberFormatException e) {
return false;
}
}
/**
* Checks if a given object is an integer.
*
* @param object The object to check.
* @return True if the object represents an integer, false otherwise.
*/
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
public static boolean isInteger(Object object) {
if (object instanceof Integer
|| object instanceof Short
|| object instanceof Byte
|| object instanceof Long) {
return true;
}
if (object instanceof String str) {
return isParsable(str, Integer::parseInt);
}
return false;
}
/**
* Checks if a given object is a floating-point number.
*
* @param object The object to check.
* @return True if the object represents a float, false otherwise.
*/
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
public static boolean isFloat(Object object) {
return (object instanceof Float || object instanceof Double)
|| (object instanceof String str && isParsable(str, Float::parseFloat));
}
/**
* Checks if a given object is a short integer.
*
* @param object The object to check.
* @return True if the object represents a short integer, false otherwise.
*/
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
public static boolean isShort(Object object) {
return (object instanceof Long)
|| (object instanceof String str && isParsable(str, Short::parseShort));
}
/**
* Checks if a given object is a byte.
*
* @param object The object to check.
* @return True if the object represents a byte, false otherwise.
*/
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
public static boolean isByte(Object object) {
return (object instanceof Long)
|| (object instanceof String str && isParsable(str, Byte::parseByte));
}
/**
* Checks if a given object is a long integer.
*
* @param object The object to check.
* @return True if the object represents a long integer, false otherwise.
*/
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
public static boolean isLong(Object object) {
return (object instanceof Long)
|| (object instanceof String str && isParsable(str, Long::parseLong));
}
/**
* Determines if an object is any type of integer (short, byte, long, or int).
*
* @param object The object to check.
* @return True if the object represents an integer type, false otherwise.
*/
public static boolean isAnyInteger(Object object) {
return isInteger(object) || isShort(object) || isByte(object) || isLong(object);
}
/**
* Copies comments from an old node to a new one.
*
* @param oldNode The original node with comments.
* @param newValueNode The new node to which comments should be copied.
*/
private void copyComments(Node oldNode, Node newValueNode) {
if (oldNode == null || newValueNode == null) return;
if (oldNode.getBlockComments() != null) {
newValueNode.setBlockComments(oldNode.getBlockComments());
}
if (oldNode.getInLineComments() != null) {
newValueNode.setInLineComments(oldNode.getInLineComments());
}
if (oldNode.getEndComments() != null) {
newValueNode.setEndComments(oldNode.getEndComments());
}
}
}
@@ -0,0 +1,176 @@
package stirling.software.common.util.misc;
import java.awt.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDFontFactory;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.text.TextPosition;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
@Slf4j
public class CustomColorReplaceStrategy extends ReplaceAndInvertColorStrategy {
private String textColor;
private String backgroundColor;
private HighContrastColorCombination highContrastColorCombination;
public CustomColorReplaceStrategy(
MultipartFile file,
ReplaceAndInvert replaceAndInvert,
String textColor,
String backgroundColor,
HighContrastColorCombination highContrastColorCombination) {
super(file, replaceAndInvert);
this.textColor = textColor;
this.backgroundColor = backgroundColor;
this.highContrastColorCombination = highContrastColorCombination;
}
@Override
public InputStreamResource replace() throws IOException {
// If ReplaceAndInvert is HighContrastColor option, then get the colors of text and
// background from static
if (replaceAndInvert == ReplaceAndInvert.HIGH_CONTRAST_COLOR) {
String[] colors =
HighContrastColorReplaceDecider.getColors(
replaceAndInvert, highContrastColorCombination);
this.textColor = colors[0];
this.backgroundColor = colors[1];
}
// Create a temporary file, with the original filename from the multipart file
File file = Files.createTempFile("temp", getFileInput().getOriginalFilename()).toFile();
// Transfer the content of the multipart file to the file
getFileInput().transferTo(file);
try (PDDocument document = Loader.loadPDF(file)) {
PDPageTree pages = document.getPages();
for (PDPage page : pages) {
PdfTextStripperCustom pdfTextStripperCustom = new PdfTextStripperCustom();
// Get text positions
List<List<TextPosition>> charactersByArticle =
pdfTextStripperCustom.processPageCustom(page);
// Begin a new content stream
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
// Set the new text color
contentStream.setNonStrokingColor(Color.decode(this.textColor));
// Draw the text with the new color
for (List<TextPosition> textPositions : charactersByArticle) {
for (TextPosition text : textPositions) {
// Move to the text position
contentStream.beginText();
contentStream.newLineAtOffset(
text.getX(), page.getMediaBox().getHeight() - text.getY());
PDFont font = null;
String unicodeText = text.getUnicode();
try {
font = PDFontFactory.createFont(text.getFont().getCOSObject());
} catch (IOException io) {
log.info("Primary font not found, using fallback font.");
font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
}
// if a character is not supported by font, then look for supported font
try {
byte[] bytes = font.encode(unicodeText);
} catch (IOException io) {
log.info("text could not be encoded ");
font = checkSupportedFontForCharacter(unicodeText);
} catch (IllegalArgumentException ie) {
log.info("text not supported by font ");
font = checkSupportedFontForCharacter(unicodeText);
} catch (UnsupportedOperationException ue) {
log.info(
"font does not support encoding operation: {} for text: '{}'",
font.getClass().getSimpleName(),
unicodeText);
font = checkSupportedFontForCharacter(unicodeText);
} finally {
// if any other font is not supported, then replace default character *
if (font == null) {
font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
unicodeText = "*";
}
}
contentStream.setFont(font, text.getFontSize());
contentStream.showText(unicodeText);
contentStream.endText();
}
}
// Close the content stream
contentStream.close();
// Use a content stream to overlay the background color
try (PDPageContentStream contentStreamBg =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.PREPEND,
true,
true)) {
// Set background color (e.g., light yellow)
contentStreamBg.setNonStrokingColor(Color.decode(this.backgroundColor));
contentStreamBg.addRect(
0, 0, page.getMediaBox().getWidth(), page.getMediaBox().getHeight());
contentStreamBg.fill();
}
}
// Save the modified PDF to a ByteArrayOutputStream
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
document.save(byteArrayOutputStream);
document.close();
// Prepare the modified PDF for download
ByteArrayInputStream inputStream =
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
InputStreamResource resource = new InputStreamResource(inputStream);
return resource;
}
}
private PDFont checkSupportedFontForCharacter(String unicodeText) {
Set<String> fonts = Standard14Fonts.getNames();
for (String font : fonts) {
Standard14Fonts.FontName fontName = Standard14Fonts.getMappedFontName(font);
PDFont currentFont = new PDType1Font(fontName);
try {
byte[] bytes = currentFont.encode(unicodeText);
return currentFont;
} catch (IOException io) {
log.info("text could not be encoded ");
} catch (IllegalArgumentException ie) {
log.info("text not supported by font ");
}
}
return null;
}
}
@@ -0,0 +1,30 @@
package stirling.software.common.util.misc;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
public class HighContrastColorReplaceDecider {
// To decide the text and background colors for High contrast color option for replace-invert
// color feature
public static String[] getColors(
ReplaceAndInvert replaceAndInvert,
HighContrastColorCombination highContrastColorCombination) {
if (highContrastColorCombination == HighContrastColorCombination.BLACK_TEXT_ON_WHITE) {
return new String[] {"0", "16777215"};
} else if (highContrastColorCombination
== HighContrastColorCombination.GREEN_TEXT_ON_BLACK) {
return new String[] {"65280", "0"};
} else if (highContrastColorCombination
== HighContrastColorCombination.WHITE_TEXT_ON_BLACK) {
return new String[] {"16777215", "0"};
} else if (highContrastColorCombination
== HighContrastColorCombination.YELLOW_TEXT_ON_BLACK) {
return new String[] {"16776960", "0"};
}
return null;
}
}
@@ -0,0 +1,122 @@
package stirling.software.common.util.misc;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
public InvertFullColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
super(file, replaceAndInvert);
}
@Override
public InputStreamResource replace() throws IOException {
File file = null;
try {
// Create a temporary file, with the original filename from the multipart file
file = Files.createTempFile("temp", getFileInput().getOriginalFilename()).toFile();
// Transfer the content of the multipart file to the file
getFileInput().transferTo(file);
// Load the uploaded PDF
PDDocument document = Loader.loadPDF(file);
// Render each page and invert colors
PDFRenderer pdfRenderer = new PDFRenderer(document);
for (int page = 0; page < document.getNumberOfPages(); page++) {
BufferedImage image =
pdfRenderer.renderImageWithDPI(page, 300); // Render page at 300 DPI
// Invert the colors
invertImageColors(image);
// Create a new PDPage from the inverted image
PDPage pdPage = document.getPage(page);
File tempImageFile = null;
try {
tempImageFile = convertToBufferedImageTpFile(image);
PDImageXObject pdImage =
PDImageXObject.createFromFileByContent(tempImageFile, document);
PDPageContentStream contentStream =
new PDPageContentStream(
document,
pdPage,
PDPageContentStream.AppendMode.OVERWRITE,
true);
contentStream.drawImage(
pdImage,
0,
0,
pdPage.getMediaBox().getWidth(),
pdPage.getMediaBox().getHeight());
contentStream.close();
} finally {
if (tempImageFile != null && tempImageFile.exists()) {
Files.delete(tempImageFile.toPath());
}
}
}
// Save the modified PDF to a ByteArrayOutputStream
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
document.save(byteArrayOutputStream);
document.close();
// Prepare the modified PDF for download
ByteArrayInputStream inputStream =
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
InputStreamResource resource = new InputStreamResource(inputStream);
return resource;
} finally {
if (file != null && file.exists()) {
Files.delete(file.toPath());
}
}
}
// Method to invert image colors
private void invertImageColors(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
int rgba = image.getRGB(x, y);
Color color = new Color(rgba, true);
Color invertedColor =
new Color(
255 - color.getRed(),
255 - color.getGreen(),
255 - color.getBlue());
image.setRGB(x, y, invertedColor.getRGB());
}
}
}
// Helper method to convert BufferedImage to InputStream
private File convertToBufferedImageTpFile(BufferedImage image) throws IOException {
File file = File.createTempFile("image", ".png");
ImageIO.write(image, "png", file);
return file;
}
}
@@ -0,0 +1,36 @@
package stirling.software.common.util.misc;
import java.awt.geom.Rectangle2D;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.PDFTextStripperByArea;
import org.apache.pdfbox.text.TextPosition;
public class PdfTextStripperCustom extends PDFTextStripperByArea {
/**
* Constructor.
*
* @throws IOException If there is an error loading properties.
*/
public PdfTextStripperCustom() throws IOException {}
// To process the page text using stripper and returns the TextPosition and its values
public List<List<TextPosition>> processPageCustom(PDPage page) throws IOException {
addRegion(
"wholePage",
new Rectangle2D.Float(
page.getMediaBox().getLowerLeftX(),
page.getMediaBox().getLowerLeftY(),
page.getMediaBox().getWidth(),
page.getMediaBox().getHeight()));
extractRegions(page);
List<List<TextPosition>> textPositions = getCharactersByArticle();
return textPositions;
}
}
@@ -0,0 +1,26 @@
package stirling.software.common.util.misc;
import java.io.IOException;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.multipart.MultipartFile;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
@Data
@EqualsAndHashCode(callSuper = true)
public abstract class ReplaceAndInvertColorStrategy extends PDFFile {
protected ReplaceAndInvert replaceAndInvert;
public ReplaceAndInvertColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
setFileInput(file);
setReplaceAndInvert(replaceAndInvert);
}
public abstract InputStreamResource replace() throws IOException;
}
@@ -0,0 +1,37 @@
package stirling.software.common.util.propertyeditor;
import java.beans.PropertyEditorSupport;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.security.RedactionArea;
@Slf4j
public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (text == null || text.trim().isEmpty()) {
setValue(new ArrayList<>());
return;
}
try {
objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
TypeReference<ArrayList<RedactionArea>> typeRef = new TypeReference<>() {};
List<RedactionArea> list = objectMapper.readValue(text, typeRef);
setValue(list);
} catch (Exception e) {
log.error("Exception while converting {}", e);
throw new IllegalArgumentException(
"Failed to convert java.lang.String to java.util.List");
}
}
}
@@ -0,0 +1,25 @@
package stirling.software.common.util.propertyeditor;
import java.beans.PropertyEditorSupport;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
public class StringToMapPropertyEditor extends PropertyEditorSupport {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void setAsText(String text) throws IllegalArgumentException {
try {
TypeReference<HashMap<String, String>> typeRef = new TypeReference<>() {};
Map<String, String> map = objectMapper.readValue(text, typeRef);
setValue(map);
} catch (Exception e) {
throw new IllegalArgumentException(
"Failed to convert java.lang.String to java.util.Map");
}
}
}
@@ -0,0 +1,208 @@
package stirling.software.common.annotations;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import java.util.Arrays;
import java.util.function.Supplier;
import org.aspectj.lang.ProceedingJoinPoint;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.aop.AutoJobAspect;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileOrUploadService;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
@ExtendWith(MockitoExtension.class)
class AutoJobPostMappingIntegrationTest {
private AutoJobAspect autoJobAspect;
@Mock
private JobExecutorService jobExecutorService;
@Mock
private HttpServletRequest request;
@Mock
private FileOrUploadService fileOrUploadService;
@Mock
private FileStorage fileStorage;
@Mock
private ResourceMonitor resourceMonitor;
@Mock
private JobQueue jobQueue;
@BeforeEach
void setUp() {
autoJobAspect = new AutoJobAspect(
jobExecutorService,
request,
fileOrUploadService,
fileStorage
);
}
@Mock
private ProceedingJoinPoint joinPoint;
@Mock
private AutoJobPostMapping autoJobPostMapping;
@Captor
private ArgumentCaptor<Supplier<Object>> workCaptor;
@Captor
private ArgumentCaptor<Boolean> asyncCaptor;
@Captor
private ArgumentCaptor<Long> timeoutCaptor;
@Captor
private ArgumentCaptor<Boolean> queueableCaptor;
@Captor
private ArgumentCaptor<Integer> resourceWeightCaptor;
@Test
void shouldExecuteWithCustomParameters() throws Throwable {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileId("test-file-id");
Object[] args = new Object[] { pdfFile };
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
when(autoJobPostMapping.timeout()).thenReturn(60000L);
when(autoJobPostMapping.retryCount()).thenReturn(3);
when(autoJobPostMapping.trackProgress()).thenReturn(true);
when(autoJobPostMapping.queueable()).thenReturn(true);
when(autoJobPostMapping.resourceWeight()).thenReturn(75);
MultipartFile mockFile = mock(MultipartFile.class);
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals(ResponseEntity.ok("success"), result);
verify(jobExecutorService).runJobGeneric(
asyncCaptor.capture(),
workCaptor.capture(),
timeoutCaptor.capture(),
queueableCaptor.capture(),
resourceWeightCaptor.capture());
assertTrue(asyncCaptor.getValue(), "Async should be true");
assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms");
assertTrue(queueableCaptor.getValue(), "Queueable should be true");
assertEquals(75, resourceWeightCaptor.getValue(), "Resource weight should be 75");
// Test that file was resolved
assertNotNull(pdfFile.getFileInput(), "File input should be set");
}
@Test
void shouldRetryOnError() throws Throwable {
// Given
when(joinPoint.getArgs()).thenReturn(new Object[0]);
when(request.getParameter("async")).thenReturn("false");
when(autoJobPostMapping.timeout()).thenReturn(-1L);
when(autoJobPostMapping.retryCount()).thenReturn(2);
when(autoJobPostMapping.trackProgress()).thenReturn(false);
when(autoJobPostMapping.queueable()).thenReturn(false);
when(autoJobPostMapping.resourceWeight()).thenReturn(50);
// First call throws exception, second succeeds
when(joinPoint.proceed(any()))
.thenThrow(new RuntimeException("First attempt failed"))
.thenReturn(ResponseEntity.ok("retry succeeded"));
// Mock jobExecutorService to execute the work immediately
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenAnswer(invocation -> {
Supplier<Object> work = invocation.getArgument(1);
return work.get();
});
// When
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals(ResponseEntity.ok("retry succeeded"), result);
// Verify that proceed was called twice (initial attempt + 1 retry)
verify(joinPoint, times(2)).proceed(any());
}
@Test
void shouldHandlePDFFileWithAsyncRequests() throws Throwable {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(mock(MultipartFile.class));
Object[] args = new Object[] { pdfFile };
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
when(autoJobPostMapping.retryCount()).thenReturn(1);
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("stored-file-id");
when(fileStorage.retrieveFile("stored-file-id")).thenReturn(mock(MultipartFile.class));
// Mock job executor to return a successful response
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals("stored-file-id", pdfFile.getFileId(),
"FileId should be set to the stored file id");
assertNotNull(pdfFile.getFileInput(), "FileInput should be replaced with persistent file");
// Verify storage operations
verify(fileStorage).storeFile(any(MultipartFile.class));
verify(fileStorage).retrieveFile("stored-file-id");
}
}
@@ -0,0 +1,223 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static stirling.software.common.service.SpyPDFDocumentFactory.*;
import java.io.*;
import java.nio.file.*;
import java.nio.file.Files;
import java.util.Arrays;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.*;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.api.PDFFile;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Execution(value = ExecutionMode.SAME_THREAD)
class CustomPDFDocumentFactoryTest {
private SpyPDFDocumentFactory factory;
private byte[] basePdfBytes;
@BeforeEach
void setup() throws IOException {
PdfMetadataService mockService = mock(PdfMetadataService.class);
factory = new SpyPDFDocumentFactory(mockService);
try (InputStream is = getClass().getResourceAsStream("/example.pdf")) {
assertNotNull(is, "example.pdf must be present in src/test/resources");
basePdfBytes = is.readAllBytes();
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_FileInput(int sizeMB, StrategyType expected) throws IOException {
File file = writeTempFile(inflatePdf(basePdfBytes, sizeMB));
try (PDDocument doc = factory.load(file)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_ByteArray(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
try (PDDocument doc = factory.load(inflated)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_InputStream(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
try (PDDocument doc = factory.load(new ByteArrayInputStream(inflated))) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_MultipartFile(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
MockMultipartFile multipart =
new MockMultipartFile("file", "doc.pdf", "application/pdf", inflated);
try (PDDocument doc = factory.load(multipart)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_PDFFile(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
MockMultipartFile multipart =
new MockMultipartFile("file", "doc.pdf", "application/pdf", inflated);
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(multipart);
try (PDDocument doc = factory.load(pdfFile)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
private byte[] inflatePdf(byte[] input, int sizeInMB) throws IOException {
try (PDDocument doc = Loader.loadPDF(input)) {
byte[] largeData = new byte[sizeInMB * 1024 * 1024];
Arrays.fill(largeData, (byte) 'A');
PDStream stream = new PDStream(doc, new ByteArrayInputStream(largeData));
stream.getCOSObject().setItem(COSName.TYPE, COSName.XOBJECT);
stream.getCOSObject().setItem(COSName.SUBTYPE, COSName.IMAGE);
doc.getDocumentCatalog()
.getCOSObject()
.setItem(COSName.getPDFName("DummyBigStream"), stream.getCOSObject());
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.save(out);
return out.toByteArray();
}
}
@Test
void testLoadFromPath() throws IOException {
File file = writeTempFile(inflatePdf(basePdfBytes, 5));
Path path = file.toPath();
try (PDDocument doc = factory.load(path)) {
assertNotNull(doc);
}
}
@Test
void testLoadFromStringPath() throws IOException {
File file = writeTempFile(inflatePdf(basePdfBytes, 5));
try (PDDocument doc = factory.load(file.getAbsolutePath())) {
assertNotNull(doc);
}
}
// neeed to add password pdf
// @Test
// void testLoadPasswordProtectedPdfFromInputStream() throws IOException {
// try (InputStream is = getClass().getResourceAsStream("/protected.pdf")) {
// assertNotNull(is, "protected.pdf must be present in src/test/resources");
// try (PDDocument doc = factory.load(is, "test123")) {
// assertNotNull(doc);
// }
// }
// }
//
// @Test
// void testLoadPasswordProtectedPdfFromMultipart() throws IOException {
// try (InputStream is = getClass().getResourceAsStream("/protected.pdf")) {
// assertNotNull(is, "protected.pdf must be present in src/test/resources");
// byte[] bytes = is.readAllBytes();
// MockMultipartFile file = new MockMultipartFile("file", "protected.pdf",
// "application/pdf", bytes);
// try (PDDocument doc = factory.load(file, "test123")) {
// assertNotNull(doc);
// }
// }
// }
@Test
void testLoadReadOnlySkipsPostProcessing() throws IOException {
PdfMetadataService mockService = mock(PdfMetadataService.class);
CustomPDFDocumentFactory readOnlyFactory = new CustomPDFDocumentFactory(mockService);
byte[] bytes = inflatePdf(basePdfBytes, 5);
try (PDDocument doc = readOnlyFactory.load(bytes, true)) {
assertNotNull(doc);
verify(mockService, never()).setDefaultMetadata(any());
}
}
@Test
void testCreateNewDocument() throws IOException {
try (PDDocument doc = factory.createNewDocument()) {
assertNotNull(doc);
}
}
@Test
void testCreateNewDocumentBasedOnOldDocument() throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, 5);
try (PDDocument oldDoc = Loader.loadPDF(inflated);
PDDocument newDoc = factory.createNewDocumentBasedOnOldDocument(oldDoc)) {
assertNotNull(newDoc);
}
}
@Test
void testLoadToBytesRoundTrip() throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, 5);
File file = writeTempFile(inflated);
byte[] resultBytes = factory.loadToBytes(file);
try (PDDocument doc = Loader.loadPDF(resultBytes)) {
assertNotNull(doc);
assertTrue(doc.getNumberOfPages() > 0);
}
}
@Test
void testSaveToBytesAndReload() throws IOException {
try (PDDocument doc = Loader.loadPDF(basePdfBytes)) {
byte[] saved = factory.saveToBytes(doc);
try (PDDocument reloaded = Loader.loadPDF(saved)) {
assertNotNull(reloaded);
assertEquals(doc.getNumberOfPages(), reloaded.getNumberOfPages());
}
}
}
@Test
void testCreateNewBytesBasedOnOldDocument() throws IOException {
byte[] newBytes = factory.createNewBytesBasedOnOldDocument(basePdfBytes);
assertNotNull(newBytes);
assertTrue(newBytes.length > 0);
}
private File writeTempFile(byte[] content) throws IOException {
File file = Files.createTempFile("pdf-test-", ".pdf").toFile();
Files.write(file.toPath(), content);
return file;
}
@BeforeEach
void cleanup() {
System.gc();
}
}
@@ -0,0 +1,190 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.AdditionalAnswers.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
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.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
class FileStorageTest {
@TempDir
Path tempDir;
@Mock
private FileOrUploadService fileOrUploadService;
@InjectMocks
private FileStorage fileStorage;
private MultipartFile mockFile;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString());
// Create a mock MultipartFile
mockFile = mock(MultipartFile.class);
when(mockFile.getOriginalFilename()).thenReturn("test.pdf");
when(mockFile.getContentType()).thenReturn("application/pdf");
}
@Test
void testStoreFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
when(mockFile.getBytes()).thenReturn(fileContent);
// Set up mock to handle transferTo by writing the file
doAnswer(invocation -> {
java.io.File file = invocation.getArgument(0);
Files.write(file.toPath(), fileContent);
return null;
}).when(mockFile).transferTo(any(java.io.File.class));
// Act
String fileId = fileStorage.storeFile(mockFile);
// Assert
assertNotNull(fileId);
assertTrue(Files.exists(tempDir.resolve(fileId)));
verify(mockFile).transferTo(any(java.io.File.class));
}
@Test
void testStoreBytes() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String originalName = "test.pdf";
// Act
String fileId = fileStorage.storeBytes(fileContent, originalName);
// Assert
assertNotNull(fileId);
assertTrue(Files.exists(tempDir.resolve(fileId)));
assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId)));
}
@Test
void testRetrieveFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
MultipartFile expectedFile = mock(MultipartFile.class);
when(fileOrUploadService.toMockMultipartFile(eq(fileId), eq(fileContent)))
.thenReturn(expectedFile);
// Act
MultipartFile result = fileStorage.retrieveFile(fileId);
// Assert
assertSame(expectedFile, result);
verify(fileOrUploadService).toMockMultipartFile(eq(fileId), eq(fileContent));
}
@Test
void testRetrieveBytes() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
// Act
byte[] result = fileStorage.retrieveBytes(fileId);
// Assert
assertArrayEquals(fileContent, result);
}
@Test
void testRetrieveFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId));
}
@Test
void testRetrieveBytes_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId));
}
@Test
void testDeleteFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
// Act
boolean result = fileStorage.deleteFile(fileId);
// Assert
assertTrue(result);
assertFalse(Files.exists(filePath));
}
@Test
void testDeleteFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act
boolean result = fileStorage.deleteFile(nonExistentFileId);
// Assert
assertFalse(result);
}
@Test
void testFileExists() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
// Act
boolean result = fileStorage.fileExists(fileId);
// Assert
assertTrue(result);
}
@Test
void testFileExists_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act
boolean result = fileStorage.fileExists(nonExistentFileId);
// Assert
assertFalse(result);
}
}
@@ -0,0 +1,202 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.model.job.JobResponse;
@ExtendWith(MockitoExtension.class)
class JobExecutorServiceTest {
private JobExecutorService jobExecutorService;
@Mock
private TaskManager taskManager;
@Mock
private FileStorage fileStorage;
@Mock
private HttpServletRequest request;
@Mock
private ResourceMonitor resourceMonitor;
@Mock
private JobQueue jobQueue;
@Captor
private ArgumentCaptor<String> jobIdCaptor;
@BeforeEach
void setUp() {
// Initialize the service manually with all its dependencies
jobExecutorService = new JobExecutorService(
taskManager,
fileStorage,
request,
resourceMonitor,
jobQueue,
30000L, // asyncRequestTimeoutMs
"30m" // sessionTimeout
);
}
@Test
void shouldRunSyncJobSuccessfully() throws Exception {
// Given
Supplier<Object> work = () -> "test-result";
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("test-result", response.getBody());
// Verify request attribute was set with jobId
verify(request).setAttribute(eq("jobId"), anyString());
}
@Test
void shouldRunAsyncJobSuccessfully() throws Exception {
// Given
Supplier<Object> work = () -> "test-result";
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody() instanceof JobResponse);
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
assertTrue(jobResponse.isAsync());
assertNotNull(jobResponse.getJobId());
// Verify task manager was called
verify(taskManager).createTask(jobIdCaptor.capture());
}
@Test
void shouldHandleSyncJobError() {
// Given
Supplier<Object> work = () -> {
throw new RuntimeException("Test error");
};
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
// Then
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, String> errorMap = (Map<String, String>) response.getBody();
assertEquals("Job failed: Test error", errorMap.get("error"));
}
@Test
void shouldQueueJobWhenResourcesLimited() {
// Given
Supplier<Object> work = () -> "test-result";
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
// Configure resourceMonitor to indicate job should be queued
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
// Configure jobQueue to return our future
when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future);
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(
true, work, 5000, true, 80);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody() instanceof JobResponse);
// Verify job was queued
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
verify(taskManager).createTask(anyString());
}
@Test
void shouldUseCustomTimeoutWhenProvided() throws Exception {
// Given
Supplier<Object> work = () -> "test-result";
long customTimeout = 60000L;
// Use reflection to access the private executeWithTimeout method
java.lang.reflect.Method executeMethod = JobExecutorService.class
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
executeMethod.setAccessible(true);
// Create a spy on the JobExecutorService to verify method calls
JobExecutorService spy = Mockito.spy(jobExecutorService);
// When
spy.runJobGeneric(false, work, customTimeout);
// Then
verify(spy).runJobGeneric(eq(false), any(Supplier.class), eq(customTimeout));
}
@Test
void shouldHandleTimeout() throws Exception {
// Given
Supplier<Object> work = () -> {
try {
Thread.sleep(100); // Simulate long-running job
return "test-result";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
};
// Use reflection to access the private executeWithTimeout method
java.lang.reflect.Method executeMethod = JobExecutorService.class
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
executeMethod.setAccessible(true);
// When/Then
try {
executeMethod.invoke(jobExecutorService, work, 1L); // Very short timeout
} catch (Exception e) {
assertTrue(e.getCause() instanceof TimeoutException);
}
}
}
@@ -0,0 +1,102 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
@ExtendWith(MockitoExtension.class)
class JobQueueTest {
private JobQueue jobQueue;
@Mock
private ResourceMonitor resourceMonitor;
private final AtomicReference<ResourceStatus> statusRef = new AtomicReference<>(ResourceStatus.OK);
@BeforeEach
void setUp() {
// Mark stubbing as lenient to avoid UnnecessaryStubbingException
lenient().when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(10);
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
// Initialize JobQueue with mocked ResourceMonitor
jobQueue = new JobQueue(resourceMonitor);
}
@Test
void shouldQueueJob() {
String jobId = "test-job-1";
int resourceWeight = 50;
Supplier<Object> work = () -> "test-result";
long timeoutMs = 1000;
jobQueue.queueJob(jobId, resourceWeight, work, timeoutMs);
assertTrue(jobQueue.isJobQueued(jobId));
assertEquals(1, jobQueue.getTotalQueuedJobs());
}
@Test
void shouldCancelJob() {
String jobId = "test-job-2";
Supplier<Object> work = () -> "test-result";
jobQueue.queueJob(jobId, 50, work, 1000);
boolean cancelled = jobQueue.cancelJob(jobId);
assertTrue(cancelled);
assertFalse(jobQueue.isJobQueued(jobId));
}
@Test
void shouldGetQueueStats() {
when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
jobQueue.queueJob("job1", 50, () -> "ok", 1000);
jobQueue.queueJob("job2", 50, () -> "ok", 1000);
jobQueue.cancelJob("job2");
Map<String, Object> stats = jobQueue.getQueueStats();
assertEquals(2, stats.get("totalQueuedJobs"));
assertTrue(stats.containsKey("queuedJobs"));
assertTrue(stats.containsKey("resourceStatus"));
}
@Test
void shouldCalculateQueueCapacity() {
when(resourceMonitor.calculateDynamicQueueCapacity(5, 2)).thenReturn(8);
int capacity = resourceMonitor.calculateDynamicQueueCapacity(5, 2);
assertEquals(8, capacity);
}
@Test
void shouldCheckIfJobIsQueued() {
String jobId = "job-123";
Supplier<Object> work = () -> "hello";
jobQueue.queueJob(jobId, 40, work, 500);
assertTrue(jobQueue.isJobQueued(jobId));
assertFalse(jobQueue.isJobQueued("nonexistent"));
}
}
@@ -0,0 +1,137 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.lang.management.OperatingSystemMXBean;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
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.CsvSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.service.ResourceMonitor.ResourceMetrics;
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
@ExtendWith(MockitoExtension.class)
class ResourceMonitorTest {
@InjectMocks
private ResourceMonitor resourceMonitor;
@Mock
private OperatingSystemMXBean osMXBean;
@Mock
private MemoryMXBean memoryMXBean;
@Spy
private AtomicReference<ResourceStatus> currentStatus = new AtomicReference<>(ResourceStatus.OK);
@Spy
private AtomicReference<ResourceMetrics> latestMetrics = new AtomicReference<>(new ResourceMetrics());
@BeforeEach
void setUp() {
// Set thresholds for testing
ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9);
ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75);
ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9);
ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75);
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean);
ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean);
ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus);
ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics);
}
@Test
void shouldCalculateDynamicQueueCapacity() {
// Given
int baseCapacity = 10;
int minCapacity = 2;
// Mock current status as OK
currentStatus.set(ResourceStatus.OK);
// When
int capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
// Then
assertEquals(baseCapacity, capacity, "With OK status, capacity should equal base capacity");
// Given
currentStatus.set(ResourceStatus.WARNING);
// When
capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
// Then
assertEquals(6, capacity, "With WARNING status, capacity should be reduced to 60%");
// Given
currentStatus.set(ResourceStatus.CRITICAL);
// When
capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
// Then
assertEquals(3, capacity, "With CRITICAL status, capacity should be reduced to 30%");
// Test minimum capacity enforcement
assertEquals(minCapacity, resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity),
"Should never go below minimum capacity");
}
@ParameterizedTest
@CsvSource({
"10, OK, false", // Light job, OK status
"10, WARNING, false", // Light job, WARNING status
"10, CRITICAL, true", // Light job, CRITICAL status
"30, OK, false", // Medium job, OK status
"30, WARNING, true", // Medium job, WARNING status
"30, CRITICAL, true", // Medium job, CRITICAL status
"80, OK, true", // Heavy job, OK status
"80, WARNING, true", // Heavy job, WARNING status
"80, CRITICAL, true" // Heavy job, CRITICAL status
})
void shouldQueueJobBasedOnWeightAndStatus(int weight, ResourceStatus status, boolean shouldQueue) {
// Given
currentStatus.set(status);
// When
boolean result = resourceMonitor.shouldQueueJob(weight);
// Then
assertEquals(shouldQueue, result,
String.format("For weight %d and status %s, shouldQueue should be %s",
weight, status, shouldQueue));
}
@Test
void resourceMetricsShouldDetectStaleState() {
// Given
Instant now = Instant.now();
Instant pastInstant = now.minusMillis(6000);
ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant);
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now);
// When/Then
assertTrue(staleMetrics.isStale(5000), "Metrics from 6 seconds ago should be stale with 5s threshold");
assertFalse(freshMetrics.isStale(5000), "Fresh metrics should not be stale");
}
}
@@ -0,0 +1,31 @@
package stirling.software.common.service;
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
class SpyPDFDocumentFactory extends CustomPDFDocumentFactory {
enum StrategyType {
MEMORY_ONLY,
MIXED,
TEMP_FILE
}
public StrategyType lastStrategyUsed;
public SpyPDFDocumentFactory(PdfMetadataService service) {
super(service);
}
@Override
public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) {
StrategyType type;
if (contentSize < 10 * 1024 * 1024) {
type = StrategyType.MEMORY_ONLY;
} else if (contentSize < 50 * 1024 * 1024) {
type = StrategyType.MIXED;
} else {
type = StrategyType.TEMP_FILE;
}
this.lastStrategyUsed = type;
return super.getStreamCacheFunction(contentSize); // delegate to real behavior
}
}
@@ -0,0 +1,309 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
class TaskManagerTest {
@Mock
private FileStorage fileStorage;
@InjectMocks
private TaskManager taskManager;
private AutoCloseable closeable;
@BeforeEach
void setUp() {
closeable = MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
}
@AfterEach
void tearDown() throws Exception {
closeable.close();
}
@Test
void testCreateTask() {
// Act
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertEquals(jobId, result.getJobId());
assertFalse(result.isComplete());
assertNotNull(result.getCreatedAt());
}
@Test
void testSetResult() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
Object resultObject = "Test result";
// Act
taskManager.setResult(jobId, resultObject);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
assertNotNull(result.getCompletedAt());
}
@Test
void testSetFileResult() throws Exception {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String fileId = "file-id";
String originalFileName = "test.pdf";
String contentType = "application/pdf";
long fileSize = 1024L;
// Mock the fileStorage.getFileSize() call
when(fileStorage.getFileSize(fileId)).thenReturn(fileSize);
// Act
taskManager.setFileResult(jobId, fileId, originalFileName, contentType);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertTrue(result.hasFiles());
assertFalse(result.hasMultipleFiles());
var resultFiles = result.getAllResultFiles();
assertEquals(1, resultFiles.size());
ResultFile resultFile = resultFiles.get(0);
assertEquals(fileId, resultFile.getFileId());
assertEquals(originalFileName, resultFile.getFileName());
assertEquals(contentType, resultFile.getContentType());
assertEquals(fileSize, resultFile.getFileSize());
assertNotNull(result.getCompletedAt());
}
@Test
void testSetError() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String errorMessage = "Test error";
// Act
taskManager.setError(jobId, errorMessage);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(errorMessage, result.getError());
assertNotNull(result.getCompletedAt());
}
@Test
void testSetComplete_WithExistingResult() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
Object resultObject = "Test result";
taskManager.setResult(jobId, resultObject);
// Act
taskManager.setComplete(jobId);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
}
@Test
void testSetComplete_WithoutExistingResult() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Act
taskManager.setComplete(jobId);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals("Task completed successfully", result.getResult());
}
@Test
void testIsComplete() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Assert - not complete initially
assertFalse(taskManager.isComplete(jobId));
// Act - mark as complete
taskManager.setComplete(jobId);
// Assert - now complete
assertTrue(taskManager.isComplete(jobId));
}
@Test
void testGetJobStats() throws Exception {
// Arrange
// Mock fileStorage.getFileSize for file operations
when(fileStorage.getFileSize("file-id")).thenReturn(1024L);
// 1. Create active job
String activeJobId = "active-job";
taskManager.createTask(activeJobId);
// 2. Create completed successful job with file
String successFileJobId = "success-file-job";
taskManager.createTask(successFileJobId);
taskManager.setFileResult(successFileJobId, "file-id", "test.pdf", "application/pdf");
// 3. Create completed successful job without file
String successJobId = "success-job";
taskManager.createTask(successJobId);
taskManager.setResult(successJobId, "Result");
// 4. Create failed job
String failedJobId = "failed-job";
taskManager.createTask(failedJobId);
taskManager.setError(failedJobId, "Error message");
// Act
JobStats stats = taskManager.getJobStats();
// Assert
assertEquals(4, stats.getTotalJobs());
assertEquals(1, stats.getActiveJobs());
assertEquals(3, stats.getCompletedJobs());
assertEquals(1, stats.getFailedJobs());
assertEquals(2, stats.getSuccessfulJobs());
assertEquals(1, stats.getFileResultJobs());
assertNotNull(stats.getNewestActiveJobTime());
assertNotNull(stats.getOldestActiveJobTime());
assertTrue(stats.getAverageProcessingTimeMs() >= 0);
}
@Test
void testCleanupOldJobs() throws Exception {
// Arrange
// 1. Create a recent completed job
String recentJobId = "recent-job";
taskManager.createTask(recentJobId);
taskManager.setResult(recentJobId, "Result");
// 2. Create an old completed job with file result
String oldJobId = "old-job";
taskManager.createTask(oldJobId);
JobResult oldJob = taskManager.getJobResult(oldJobId);
// Manually set the completion time to be older than the expiry
LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
ReflectionTestUtils.setField(oldJob, "complete", true);
// Create a ResultFile and set it using the new approach
ResultFile resultFile = ResultFile.builder()
.fileId("file-id")
.fileName("test.pdf")
.contentType("application/pdf")
.fileSize(1024L)
.build();
ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile));
when(fileStorage.deleteFile("file-id")).thenReturn(true);
// Obtain access to the private jobResults map
Map<String, JobResult> jobResultsMap = (Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
// 3. Create an active job
String activeJobId = "active-job";
taskManager.createTask(activeJobId);
// Verify all jobs are in the map
assertTrue(jobResultsMap.containsKey(recentJobId));
assertTrue(jobResultsMap.containsKey(oldJobId));
assertTrue(jobResultsMap.containsKey(activeJobId));
// Act
taskManager.cleanupOldJobs();
// Assert - the old job should be removed
assertFalse(jobResultsMap.containsKey(oldJobId));
assertTrue(jobResultsMap.containsKey(recentJobId));
assertTrue(jobResultsMap.containsKey(activeJobId));
verify(fileStorage).deleteFile("file-id");
}
@Test
void testShutdown() throws Exception {
// This mainly tests that the shutdown method doesn't throw exceptions
taskManager.shutdown();
// Verify the executor service is shutdown
// This is difficult to test directly, but we can verify it doesn't throw exceptions
}
@Test
void testAddNote() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String note = "Test note";
// Act
boolean result = taskManager.addNote(jobId, note);
// Assert
assertTrue(result);
JobResult jobResult = taskManager.getJobResult(jobId);
assertNotNull(jobResult);
assertNotNull(jobResult.getNotes());
assertEquals(1, jobResult.getNotes().size());
assertEquals(note, jobResult.getNotes().get(0));
}
@Test
void testAddNote_NonExistentJob() {
// Arrange
String jobId = "non-existent-job";
String note = "Test note";
// Act
boolean result = taskManager.addNote(jobId, note);
// Assert
assertFalse(result);
}
}
@@ -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));
}
}
@@ -0,0 +1,205 @@
package stirling.software.common.util;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.Arrays;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CheckProgramInstallTest {
private MockedStatic<ProcessExecutor> mockProcessExecutor;
private ProcessExecutor mockExecutor;
@BeforeEach
void setUp() throws Exception {
// Reset static variables before each test
resetStaticFields();
// Set up mock for ProcessExecutor
mockExecutor = Mockito.mock(ProcessExecutor.class);
mockProcessExecutor = mockStatic(ProcessExecutor.class);
mockProcessExecutor
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(mockExecutor);
}
@AfterEach
void tearDown() {
// Close the static mock to prevent memory leaks
if (mockProcessExecutor != null) {
mockProcessExecutor.close();
}
}
/** Reset static fields in the CheckProgramInstall class using reflection */
private void resetStaticFields() throws Exception {
Field pythonAvailableCheckedField =
CheckProgramInstall.class.getDeclaredField("pythonAvailableChecked");
pythonAvailableCheckedField.setAccessible(true);
pythonAvailableCheckedField.set(null, false);
Field availablePythonCommandField =
CheckProgramInstall.class.getDeclaredField("availablePythonCommand");
availablePythonCommandField.setAccessible(true);
availablePythonCommandField.set(null, null);
}
@Test
void testGetAvailablePythonCommand_WhenPython3IsAvailable()
throws IOException, InterruptedException {
// Arrange
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(0);
when(result.getMessages()).thenReturn("Python 3.9.0");
when(mockExecutor.runCommandWithOutputHandling(Arrays.asList("python3", "--version")))
.thenReturn(result);
// Act
String pythonCommand = CheckProgramInstall.getAvailablePythonCommand();
// Assert
assertEquals("python3", pythonCommand);
assertTrue(CheckProgramInstall.isPythonAvailable());
// Verify that the command was executed
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
}
@Test
void testGetAvailablePythonCommand_WhenPython3IsNotAvailableButPythonIs()
throws IOException, InterruptedException {
// Arrange
when(mockExecutor.runCommandWithOutputHandling(Arrays.asList("python3", "--version")))
.thenThrow(new IOException("Command not found"));
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(0);
when(result.getMessages()).thenReturn("Python 2.7.0");
when(mockExecutor.runCommandWithOutputHandling(Arrays.asList("python", "--version")))
.thenReturn(result);
// Act
String pythonCommand = CheckProgramInstall.getAvailablePythonCommand();
// Assert
assertEquals("python", pythonCommand);
assertTrue(CheckProgramInstall.isPythonAvailable());
// Verify that both commands were attempted
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python", "--version"));
}
@Test
void testGetAvailablePythonCommand_WhenPythonReturnsNonZeroExitCode()
throws IOException, InterruptedException, Exception {
// Arrange
// Reset the static fields again to ensure clean state
resetStaticFields();
// Since we want to test the scenario where Python returns a non-zero exit code
// We need to make sure both python3 and python commands are mocked to return failures
ProcessExecutorResult resultPython3 = Mockito.mock(ProcessExecutorResult.class);
when(resultPython3.getRc()).thenReturn(1); // Non-zero exit code
when(resultPython3.getMessages()).thenReturn("Error");
// Important: in the CheckProgramInstall implementation, only checks if
// command throws exception, it doesn't check the return code
// So we need to throw an exception instead
when(mockExecutor.runCommandWithOutputHandling(Arrays.asList("python3", "--version")))
.thenThrow(new IOException("Command failed with non-zero exit code"));
when(mockExecutor.runCommandWithOutputHandling(Arrays.asList("python", "--version")))
.thenThrow(new IOException("Command failed with non-zero exit code"));
// Act
String pythonCommand = CheckProgramInstall.getAvailablePythonCommand();
// Assert - Both commands throw exceptions, so no python is available
assertNull(pythonCommand);
assertFalse(CheckProgramInstall.isPythonAvailable());
}
@Test
void testGetAvailablePythonCommand_WhenNoPythonIsAvailable()
throws IOException, InterruptedException {
// Arrange
when(mockExecutor.runCommandWithOutputHandling(anyList()))
.thenThrow(new IOException("Command not found"));
// Act
String pythonCommand = CheckProgramInstall.getAvailablePythonCommand();
// Assert
assertNull(pythonCommand);
assertFalse(CheckProgramInstall.isPythonAvailable());
// Verify attempts to run both python3 and python
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python", "--version"));
}
@Test
void testGetAvailablePythonCommand_CachesResult() throws IOException, InterruptedException {
// Arrange
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(0);
when(result.getMessages()).thenReturn("Python 3.9.0");
when(mockExecutor.runCommandWithOutputHandling(Arrays.asList("python3", "--version")))
.thenReturn(result);
// Act
String firstCall = CheckProgramInstall.getAvailablePythonCommand();
// Change the mock to simulate a change in the environment
when(mockExecutor.runCommandWithOutputHandling(anyList()))
.thenThrow(new IOException("Command not found"));
String secondCall = CheckProgramInstall.getAvailablePythonCommand();
// Assert
assertEquals("python3", firstCall);
assertEquals("python3", secondCall); // Second call should return the cached result
// Verify python3 command was only executed once (caching worked)
verify(mockExecutor, times(1))
.runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
}
@Test
void testIsPythonAvailable_DirectCall() throws Exception {
// Arrange
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(0);
when(result.getMessages()).thenReturn("Python 3.9.0");
when(mockExecutor.runCommandWithOutputHandling(Arrays.asList("python3", "--version")))
.thenReturn(result);
// Reset again to ensure clean state
resetStaticFields();
// Act - Call isPythonAvailable() directly
boolean pythonAvailable = CheckProgramInstall.isPythonAvailable();
// Assert
assertTrue(pythonAvailable);
// Verify getAvailablePythonCommand was called internally
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
}
}

Some files were not shown because too many files have changed in this diff Show More