feat(docker): update base images to Java 25, Spring 4, Jackson 3, Gradle 9 and optimize JVM options (Project Lilliput) (#5725)

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
Balázs Szücs
2026-02-24 20:06:32 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent 24128dd318
commit 1f9b90ad57
112 changed files with 3181 additions and 2102 deletions
+40 -63
View File
@@ -1,75 +1,52 @@
# Node modules and build artifacts # Version control
node_modules .git/
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
frontend/src-tauri/target
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore .gitignore
# IDE # Build outputs
.vscode build/
.idea */build/
**/build/
out/
target/
# Gradle caches (local, not what's in the container)
.gradle/
**/.gradle/
# Node / frontend
node_modules/
**/node_modules/
.npm/
.yarn/
# IDE and editor
.idea/
.vscode/
*.iml *.iml
*.iws
*.ipr *.ipr
*.iws
# Logs # Logs and temp files
*.log *.log
logs *.tmp
*.pid
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Java compiled files # Docker itself
*.class Dockerfile*
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore .dockerignore
# Temporary files # CI / CD configs (not needed in build context)
tmp .github/
temp .circleci/
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml .gitlab-ci.yml
# Test reports
**/test-results/
**/jacoco/
# Local env
.env
.env.*
!.env.example
@@ -150,10 +150,10 @@ jobs:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }} token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
+14 -9
View File
@@ -52,7 +52,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
jdk-version: [17, 21] jdk-version: [21, 25]
spring-security: [true, false] spring-security: [true, false]
steps: steps:
- name: Harden Runner - name: Harden Runner
@@ -140,7 +140,7 @@ jobs:
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
@@ -209,10 +209,10 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
@@ -221,7 +221,12 @@ jobs:
gradle-version: 8.14 gradle-version: 8.14
- name: check the licenses for compatibility - name: check the licenses for compatibility
run: ./gradlew checkLicense # NOTE: --no-parallel is intentional here. Running the checkLicense task in parallel with other
# Gradle tasks has been observed to cause intermittent failures with the dependency license
# checking plugin on this Gradle version. Disabling parallel execution trades some build speed
# for more reliable, deterministic license checks. If upgrading Gradle or the plugin, consider
# re-evaluating whether this flag is still required before removing it.
run: ./gradlew checkLicense --no-parallel
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
@@ -265,10 +270,10 @@ jobs:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
@@ -355,10 +360,10 @@ jobs:
docker system prune -af || true docker system prune -af || true
echo "Disk space after cleanup:" && df -h echo "Disk space after cleanup:" && df -h
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
@@ -330,10 +330,10 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }} app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
@@ -344,7 +344,12 @@ jobs:
- name: Check licenses and generate report - name: Check licenses and generate report
id: license-check id: license-check
run: | run: |
./gradlew checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV # NOTE: --no-parallel is intentional here. Running the license-checking tasks in parallel has
# previously caused intermittent concurrency issues in CI (e.g. flaky failures in the license
# plugin/Gradle when multiple projects are evaluated concurrently). Disabling parallelism trades
# some build speed for more reliable license reports. If the underlying issues are resolved in
# future Gradle or plugin versions, this flag can be reconsidered.
./gradlew checkLicense generateLicenseReport --no-parallel || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+6 -6
View File
@@ -44,10 +44,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
@@ -117,10 +117,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
@@ -197,10 +197,10 @@ jobs:
toolchain: stable toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
+2 -2
View File
@@ -39,10 +39,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
+2 -2
View File
@@ -33,10 +33,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 21 - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "25"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
+3 -3
View File
@@ -27,8 +27,8 @@ spotless {
} }
} }
dependencies { dependencies {
api 'org.springframework.boot:spring-boot-starter-web' api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aop' api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1' api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6' api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0' api 'com.posthog.java:posthog:1.2.0'
@@ -42,7 +42,7 @@ dependencies {
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1' api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15" api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6' api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
@@ -91,9 +91,9 @@ public class RuntimePathConfig {
// Initialize Operation paths // Initialize Operation paths
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint"; String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert"; String defaultUnoConvertPath = isDocker ? "/usr/local/bin/unoconvert" : "unoconvert";
String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert"; String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert";
String defaultOcrMyPdfPath = isDocker ? "/usr/bin/ocrmypdf" : "ocrmypdf"; String defaultOcrMyPdfPath = isDocker ? "/opt/venv/bin/ocrmypdf" : "ocrmypdf";
String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice"; String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice";
Operations operations = customPaths.getOperations(); Operations operations = customPaths.getOperations();
@@ -0,0 +1,23 @@
package stirling.software.common.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
/**
* Configures the scheduler used by all {@code @Scheduled} methods. Uses virtual threads so that
* long-running scheduled tasks (e.g. cleanup, license checks, file monitoring) never block each
* other — each runs on its own lightweight virtual thread.
*/
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
scheduler.setVirtualThreads(true);
scheduler.setThreadNamePrefix("scheduled-vt-");
return scheduler;
}
}
@@ -35,7 +35,7 @@ public class JobExecutorService {
private final HttpServletRequest request; private final HttpServletRequest request;
private final ResourceMonitor resourceMonitor; private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue; private final JobQueue jobQueue;
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor(); private final ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
private final long effectiveTimeoutMs; private final long effectiveTimeoutMs;
@Autowired(required = false) @Autowired(required = false)
@@ -44,8 +44,10 @@ public class JobQueue implements SmartLifecycle {
private volatile BlockingQueue<QueuedJob> jobQueue; private volatile BlockingQueue<QueuedJob> jobQueue;
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>(); private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private final ScheduledExecutorService scheduler =
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor(); Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("job-queue-scheduler-", 0).factory());
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualThreadExecutor();
private final Object queueLock = new Object(); // Lock for synchronizing queue operations private final Object queueLock = new Object(); // Lock for synchronizing queue operations
private boolean shuttingDown = false; private boolean shuttingDown = false;
@@ -43,7 +43,9 @@ public class ResourceMonitor {
@Value("${stirling.resource.monitor.interval-ms:60000}") @Value("${stirling.resource.monitor.interval-ms:60000}")
private long monitorIntervalMs = 60000; // 60 seconds private long monitorIntervalMs = 60000; // 60 seconds
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("resource-monitor-", 0).factory());
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean(); private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
@@ -42,7 +42,8 @@ public class TaskManager {
private final FileStorage fileStorage; private final FileStorage fileStorage;
private final ScheduledExecutorService cleanupExecutor = private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor(); Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("task-cleanup-", 0).factory());
/** Initialize the task manager and start the cleanup scheduler */ /** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) { public TaskManager(FileStorage fileStorage) {
@@ -2,30 +2,28 @@ package stirling.software.common.util;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import lombok.extern.slf4j.Slf4j; /**
* Factory for creating executors backed by virtual threads (Java 21+). Virtual threads are
* lightweight, managed by the JVM, and ideal for I/O-bound tasks. They eliminate the need for
* thread pool sizing since thousands can run concurrently with minimal overhead.
*/
public final class ExecutorFactory {
@Slf4j private ExecutorFactory() {}
public class ExecutorFactory {
/** Creates an {@link ExecutorService} that starts a new virtual thread for each task. */
public static ExecutorService newVirtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/** /**
* Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a * Creates a {@link ScheduledExecutorService} backed by a single virtual thread. Useful for
* cached thread pool on older Java versions. * periodic/delayed tasks that should not pin a platform thread.
*/ */
public static ExecutorService newVirtualOrCachedThreadExecutor() { public static ScheduledExecutorService newSingleVirtualThreadScheduledExecutor() {
try { return Executors.newSingleThreadScheduledExecutor(
ExecutorService executor = Thread.ofVirtual().name("scheduled-vt-", 0).factory());
(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();
} }
} }
@@ -68,6 +68,17 @@ public class FileMonitor {
if (this.rootDirs.isEmpty()) { if (this.rootDirs.isEmpty()) {
log.error("No valid directories to monitor - FileMonitor will not function"); log.error("No valid directories to monitor - FileMonitor will not function");
} else {
// Register directories eagerly so the first @Scheduled tick does not warn.
for (Path rootDir : this.rootDirs) {
if (Files.exists(rootDir)) {
try {
recursivelyRegisterEntry(rootDir);
} catch (IOException e) {
log.error("Failed to register monitoring for {}", rootDir, e);
}
}
}
} }
} }
@@ -649,7 +649,8 @@ public class GeneralUtils {
} }
public List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) { public List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) {
List<Integer> result = new ArrayList<>(); // Use LinkedHashSet to prevent duplicates from inflating size and triggering maxSize guard
Set<Integer> result = new LinkedHashSet<>();
int offset = oneBased ? 1 : 0; int offset = oneBased ? 1 : 0;
int maxSize = Math.max(1000, totalPages * 3); int maxSize = Math.max(1000, totalPages * 3);
for (String page : pages) { for (String page : pages) {
@@ -672,7 +673,7 @@ public class GeneralUtils {
"Page list exceeds maximum allowed size of " + maxSize); "Page list exceeds maximum allowed size of " + maxSize);
} }
} }
return result; return new ArrayList<>(result);
} }
/* /*
@@ -226,50 +226,54 @@ public class ProcessExecutor {
List<String> outputLines = new ArrayList<>(); List<String> outputLines = new ArrayList<>();
Thread errorReaderThread = Thread errorReaderThread =
new Thread( Thread.ofVirtual()
() -> { .unstarted(
try (BufferedReader errorReader = () -> {
new BufferedReader( try (BufferedReader errorReader =
new InputStreamReader( new BufferedReader(
process.getErrorStream(), new InputStreamReader(
StandardCharsets.UTF_8))) { process.getErrorStream(),
String line; StandardCharsets.UTF_8))) {
while ((line = String line;
BoundedLineReader.readLine( while ((line =
errorReader, 5_000_000)) BoundedLineReader.readLine(
!= null) { errorReader, 5_000_000))
errorLines.add(line); != null) {
if (liveUpdates) log.info(line); errorLines.add(line);
} if (liveUpdates) log.info(line);
} catch (InterruptedIOException e) { }
log.warn("Error reader thread was interrupted due to timeout."); } catch (InterruptedIOException e) {
} catch (IOException e) { log.warn(
log.error("exception", e); "Error reader thread was interrupted due to timeout.");
} } catch (IOException e) {
}); log.error("exception", e);
}
});
Thread outputReaderThread = Thread outputReaderThread =
new Thread( Thread.ofVirtual()
() -> { .unstarted(
try (BufferedReader outputReader = () -> {
new BufferedReader( try (BufferedReader outputReader =
new InputStreamReader( new BufferedReader(
process.getInputStream(), new InputStreamReader(
StandardCharsets.UTF_8))) { process.getInputStream(),
String line; StandardCharsets.UTF_8))) {
while ((line = String line;
BoundedLineReader.readLine( while ((line =
outputReader, 5_000_000)) BoundedLineReader.readLine(
!= null) { outputReader, 5_000_000))
outputLines.add(line); != null) {
if (liveUpdates) log.info(line); outputLines.add(line);
} if (liveUpdates) log.info(line);
} catch (InterruptedIOException e) { }
log.warn("Error reader thread was interrupted due to timeout."); } catch (InterruptedIOException e) {
} catch (IOException e) { log.warn(
log.error("exception", e); "Error reader thread was interrupted due to timeout.");
} } catch (IOException e) {
}); log.error("exception", e);
}
});
errorReaderThread.start(); errorReaderThread.start();
outputReaderThread.start(); outputReaderThread.start();
@@ -370,7 +374,7 @@ public class ProcessExecutor {
} }
// Match common unoconvert variants (but NOT soffice) // Match common unoconvert variants (but NOT soffice)
String lowerBasename = basename.toLowerCase(java.util.Locale.ROOT); String lowerBasename = basename.toLowerCase(java.util.Locale.ROOT);
if (lowerBasename.contains("unoconvert") || lowerBasename.equals("unoconv")) { if (lowerBasename.contains("unoconvert") || "unoconv".equals(lowerBasename)) {
return true; return true;
} }
} }
@@ -4,18 +4,23 @@ import java.beans.PropertyEditorSupport;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; 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 lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.security.RedactionArea; import stirling.software.common.model.api.security.RedactionArea;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@Slf4j @Slf4j
public class StringToArrayListPropertyEditor extends PropertyEditorSupport { public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper =
JsonMapper.builder()
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
@Override @Override
public void setAsText(String text) throws IllegalArgumentException { public void setAsText(String text) throws IllegalArgumentException {
@@ -24,7 +29,6 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
return; return;
} }
try { try {
objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
TypeReference<ArrayList<RedactionArea>> typeRef = new TypeReference<>() {}; TypeReference<ArrayList<RedactionArea>> typeRef = new TypeReference<>() {};
List<RedactionArea> list = objectMapper.readValue(text, typeRef); List<RedactionArea> list = objectMapper.readValue(text, typeRef);
setValue(list); setValue(list);
@@ -4,12 +4,13 @@ import java.beans.PropertyEditorSupport;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference; import tools.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
public class StringToMapPropertyEditor extends PropertyEditorSupport { public class StringToMapPropertyEditor extends PropertyEditorSupport {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = JsonMapper.builder().build();
@Override @Override
public void setAsText(String text) throws IllegalArgumentException { public void setAsText(String text) throws IllegalArgumentException {
@@ -12,11 +12,12 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory {
public StrategyType lastStrategyUsed; public StrategyType lastStrategyUsed;
public SpyPDFDocumentFactory(PdfMetadataService service) { public SpyPDFDocumentFactory(PdfMetadataService service) {
super(service); super(service); // TempFileManager falls back to Files.createTempFile in tests
} }
@Override @Override
public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) { protected StreamCacheCreateFunction getStreamCacheFunction(
long contentSize, MemorySnapshot mem) {
StrategyType type; StrategyType type;
if (contentSize < 10 * 1024 * 1024) { if (contentSize < 10 * 1024 * 1024) {
type = StrategyType.MEMORY_ONLY; type = StrategyType.MEMORY_ONLY;
@@ -26,6 +27,6 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory {
type = StrategyType.TEMP_FILE; type = StrategyType.TEMP_FILE;
} }
this.lastStrategyUsed = type; this.lastStrategyUsed = type;
return super.getStreamCacheFunction(contentSize); // delegate to real behavior return super.getStreamCacheFunction(contentSize, mem);
} }
} }
@@ -121,17 +121,19 @@ public class UnoServerPoolTest {
// Try to acquire a third endpoint in separate thread (should block) // Try to acquire a third endpoint in separate thread (should block)
Thread blockingThread = Thread blockingThread =
new Thread( Thread.ofVirtual()
() -> { .unstarted(
try { () -> {
acquireLatch.countDown(); // Signal we're about to block try {
UnoServerPool.UnoServerLease lease3 = pool.acquireEndpoint(); acquireLatch.countDown(); // Signal we're about to block
acquired.incrementAndGet(); UnoServerPool.UnoServerLease lease3 =
lease3.close(); pool.acquireEndpoint();
} catch (InterruptedException e) { acquired.incrementAndGet();
Thread.currentThread().interrupt(); lease3.close();
} } catch (InterruptedException e) {
}); Thread.currentThread().interrupt();
}
});
blockingThread.start(); blockingThread.start();
acquireLatch.await(); // Wait for thread to start acquireLatch.await(); // Wait for thread to start
@@ -143,11 +143,21 @@ class StringToArrayListPropertyEditorTest {
} }
@Test @Test
void testSetAsText_InvalidStructure() { void testSetAsText_UnknownProperties() {
// Arrange - this JSON doesn't match RedactionArea structure // Arrange - this JSON contains properties not in RedactionArea
// With FAIL_ON_UNKNOWN_PROPERTIES disabled, this should ignore the unknown properties
String json = "[{\"invalid\":\"structure\"}]"; String json = "[{\"invalid\":\"structure\"}]";
// Act & Assert // Act
assertThrows(IllegalArgumentException.class, () -> editor.setAsText(json)); editor.setAsText(json);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertEquals(1, list.size(), "List should have 1 entry (empty object)");
} }
} }
+20 -4
View File
@@ -46,8 +46,23 @@ dependencies {
implementation project(':common') implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty' implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'com.posthog.java:posthog:1.2.0' implementation 'org.eclipse.jetty.http2:jetty-http2-server'
implementation 'org.telegram:telegrambots:6.9.7.1' implementation ('org.telegram:telegrambots:6.9.7.1') {
// Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
// Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
exclude group: 'org.glassfish.jersey.inject'
exclude group: 'org.glassfish.jersey.media'
exclude group: 'org.glassfish.jersey.containers'
exclude group: 'org.glassfish.jersey.core'
exclude group: 'org.glassfish.jersey.ext'
exclude group: 'org.glassfish.grizzly'
exclude group: 'org.glassfish.hk2'
exclude group: 'org.glassfish.hk2.external'
exclude group: 'org.javassist', module: 'javassist'
// Old javax JAX-RS Jackson bindings (not needed, project uses jakarta)
exclude group: 'com.fasterxml.jackson.jaxrs'
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
}
implementation 'commons-io:commons-io:2.21.0' implementation 'commons-io:commons-io:2.21.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion" implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
@@ -78,8 +93,9 @@ dependencies {
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1' implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Batik // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
implementation 'org.apache.xmlgraphics:batik-all:1.19' // Replaces batik-all which included unused codec, svggen, transcoder, script modules
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion // PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5' implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
@@ -49,8 +49,8 @@ public class LibreOfficeListener {
process = SystemCommand.runCommand(Runtime.getRuntime(), "unoconv --listener"); process = SystemCommand.runCommand(Runtime.getRuntime(), "unoconv --listener");
lastActivityTime = System.currentTimeMillis(); lastActivityTime = System.currentTimeMillis();
// Start a background thread to monitor the activity timeout // Start a virtual thread to monitor the activity timeout
executorService = Executors.newSingleThreadExecutor(); executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit( executorService.submit(
() -> { () -> {
while (true) { while (true) {
@@ -13,7 +13,7 @@ import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.context.WebServerInitializedEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@@ -176,11 +176,14 @@ public class SPDFApplication {
} }
@EventListener @EventListener
public void onWebServerInitialized(WebServerInitializedEvent event) { public void onApplicationReady(ApplicationReadyEvent event) {
int actualPort = event.getWebServer().getPort(); String port =
serverPortStatic = String.valueOf(actualPort); event.getApplicationContext().getEnvironment().getProperty("local.server.port");
if (port != null) {
serverPortStatic = port;
}
// Log the actual runtime port for Tauri to parse // Log the actual runtime port for Tauri to parse
log.info("Stirling-PDF running on port: {}", actualPort); log.info("Stirling-PDF running on port: {}", serverPortStatic);
} }
private static void printStartupLogs() { private static void printStartupLogs() {
@@ -51,9 +51,7 @@ public class ExternalAppDepConfig {
*/ */
private final Map<String, List<String>> commandToGroupMapping; private final Map<String, List<String>> commandToGroupMapping;
private final ExecutorService pool = private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
public ExternalAppDepConfig( public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) { EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
@@ -1,7 +1,7 @@
package stirling.software.SPDF.config; package stirling.software.SPDF.config;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.MultipartConfigFactory; import org.springframework.boot.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.DependsOn;
@@ -54,8 +54,8 @@ public class TauriProcessMonitor {
scheduler = scheduler =
Executors.newSingleThreadScheduledExecutor( Executors.newSingleThreadScheduledExecutor(
r -> { r -> {
Thread t = new Thread(r, "tauri-process-monitor"); Thread t =
t.setDaemon(true); Thread.ofVirtual().name("tauri-process-monitor").unstarted(r);
return t; return t;
}); });
@@ -154,8 +154,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
.maxAge(3600); .maxAge(3600);
} else { } else {
// Default to allowing all origins when nothing is configured // Default to allowing all origins when nothing is configured
logger.info( logger.debug(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins."); "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
registry.addMapping("/**") registry.addMapping("/**")
.allowedOriginPatterns("*") .allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
@@ -15,9 +15,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.Getter; import lombok.Getter;
@@ -32,6 +29,9 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@GeneralApi @GeneralApi
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -16,9 +16,6 @@ import org.springframework.core.io.ResourceLoader;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.Data; import lombok.Data;
@@ -35,6 +32,9 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Slf4j @Slf4j
@UiDataApi @UiDataApi
public class UIDataController { public class UIDataController {
@@ -44,18 +44,21 @@ public class UIDataController {
private final UserServiceInterface userService; private final UserServiceInterface userService;
private final ResourceLoader resourceLoader; private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final ObjectMapper objectMapper;
public UIDataController( public UIDataController(
ApplicationProperties applicationProperties, ApplicationProperties applicationProperties,
SharedSignatureService signatureService, SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService, @Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader, ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) { RuntimePathConfig runtimePathConfig,
ObjectMapper objectMapper) {
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
this.signatureService = signatureService; this.signatureService = signatureService;
this.userService = userService; this.userService = userService;
this.resourceLoader = resourceLoader; this.resourceLoader = resourceLoader;
this.runtimePathConfig = runtimePathConfig; this.runtimePathConfig = runtimePathConfig;
this.objectMapper = objectMapper;
} }
@GetMapping("/footer-info") @GetMapping("/footer-info")
@@ -93,9 +96,8 @@ public class UIDataController {
try (InputStream is = resource.getInputStream()) { try (InputStream is = resource.getInputStream()) {
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> licenseData = Map<String, List<Dependency>> licenseData =
mapper.readValue(json, new TypeReference<>() {}); objectMapper.readValue(json, new TypeReference<>() {});
data.setDependencies(licenseData.get("dependencies")); data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to load licenses data", e); log.error("Failed to load licenses data", e);
@@ -127,8 +129,8 @@ public class UIDataController {
for (String config : pipelineConfigs) { for (String config : pipelineConfigs) {
Map<String, Object> jsonContent = Map<String, Object> jsonContent =
new ObjectMapper() objectMapper.readValue(
.readValue(config, new TypeReference<Map<String, Object>>() {}); config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name"); String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) { if (name == null || name.length() < 1) {
String filename = String filename =
@@ -91,22 +91,32 @@ public class ConvertOfficeController {
Path libreOfficeProfile = null; Path libreOfficeProfile = null;
try { try {
ProcessExecutorResult result; ProcessExecutorResult result = null;
// Run Unoconvert command IOException unoconvertException = null;
if (isUnoconvertAvailable()) {
// Unoconvert: schreibe direkt in outputPath innerhalb des workDir
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getUnoConvertPath());
command.add("--convert-to");
command.add("pdf");
command.add(inputPath.toString());
command.add(outputPath.toString());
result = // Try unoconvert first if available
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE) if (isUnoconvertAvailable()) {
.runCommandWithOutputHandling(command); try {
} // Run soffice command List<String> command = new ArrayList<>();
else { command.add(runtimePathConfig.getUnoConvertPath());
command.add("--convert-to");
command.add("pdf");
command.add(inputPath.toString());
command.add(outputPath.toString());
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
unoconvertException = e;
log.warn(
"Unoconvert command failed ({}). Falling back to soffice command.",
e.getMessage());
}
}
// Fallback to soffice if unoconvert was unavailable or failed
if (result == null) {
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_"); libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getSOfficePath()); command.add(runtimePathConfig.getSOfficePath());
@@ -114,14 +124,21 @@ public class ConvertOfficeController {
command.add("--headless"); command.add("--headless");
command.add("--nologo"); command.add("--nologo");
command.add("--convert-to"); command.add("--convert-to");
command.add("pdf:writer_pdf_Export"); command.add("pdf");
command.add("--outdir"); command.add("--outdir");
command.add(workDir.toString()); command.add(workDir.toString());
command.add(inputPath.toString()); command.add(inputPath.toString());
result = try {
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE) result =
.runCommandWithOutputHandling(command); ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
if (unoconvertException != null) {
e.addSuppressed(unoconvertException);
}
throw e;
}
} }
// Check the result // Check the result
@@ -70,10 +70,7 @@ public class ConvertPDFToExcelController {
tables.size() == 1 tables.size() == 1
? String.format(Locale.ROOT, "Page %d", pageNum) ? String.format(Locale.ROOT, "Page %d", pageNum)
: String.format( : String.format(
Locale.ROOT, Locale.ROOT, "Page %d Table %d", pageNum, tableIdx + 1);
"Page %d Table %d",
pageNum,
tableIdx + 1);
sheetName = getUniqueSheetName(workbook, sheetName); sheetName = getUniqueSheetName(workbook, sheetName);
Sheet sheet = workbook.createSheet(sheetName); Sheet sheet = workbook.createSheet(sheetName);
@@ -9,13 +9,13 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.exception.CacheUnavailableException; import stirling.software.SPDF.exception.CacheUnavailableException;
import tools.jackson.databind.ObjectMapper;
@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class) @ControllerAdvice(assignableTypes = ConvertPdfJsonController.class)
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -19,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.opencsv.CSVWriter; import com.opencsv.CSVWriter;
import io.github.pixee.security.Filenames; import io.github.pixee.security.Filenames;
@@ -38,6 +36,9 @@ import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils; import stirling.software.common.util.FormUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@RestController @RestController
@RequestMapping("/api/v1/form") @RequestMapping("/api/v1/form")
@Tag( @Tag(
@@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api.form; package stirling.software.SPDF.controller.api.form;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@@ -8,13 +7,14 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils; import stirling.software.common.util.FormUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
final class FormPayloadParser { final class FormPayloadParser {
private static final String KEY_FIELDS = "fields"; private static final String KEY_FIELDS = "fields";
@@ -32,8 +32,7 @@ final class FormPayloadParser {
private FormPayloadParser() {} private FormPayloadParser() {}
static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json) static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json) {
throws IOException {
if (json == null || json.isBlank()) { if (json == null || json.isBlank()) {
return Map.of(); return Map.of();
} }
@@ -41,7 +40,7 @@ final class FormPayloadParser {
JsonNode root; JsonNode root;
try { try {
root = objectMapper.readTree(json); root = objectMapper.readTree(json);
} catch (IOException e) { } catch (JacksonException e) {
// Fallback to legacy direct map parse (will throw again if invalid) // Fallback to legacy direct map parse (will throw again if invalid)
return objectMapper.readValue(json, MAP_TYPE); return objectMapper.readValue(json, MAP_TYPE);
} }
@@ -88,14 +87,14 @@ final class FormPayloadParser {
} }
static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions( static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions(
ObjectMapper objectMapper, String json) throws IOException { ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) { if (json == null || json.isBlank()) {
return List.of(); return List.of();
} }
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
} }
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException { static List<String> parseNameList(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) { if (json == null || json.isBlank()) {
return List.of(); return List.of();
} }
@@ -119,7 +118,7 @@ final class FormPayloadParser {
} }
} }
} else if (root.isTextual()) { } else if (root.isTextual()) {
final String single = trimToNull(root.asText()); final String single = trimToNull(root.asText(""));
if (single != null) { if (single != null) {
names.add(single); names.add(single);
} }
@@ -131,7 +130,7 @@ final class FormPayloadParser {
try { try {
return objectMapper.readValue(json, STRING_LIST_TYPE); return objectMapper.readValue(json, STRING_LIST_TYPE);
} catch (IOException e) { } catch (JacksonException e) {
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "error.invalidFormat",
"Invalid {0} format: {1}", "Invalid {0} format: {1}",
@@ -199,7 +198,7 @@ final class FormPayloadParser {
return null; return null;
} }
if (node.isTextual()) { if (node.isTextual()) {
return trimToEmpty(node.asText()); return trimToEmpty(node.asText(""));
} }
if (node.isNumber()) { if (node.isNumber()) {
return node.numberValue().toString(); return node.numberValue().toString();
@@ -208,7 +207,7 @@ final class FormPayloadParser {
return Boolean.toString(node.booleanValue()); return Boolean.toString(node.booleanValue());
} }
// Fallback for other scalar-like nodes // Fallback for other scalar-like nodes
return trimToEmpty(node.asText()); return trimToEmpty(node.asText(""));
} }
private static void collectNames(JsonNode arrayNode, Set<String> sink) { private static void collectNames(JsonNode arrayNode, Set<String> sink) {
@@ -229,7 +228,7 @@ final class FormPayloadParser {
} }
if (node.isTextual()) { if (node.isTextual()) {
return trimToNull(node.asText()); return trimToNull(node.asText(""));
} }
if (node.isObject()) { if (node.isObject()) {
@@ -264,8 +263,8 @@ final class FormPayloadParser {
private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) { private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) {
final Map<String, Object> result = new LinkedHashMap<>(); final Map<String, Object> result = new LinkedHashMap<>();
objectNode objectNode
.fieldNames() .propertyNames()
.forEachRemaining( .forEach(
key -> { key -> {
final JsonNode v = objectNode.get(key); final JsonNode v = objectNode.get(key);
if (v == null || v.isNull()) { if (v == null || v.isNull()) {
@@ -82,9 +82,8 @@ public class ExtractImagesController {
Set<byte[]> processedImages = new HashSet<>(); Set<byte[]> processedImages = new HashSet<>();
if (useMultithreading) { if (useMultithreading) {
// Executor service to handle multithreading // Virtual thread executor lightweight threads ideal for I/O-bound image extraction
ExecutorService executor = ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
Set<Future<Void>> futures = new HashSet<>(); Set<Future<Void>> futures = new HashSet<>();
// Safely iterate over each page, handling corrupt PDFs where page count might be wrong // Safely iterate over each page, handling corrupt PDFs where page count might be wrong
@@ -246,10 +246,12 @@ public class OCRController {
command.add("--clean-final"); command.add("--clean-final");
} }
if (ocrType != null && !ocrType.isEmpty()) { if (ocrType != null && !ocrType.isEmpty()) {
if ("skip-text".equals(ocrType)) { if ("force-ocr".equals(ocrType)) {
command.add("--skip-text");
} else if ("force-ocr".equals(ocrType)) {
command.add("--force-ocr"); command.add("--force-ocr");
} else {
// Default for 'Normal' and 'skip-text': use --skip-text
// ocrmypdf 17+ requires explicit flag when PDF already contains text
command.add("--skip-text");
} }
} }
command.add("--invalidate-digital-signatures"); command.add("--invalidate-digital-signatures");
@@ -378,11 +380,12 @@ public class OCRController {
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add("tesseract"); command.add("tesseract");
command.add(imagePath.toString()); command.add(imagePath.toString());
command.add( String outputBase =
new File( new File(
tempOutputDir, tempOutputDir,
String.format(Locale.ROOT, "page_%d", pageNum)) String.format(Locale.ROOT, "page_%d", pageNum))
.toString()); .toString();
command.add(outputBase);
command.add("-l"); command.add("-l");
command.add(String.join("+", selectedLanguages)); command.add(String.join("+", selectedLanguages));
command.add("pdf"); // Always output PDF command.add("pdf"); // Always output PDF
@@ -400,6 +403,18 @@ public class OCRController {
result.getRc()); result.getRc());
} }
// Verify the OCR'd PDF was created
if (!pageOutputPath.exists()) {
log.warn(
"Tesseract did not create expected output file: {}. Page may be blank or unreadable.",
pageOutputPath.getAbsolutePath());
// Save original page without OCR as fallback
try (PDDocument pageDoc = new PDDocument()) {
pageDoc.addPage(page);
pageDoc.save(pageOutputPath);
}
}
// Add OCR'd PDF to merger // Add OCR'd PDF to merger
merger.addSource(pageOutputPath); merger.addSource(pageOutputPath);
} else { } else {
@@ -14,10 +14,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -34,6 +30,10 @@ import stirling.software.common.service.PostHogService;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DatabindException;
import tools.jackson.databind.ObjectMapper;
@PipelineApi @PipelineApi
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -54,7 +54,7 @@ public class PipelineController {
+ "Users provide files and a JSON configuration defining the sequence of operations to perform. " + "Users provide files and a JSON configuration defining the sequence of operations to perform. "
+ "Input:PDF Output:PDF/ZIP Type:MIMO") + "Input:PDF Output:PDF/ZIP Type:MIMO")
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request) public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
throws JsonMappingException, JsonProcessingException { throws DatabindException, JacksonException {
MultipartFile[] files = request.getFileInput(); MultipartFile[] files = request.getFileInput();
String jsonString = request.getJson(); String jsonString = request.getJson();
if (files == null) { if (files == null) {
@@ -28,8 +28,6 @@ import org.springframework.core.io.Resource;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PipelineConfig; import stirling.software.SPDF.model.PipelineConfig;
@@ -40,6 +38,8 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService; import stirling.software.common.service.PostHogService;
import stirling.software.common.util.FileMonitor; import stirling.software.common.util.FileMonitor;
import tools.jackson.databind.ObjectMapper;
@Service @Service
@Slf4j @Slf4j
public class PipelineDirectoryProcessor { public class PipelineDirectoryProcessor {
@@ -47,10 +47,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -66,6 +62,11 @@ import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
@SecurityApi @SecurityApi
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -77,7 +78,7 @@ public class GetInfoOnPDF {
private static final String PAGE_PREFIX = "Page "; private static final String PAGE_PREFIX = "Page ";
private static final long MAX_FILE_SIZE = 100L * 1024 * 1024; private static final long MAX_FILE_SIZE = 100L * 1024 * 1024;
private static final ObjectMapper objectMapper = new ObjectMapper(); private static final ObjectMapper objectMapper = JsonMapper.builder().build();
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final VeraPDFService veraPDFService; private final VeraPDFService veraPDFService;
@@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.web; package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
@@ -54,10 +56,27 @@ public class MetricsController {
} }
Map<String, String> status = new HashMap<>(); Map<String, String> status = new HashMap<>();
status.put("status", "UP"); status.put("status", "UP");
status.put("version", getClass().getPackage().getImplementationVersion()); String version = getClass().getPackage().getImplementationVersion();
if (version == null) {
version = getVersionFromProperties();
}
status.put("version", version);
return ResponseEntity.ok(status); return ResponseEntity.ok(status);
} }
private String getVersionFromProperties() {
try (InputStream is = getClass().getResourceAsStream("/version.properties")) {
if (is != null) {
Properties props = new Properties();
props.load(is);
return props.getProperty("version");
}
} catch (IOException e) {
log.error("Failed to load version.properties", e);
}
return null;
}
@GetMapping("/load") @GetMapping("/load")
@Operation( @Operation(
summary = "GET request count", summary = "GET request count",
@@ -190,6 +190,31 @@ public class GlobalExceptionHandler {
return problemDetail; return problemDetail;
} }
/**
* Checks whether the given IOException indicates that the client disconnected before the
* response could be written (broken pipe, connection reset, etc.). When this happens there is
* no point in serialising a {@link ProblemDetail} body because the socket is already closed
* and attempting to do so may trigger a secondary {@code HttpMessageNotWritableException} if
* the response Content-Type was already committed as a non-JSON type (e.g. image/png).
*/
private static boolean isClientDisconnectException(IOException ex) {
// Walk the causal chain Jetty/Tomcat may wrap the low-level SocketException
Throwable current = ex;
while (current != null) {
String msg = current.getMessage();
if (msg != null) {
String lower = msg.toLowerCase(java.util.Locale.ROOT);
if (lower.contains("broken pipe")
|| lower.contains("connection reset")
|| lower.contains("an established connection was aborted")) {
return true;
}
}
current = current.getCause();
}
return false;
}
/** /**
* Helper method to create a standardized ProblemDetail response for exceptions with error * Helper method to create a standardized ProblemDetail response for exceptions with error
* codes. * codes.
@@ -1107,6 +1132,15 @@ public class GlobalExceptionHandler {
public ResponseEntity<ProblemDetail> handleIOException( public ResponseEntity<ProblemDetail> handleIOException(
IOException ex, HttpServletRequest request) { IOException ex, HttpServletRequest request) {
// Broken pipe / connection reset means the client disconnected.
// Attempting to write a ProblemDetail response will fail because the
// response Content-Type may already be committed (e.g. image/png) and
// the client is gone anyway. Log at WARN and return an empty body.
if (isClientDisconnectException(ex)) {
log.warn("Client disconnected at {}: {}", request.getRequestURI(), ex.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
// Check if this is a PDF-specific error and wrap it appropriately // Check if this is a PDF-specific error and wrap it appropriately
IOException processedException = IOException processedException =
ExceptionUtils.handlePdfException(ex, request.getRequestURI()); ExceptionUtils.handlePdfException(ex, request.getRequestURI());
@@ -3,10 +3,10 @@ package stirling.software.SPDF.model;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter; import lombok.Getter;
import tools.jackson.databind.JsonNode;
public class ApiEndpoint { public class ApiEndpoint {
private final String name; private final String name;
private Map<String, JsonNode> parameters; private Map<String, JsonNode> parameters;
@@ -18,10 +18,10 @@ public class ApiEndpoint {
postNode.path("parameters") postNode.path("parameters")
.forEach( .forEach(
paramNode -> { paramNode -> {
String paramName = paramNode.path("name").asText(); String paramName = paramNode.path("name").asText("");
parameters.put(paramName, paramNode); parameters.put(paramName, paramNode);
}); });
this.description = postNode.path("description").asText(); this.description = postNode.path("description").asText("");
} }
public boolean areParametersValid(Map<String, Object> providedParams) { public boolean areParametersValid(Map<String, Object> providedParams) {
@@ -15,9 +15,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -28,6 +25,9 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.common.service.UserServiceInterface; import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.RegexPatternUtils;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Service @Service
@Slf4j @Slf4j
public class ApiDocService { public class ApiDocService {
@@ -36,12 +36,15 @@ public class ApiDocService {
private final ServletContext servletContext; private final ServletContext servletContext;
private final UserServiceInterface userService; private final UserServiceInterface userService;
private final ObjectMapper objectMapper;
Map<String, List<String>> outputToFileTypes = new HashMap<>(); Map<String, List<String>> outputToFileTypes = new HashMap<>();
JsonNode apiDocsJsonRootNode; JsonNode apiDocsJsonRootNode;
public ApiDocService( public ApiDocService(
ObjectMapper objectMapper,
ServletContext servletContext, ServletContext servletContext,
@Autowired(required = false) UserServiceInterface userService) { @Autowired(required = false) UserServiceInterface userService) {
this.objectMapper = objectMapper;
this.servletContext = servletContext; this.servletContext = servletContext;
this.userService = userService; this.userService = userService;
} }
@@ -116,8 +119,7 @@ public class ApiDocService {
ResponseEntity<String> response = ResponseEntity<String> response =
restTemplate.exchange(getApiDocsUrl(), HttpMethod.GET, entity, String.class); restTemplate.exchange(getApiDocsUrl(), HttpMethod.GET, entity, String.class);
apiDocsJson = response.getBody(); apiDocsJson = response.getBody();
ObjectMapper mapper = new ObjectMapper(); apiDocsJsonRootNode = objectMapper.readTree(apiDocsJson);
apiDocsJsonRootNode = mapper.readTree(apiDocsJson);
JsonNode paths = apiDocsJsonRootNode.path("paths"); JsonNode paths = apiDocsJsonRootNode.path("paths");
paths.propertyStream() paths.propertyStream()
.forEach( .forEach(
@@ -427,7 +427,8 @@ public class CertificateValidationService {
try (InputStream certStream = try (InputStream certStream =
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem")) { getClass().getClassLoader().getResourceAsStream("certs/cacert.pem")) {
if (certStream == null) { if (certStream == null) {
log.warn("Bundled Mozilla CA certificate file not found in resources"); log.debug(
"Bundled Mozilla CA certificate file not found in resources — using Java system trust store only");
return; return;
} }
@@ -90,8 +90,6 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -129,6 +127,8 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -6834,7 +6834,8 @@ public class PdfJsonConversionService {
/** Schedules automatic cleanup of cached documents after 30 minutes. */ /** Schedules automatic cleanup of cached documents after 30 minutes. */
private void scheduleDocumentCleanup(String jobId) { private void scheduleDocumentCleanup(String jobId) {
new Thread( Thread.ofVirtual()
.start(
() -> { () -> {
try { try {
Thread.sleep(TimeUnit.MINUTES.toMillis(30)); Thread.sleep(TimeUnit.MINUTES.toMillis(30));
@@ -6843,7 +6844,6 @@ public class PdfJsonConversionService {
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
}) });
.start();
} }
} }
@@ -15,8 +15,6 @@ import java.util.stream.Stream;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile; import stirling.software.SPDF.model.SignatureFile;
@@ -24,6 +22,8 @@ import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse; import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
import tools.jackson.databind.ObjectMapper;
@Service @Service
@Slf4j @Slf4j
public class SharedSignatureService { public class SharedSignatureService {
@@ -33,9 +33,9 @@ public class SharedSignatureService {
private final String ALL_USERS_FOLDER = "ALL_USERS"; private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public SharedSignatureService() { public SharedSignatureService(ObjectMapper objectMapper) {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath(); SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = new ObjectMapper(); this.objectMapper = objectMapper;
} }
public boolean hasAccessToFile(String username, String fileName) throws IOException { public boolean hasAccessToFile(String username, String fileName) throws IOException {
@@ -18,8 +18,6 @@ import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data; import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -38,6 +36,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.TaskManager; import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ExceptionUtils;
import tools.jackson.databind.ObjectMapper;
/** /**
* Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce * Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce
* memory usage for large PDFs. * memory usage for large PDFs.
@@ -255,7 +255,8 @@ public class PdfLazyLoadingService {
/** Schedules automatic cleanup of cached documents after 30 minutes. */ /** Schedules automatic cleanup of cached documents after 30 minutes. */
private void scheduleDocumentCleanup(String jobId) { private void scheduleDocumentCleanup(String jobId) {
new Thread( Thread.ofVirtual()
.start(
() -> { () -> {
try { try {
Thread.sleep(TimeUnit.MINUTES.toMillis(30)); Thread.sleep(TimeUnit.MINUTES.toMillis(30));
@@ -264,8 +265,7 @@ public class PdfLazyLoadingService {
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
}) });
.start();
} }
/** /**
@@ -18,14 +18,14 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator; import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -202,20 +202,34 @@ public class Type3FontLibrary {
if (payload == null) { if (payload == null) {
return null; return null;
} }
byte[] data = null; String base64;
if (payload.base64 != null && !payload.base64.isBlank()) { if (payload.base64 != null && !payload.base64.isBlank()) {
// Validate the base64 string without wasteful full decode
// Only decode a small prefix to verify encoding is valid
try { try {
data = Base64.getDecoder().decode(payload.base64); byte[] probe =
Base64.getDecoder()
.decode(
payload.base64.substring(
0, Math.min(4, payload.base64.length())));
if (probe.length == 0 && payload.base64.length() <= 4) {
return null;
}
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
log.warn("[TYPE3] Invalid base64 payload in Type3 library: {}", ex.getMessage()); log.warn("[TYPE3] Invalid base64 payload in Type3 library: {}", ex.getMessage());
return null;
} }
// Keep the original base64 string directly avoids 3x memory pressure
base64 = payload.base64;
} else if (payload.resource != null && !payload.resource.isBlank()) { } else if (payload.resource != null && !payload.resource.isBlank()) {
data = loadResourceBytes(payload.resource); byte[] data = loadResourceBytes(payload.resource);
} if (data == null || data.length == 0) {
if (data == null || data.length == 0) { return null;
}
base64 = Base64.getEncoder().encodeToString(data);
} else {
return null; return null;
} }
String base64 = Base64.getEncoder().encodeToString(data);
return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format)); return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format));
} }
@@ -26,14 +26,15 @@ import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.graphics.PDXObject; import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator; import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline; import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectWriter;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
/** /**
* Small CLI helper that scans a PDF for Type3 fonts, computes their signatures, and optionally * Small CLI helper that scans a PDF for Type3 fonts, computes their signatures, and optionally
* emits JSON describing the glyph coverage. This allows Type3 library entries to be added without * emits JSON describing the glyph coverage. This allows Type3 library entries to be added without
@@ -48,7 +49,7 @@ import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
public final class Type3SignatureTool { public final class Type3SignatureTool {
private static final ObjectMapper OBJECT_MAPPER = private static final ObjectMapper OBJECT_MAPPER =
new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
private Type3SignatureTool() {} private Type3SignatureTool() {}
@@ -97,7 +97,7 @@ public class SvgToPdf {
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc) private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
throws IOException { throws IOException {
GVTBuilder builder = new GVTBuilder(); GVTBuilder builder = new GVTBuilder();
ExecutorService executor = Executors.newSingleThreadExecutor(); ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Callable<GraphicsNode> buildTask = () -> builder.build(ctx, svgDoc); Callable<GraphicsNode> buildTask = () -> builder.build(ctx, svgDoc);
Future<GraphicsNode> future = executor.submit(buildTask); Future<GraphicsNode> future = executor.submit(buildTask);
@@ -13,6 +13,18 @@ logging.level.stirling.software.common.service.JobExecutorService=INFO
logging.level.stirling.software.common.service.TaskManager=INFO logging.level.stirling.software.common.service.TaskManager=INFO
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE server.forward-headers-strategy=NATIVE
# Enable HTTP/2 for improved performance (multiplexed streams, header compression)
server.http2.enabled=true
# Enable virtual threads (Java 21+, pinning fix in Java 25)
spring.threads.virtual.enabled=true
# Response compression
server.compression.enabled=true
server.compression.min-response-size=1024
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
server.error.path=/error server.error.path=/error
server.error.whitelabel.enabled=false server.error.whitelabel.enabled=false
server.error.include-stacktrace=always server.error.include-stacktrace=always
@@ -38,7 +50,7 @@ spring.devtools.livereload.enabled=true
spring.devtools.restart.exclude=stirling.software.proprietary.security/** spring.devtools.restart.exclude=stirling.software.proprietary.security/**
spring.web.resources.mime-mappings.webmanifest=application/manifest+json spring.web.resources.mime-mappings.webmanifest=application/manifest+json
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000} spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
server.tomcat.max-http-header-size=32768 server.jetty.max-http-request-header-size=32768
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver spring.datasource.driver-class-name=org.h2.Driver
@@ -27,13 +27,13 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.SPDF.controller.api.EditTableOfContentsController.BookmarkItem; import stirling.software.SPDF.controller.api.EditTableOfContentsController.BookmarkItem;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest; import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class EditTableOfContentsControllerTest { class EditTableOfContentsControllerTest {
@@ -40,14 +40,15 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.SPDF.model.api.security.PDFVerificationResult; import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService; import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@DisplayName("GetInfoOnPDF Controller Tests") @DisplayName("GetInfoOnPDF Controller Tests")
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class GetInfoOnPDFTest { class GetInfoOnPDFTest {
@@ -64,7 +65,7 @@ class GetInfoOnPDFTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
objectMapper = new ObjectMapper(); objectMapper = JsonMapper.builder().build();
} }
/** Helper method to load a PDF file from test resources */ /** Helper method to load a PDF file from test resources */
@@ -216,8 +217,8 @@ class GetInfoOnPDFTest {
Assertions.assertTrue(jsonNode.has("Permissions")); Assertions.assertTrue(jsonNode.has("Permissions"));
JsonNode metadata = jsonNode.get("Metadata"); JsonNode metadata = jsonNode.get("Metadata");
Assertions.assertEquals("Test Title", metadata.get("Title").asText()); Assertions.assertEquals("Test Title", metadata.get("Title").asText(""));
Assertions.assertEquals("Test Author", metadata.get("Author").asText()); Assertions.assertEquals("Test Author", metadata.get("Author").asText(""));
} }
} }
@@ -315,12 +316,12 @@ class GetInfoOnPDFTest {
JsonNode jsonNode = objectMapper.readTree(jsonResponse); JsonNode jsonNode = objectMapper.readTree(jsonResponse);
JsonNode metadata = jsonNode.get("Metadata"); JsonNode metadata = jsonNode.get("Metadata");
Assertions.assertEquals("Test Title", metadata.get("Title").asText()); Assertions.assertEquals("Test Title", metadata.get("Title").asText(""));
Assertions.assertEquals("Test Author", metadata.get("Author").asText()); Assertions.assertEquals("Test Author", metadata.get("Author").asText(""));
Assertions.assertEquals("Test Subject", metadata.get("Subject").asText()); Assertions.assertEquals("Test Subject", metadata.get("Subject").asText(""));
Assertions.assertEquals("test, pdf, metadata", metadata.get("Keywords").asText()); Assertions.assertEquals("test, pdf, metadata", metadata.get("Keywords").asText(""));
Assertions.assertEquals("Test Creator", metadata.get("Creator").asText()); Assertions.assertEquals("Test Creator", metadata.get("Creator").asText(""));
Assertions.assertEquals("Test Producer", metadata.get("Producer").asText()); Assertions.assertEquals("Test Producer", metadata.get("Producer").asText(""));
Assertions.assertTrue(metadata.has("CreationDate")); Assertions.assertTrue(metadata.has("CreationDate"));
Assertions.assertTrue(metadata.has("ModificationDate")); Assertions.assertTrue(metadata.has("ModificationDate"));
@@ -512,10 +513,10 @@ class GetInfoOnPDFTest {
JsonNode page1 = perPageInfo.get("Page 1"); JsonNode page1 = perPageInfo.get("Page 1");
Assertions.assertTrue(page1.has("Size")); Assertions.assertTrue(page1.has("Size"));
Assertions.assertTrue(page1.get("Size").has("Standard Page")); Assertions.assertTrue(page1.get("Size").has("Standard Page"));
Assertions.assertEquals("A4", page1.get("Size").get("Standard Page").asText()); Assertions.assertEquals("A4", page1.get("Size").get("Standard Page").asText(""));
JsonNode page2 = perPageInfo.get("Page 2"); JsonNode page2 = perPageInfo.get("Page 2");
Assertions.assertEquals("Letter", page2.get("Size").get("Standard Page").asText()); Assertions.assertEquals("Letter", page2.get("Size").get("Standard Page").asText(""));
loadedDoc.close(); loadedDoc.close();
} }
@@ -569,7 +570,8 @@ class GetInfoOnPDFTest {
JsonNode jsonNode = objectMapper.readTree(jsonResponse); JsonNode jsonNode = objectMapper.readTree(jsonResponse);
Assertions.assertTrue(jsonNode.has("error")); Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue(jsonNode.get("error").asText().contains("PDF file is required")); Assertions.assertTrue(
jsonNode.get("error").asText("").contains("PDF file is required"));
} }
@Test @Test
@@ -645,7 +647,7 @@ class GetInfoOnPDFTest {
Assertions.assertTrue(jsonNode.has("error")); Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue( Assertions.assertTrue(
jsonNode.get("error").asText().contains("exceeds maximum allowed size")); jsonNode.get("error").asText("").contains("exceeds maximum allowed size"));
} }
} }
@@ -7,14 +7,15 @@ import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode; import tools.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
class ApiEndpointTest { class ApiEndpointTest {
private final ObjectMapper mapper = new ObjectMapper(); private final ObjectMapper mapper = JsonMapper.builder().build();
private JsonNode postNodeWithParams(String description, String... names) { private JsonNode postNodeWithParams(String description, String... names) {
ObjectNode post = mapper.createObjectNode(); ObjectNode post = mapper.createObjectNode();
@@ -12,14 +12,15 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContext;
import stirling.software.SPDF.model.ApiEndpoint; import stirling.software.SPDF.model.ApiEndpoint;
import stirling.software.common.service.UserServiceInterface; import stirling.software.common.service.UserServiceInterface;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class ApiDocServiceTest { class ApiDocServiceTest {
@@ -27,11 +28,11 @@ class ApiDocServiceTest {
@Mock UserServiceInterface userService; @Mock UserServiceInterface userService;
ApiDocService apiDocService; ApiDocService apiDocService;
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = JsonMapper.builder().build();
@BeforeEach @BeforeEach
void setUp() { void setUp() {
apiDocService = new ApiDocService(servletContext, userService); apiDocService = new ApiDocService(mapper, servletContext, userService);
} }
private void setApiDocumentation(Map<String, ApiEndpoint> docs) throws Exception { private void setApiDocumentation(Map<String, ApiEndpoint> docs) throws Exception {
@@ -20,6 +20,8 @@ import org.mockito.MockedStatic;
import stirling.software.SPDF.model.SignatureFile; import stirling.software.SPDF.model.SignatureFile;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
import tools.jackson.databind.json.JsonMapper;
class SignatureServiceTest { class SignatureServiceTest {
@TempDir Path tempDir; @TempDir Path tempDir;
@@ -53,7 +55,7 @@ class SignatureServiceTest {
.thenReturn(tempDir.toString()); .thenReturn(tempDir.toString());
// Initialize the service with our temp directory // Initialize the service with our temp directory
signatureService = new SharedSignatureService(); signatureService = new SharedSignatureService(JsonMapper.builder().build());
} }
} }
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-jetty' api 'org.springframework.boot:spring-boot-starter-jetty'
api 'org.springframework.boot:spring-boot-starter-security' api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-client' api 'org.springframework.boot:spring-boot-starter-security-oauth2-client'
api 'org.springframework.boot:spring-boot-starter-mail' api 'org.springframework.boot:spring-boot-starter-mail'
api 'org.springframework.boot:spring-boot-starter-cache' api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine' api 'com.github.ben-manes.caffeine:caffeine'
@@ -2,21 +2,22 @@ package stirling.software.proprietary.config;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator; import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.TaskExecutorAdapter;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration @Configuration
@EnableAsync @EnableAsync
public class AsyncConfig { public class AsyncConfig {
/** /**
* MDC context-propagating task decorator Copies MDC context from the caller thread to the async * MDC context-propagating task decorator. Copies MDC context from the caller thread to the
* executor thread * virtual thread executing the task.
*/ */
static class MDCContextTaskDecorator implements TaskDecorator { static class MDCContextTaskDecorator implements TaskDecorator {
@Override @Override
@@ -42,16 +43,9 @@ public class AsyncConfig {
@Bean(name = "auditExecutor") @Bean(name = "auditExecutor")
public Executor auditExecutor() { public Executor auditExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); TaskExecutorAdapter adapter =
exec.setCorePoolSize(2); new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
exec.setMaxPoolSize(8); adapter.setTaskDecorator(new MDCContextTaskDecorator());
exec.setQueueCapacity(1_000); return adapter;
exec.setThreadNamePrefix("audit-");
// Set the task decorator to propagate MDC context
exec.setTaskDecorator(new MDCContextTaskDecorator());
exec.initialize();
return exec;
} }
} }
@@ -1,15 +1,12 @@
package stirling.software.proprietary.config; package stirling.software.proprietary.config;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
/** Configuration to enable scheduling for the audit system. */ /** Configuration for audit system transaction management. */
@Configuration @Configuration
@EnableTransactionManagement @EnableTransactionManagement
@EnableScheduling
public class AuditJpaConfig { public class AuditJpaConfig {
// This configuration enables scheduling for audit cleanup tasks // Scheduling is enabled on SPDFApplication no duplicate @EnableScheduling needed.
// JPA repositories are now managed by DatabaseConfig to avoid conflicts // JPA repositories are now managed by DatabaseConfig to avoid conflicts.
// No additional beans or methods needed
} }
@@ -12,8 +12,6 @@ import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -21,6 +19,8 @@ import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.util.SecretMasker; import stirling.software.proprietary.util.SecretMasker;
import tools.jackson.databind.ObjectMapper;
@Component @Component
@Primary @Primary
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -68,7 +68,10 @@ public class CustomAuditEventRepository implements AuditEventRepository {
.build(); .build();
repo.save(ent); repo.save(ent);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); // fail-open log.error(
"Failed to persist audit event (fail-open); principal={}",
ev.getPrincipal(),
e);
} }
} }
} }
@@ -28,9 +28,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -47,6 +44,9 @@ import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/** REST endpoints for the audit dashboard. */ /** REST endpoints for the audit dashboard. */
@Slf4j @Slf4j
@RestController @RestController
@@ -266,7 +266,7 @@ public class AuditDashboardController {
headers.setContentDispositionFormData("attachment", "audit_export.json"); headers.setContentDispositionFormData("attachment", "audit_export.json");
return ResponseEntity.ok().headers(headers).body(jsonBytes); return ResponseEntity.ok().headers(headers).body(jsonBytes);
} catch (JsonProcessingException e) { } catch (JacksonException e) {
log.error("Error serializing audit events to JSON", e); log.error("Error serializing audit events to JSON", e);
return ResponseEntity.internalServerError().build(); return ResponseEntity.internalServerError().build();
} }
@@ -20,9 +20,6 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -32,6 +29,9 @@ import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/** REST API controller for audit data used by React frontend. */ /** REST API controller for audit data used by React frontend. */
@Slf4j @Slf4j
@ProprietaryUiDataApi @ProprietaryUiDataApi
@@ -332,7 +332,7 @@ public class AuditRestController {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> parsed = objectMapper.readValue(event.getData(), Map.class); Map<String, Object> parsed = objectMapper.readValue(event.getData(), Map.class);
details = parsed; details = parsed;
} catch (JsonProcessingException e) { } catch (JacksonException e) {
log.warn("Failed to parse audit event data as JSON: {}", event.getData()); log.warn("Failed to parse audit event data as JSON: {}", event.getData());
details.put("rawData", event.getData()); details.put("rawData", event.getData());
} }
@@ -380,7 +380,7 @@ public class AuditRestController {
headers.setContentDispositionFormData("attachment", "audit_export.json"); headers.setContentDispositionFormData("attachment", "audit_export.json");
return ResponseEntity.ok().headers(headers).body(jsonBytes); return ResponseEntity.ok().headers(headers).body(jsonBytes);
} catch (JsonProcessingException e) { } catch (JacksonException e) {
log.error("Error serializing audit events to JSON", e); log.error("Error serializing audit events to JSON", e);
return ResponseEntity.internalServerError().build(); return ResponseEntity.internalServerError().build();
} }
@@ -15,9 +15,6 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.Data; import lombok.Data;
@@ -54,6 +51,9 @@ import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService; import stirling.software.proprietary.service.UserLicenseSettingsService;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@Slf4j @Slf4j
@ProprietaryUiDataApi @ProprietaryUiDataApi
public class ProprietaryUIDataController { public class ProprietaryUIDataController {
@@ -414,7 +414,7 @@ public class ProprietaryUIDataController {
String settingsJson; String settingsJson;
try { try {
settingsJson = objectMapper.writeValueAsString(user.get().getSettings()); settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
} catch (JsonProcessingException e) { } catch (JacksonException e) {
log.error("Error converting settings map", e); log.error("Error converting settings map", e);
return ResponseEntity.status(500).build(); return ResponseEntity.status(500).build();
} }
@@ -8,9 +8,6 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -20,6 +17,9 @@ import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/** REST API controller for usage analytics data used by React frontend. */ /** REST API controller for usage analytics data used by React frontend. */
@Slf4j @Slf4j
@ProprietaryUiDataApi @ProprietaryUiDataApi
@@ -135,7 +135,7 @@ public class UsageRestController {
return normalizeEndpoint(requestUri.toString()); return normalizeEndpoint(requestUri.toString());
} }
} catch (JsonProcessingException e) { } catch (JacksonException e) {
log.debug("Failed to parse audit data JSON: {}", dataJson, e); log.debug("Failed to parse audit data JSON: {}", dataJson, e);
} }
@@ -6,9 +6,9 @@ import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.jdbc.DatabaseDriver; import org.springframework.boot.jdbc.DatabaseDriver;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
@@ -75,10 +75,18 @@ public class DatabaseConfig {
} }
private DataSource useDefaultDataSource(DataSourceBuilder<?> dataSourceBuilder) { private DataSource useDefaultDataSource(DataSourceBuilder<?> dataSourceBuilder) {
// Support AOT training: override URL via system property to avoid H2 file lock
// conflicts when the AOT RECORD phase starts a second Spring context
String overrideUrl = System.getProperty("stirling.datasource.url");
String url =
(overrideUrl != null && !overrideUrl.isBlank())
? overrideUrl
: DATASOURCE_DEFAULT_URL;
log.info("Using default H2 database"); log.info("Using default H2 database");
dataSourceBuilder dataSourceBuilder
.url(DATASOURCE_DEFAULT_URL) .url(url)
.driverClassName(DatabaseDriver.H2.getDriverClassName()) .driverClassName(DatabaseDriver.H2.getDriverClassName())
.username(DEFAULT_USERNAME); .username(DEFAULT_USERNAME);
@@ -14,15 +14,17 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver; import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
@@ -82,7 +84,7 @@ public class SecurityConfiguration {
private final PersistentLoginRepository persistentLoginRepository; private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations; private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver; private final OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final stirling.software.proprietary.service.UserLicenseSettingsService private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService; licenseSettingsService;
private final ClientRegistrationRepository clientRegistrationRepository; private final ClientRegistrationRepository clientRegistrationRepository;
@@ -105,7 +107,7 @@ public class SecurityConfiguration {
@Autowired(required = false) @Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations, RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false) @Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver, OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository, @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
stirling.software.proprietary.service.UserLicenseSettingsService stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService) { licenseSettingsService) {
@@ -151,7 +153,8 @@ public class SecurityConfiguration {
// Default to allowing all origins when nothing is configured // Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*")); cfg.setAllowedOriginPatterns(List.of("*"));
log.info( log.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins."); "No CORS allowed origins configured in settings.yml"
+ " (system.corsAllowedOrigins); allowing all origins.");
} }
// Explicitly configure supported HTTP methods (include OPTIONS for preflight) // Explicitly configure supported HTTP methods (include OPTIONS for preflight)
@@ -225,7 +228,7 @@ public class SecurityConfiguration {
http.cors(cors -> cors.configurationSource(corsSource)); http.cors(cors -> cors.configurationSource(corsSource));
} else { } else {
// Explicitly disable CORS when no origins are configured // Explicitly disable CORS when no origins are configured
http.cors(cors -> cors.disable()); http.cors(CorsConfigurer::disable);
} }
http.csrf(CsrfConfigurer::disable); http.csrf(CsrfConfigurer::disable);
@@ -233,24 +236,24 @@ public class SecurityConfiguration {
// Configure X-Frame-Options based on settings.yml configuration // Configure X-Frame-Options based on settings.yml configuration
// When login is disabled, automatically disable X-Frame-Options to allow embedding // When login is disabled, automatically disable X-Frame-Options to allow embedding
if (!loginEnabledValue) { if (!loginEnabledValue) {
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable())); http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
} else { } else {
String xFrameOption = securityProperties.getXFrameOptions(); String xFrameOption = securityProperties.getXFrameOptions();
if (xFrameOption != null) { if (xFrameOption != null) {
http.headers( http.headers(
headers -> { headers -> {
if ("DISABLED".equalsIgnoreCase(xFrameOption)) { if ("DISABLED".equalsIgnoreCase(xFrameOption)) {
headers.frameOptions(frameOptions -> frameOptions.disable()); headers.frameOptions(FrameOptionsConfig::disable);
} else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) { } else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) {
headers.frameOptions(frameOptions -> frameOptions.sameOrigin()); headers.frameOptions(FrameOptionsConfig::sameOrigin);
} else { } else {
// Default to DENY // Default to DENY
headers.frameOptions(frameOptions -> frameOptions.deny()); headers.frameOptions(FrameOptionsConfig::deny);
} }
}); });
} else { } else {
// If not configured, use default DENY // If not configured, use default DENY
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.deny())); http.headers(headers -> headers.frameOptions(FrameOptionsConfig::deny));
} }
} }
@@ -381,8 +384,8 @@ public class SecurityConfiguration {
} }
// Handle SAML // Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) { if (securityProperties.isSaml2Active() && runningProOrHigher) {
OpenSaml4AuthenticationProvider authenticationProvider = OpenSaml5AuthenticationProvider authenticationProvider =
new OpenSaml4AuthenticationProvider(); new OpenSaml5AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter( authenticationProvider.setResponseAuthenticationConverter(
new CustomSaml2ResponseAuthenticationConverter(userService)); new CustomSaml2ResponseAuthenticationConverter(userService));
http.authenticationProvider(authenticationProvider) http.authenticationProvider(authenticationProvider)
@@ -12,11 +12,6 @@ import org.bouncycastle.crypto.signers.Ed25519Signer;
import org.bouncycastle.util.encoders.Hex; import org.bouncycastle.util.encoders.Hex;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.posthog.java.shaded.org.json.JSONException;
import com.posthog.java.shaded.org.json.JSONObject;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -24,6 +19,9 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.RegexPatternUtils;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Service @Service
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -47,7 +45,7 @@ public class KeygenLicenseVerifier {
private static final String JWT_PREFIX = "key/"; private static final String JWT_PREFIX = "key/";
private static final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
// Shared HTTP client for connection pooling // Shared HTTP client for connection pooling
@@ -135,11 +133,11 @@ public class KeygenLicenseVerifier {
String algorithm = ""; String algorithm = "";
try { try {
JSONObject attrs = new JSONObject(payload); JsonNode attrs = objectMapper.readTree(payload);
encryptedData = (String) attrs.get("enc"); encryptedData = attrs.path("enc").asText("");
encodedSignature = (String) attrs.get("sig"); encodedSignature = attrs.path("sig").asText("");
algorithm = (String) attrs.get("alg"); algorithm = attrs.path("alg").asText("");
} catch (JSONException e) { } catch (Exception e) {
log.error("Failed to parse license file: {}", e.getMessage()); log.error("Failed to parse license file: {}", e.getMessage());
return false; return false;
} }
@@ -215,11 +213,17 @@ public class KeygenLicenseVerifier {
private boolean processCertificateData(String certData, LicenseContext context) { private boolean processCertificateData(String certData, LicenseContext context) {
try { try {
JSONObject licenseData = new JSONObject(certData); JsonNode licenseData = objectMapper.readTree(certData);
JSONObject metaObj = licenseData.optJSONObject("meta"); JsonNode metaObj = licenseData.path("meta");
if (metaObj != null) { if (!metaObj.isMissingNode() && metaObj.isObject()) {
String issuedStr = metaObj.optString("issued", null); String issuedStr =
String expiryStr = metaObj.optString("expiry", null); metaObj.path("issued").isNull()
? null
: metaObj.path("issued").asText(null);
String expiryStr =
metaObj.path("expiry").isNull()
? null
: metaObj.path("expiry").asText(null);
if (issuedStr != null && expiryStr != null) { if (issuedStr != null && expiryStr != null) {
java.time.Instant issued = java.time.Instant.parse(issuedStr); java.time.Instant issued = java.time.Instant.parse(issuedStr);
@@ -244,31 +248,32 @@ public class KeygenLicenseVerifier {
} }
// Get the main license data // Get the main license data
JSONObject dataObj = licenseData.optJSONObject("data"); JsonNode dataObj = licenseData.path("data");
if (dataObj == null) { if (dataObj.isMissingNode() || !dataObj.isObject()) {
log.error("No data object found in certificate"); log.error("No data object found in certificate");
return false; return false;
} }
// Extract license or machine information // Extract license or machine information
JSONObject attributesObj = dataObj.optJSONObject("attributes"); JsonNode attributesObj = dataObj.path("attributes");
if (attributesObj != null) { if (!attributesObj.isMissingNode() && attributesObj.isObject()) {
log.info("Found attributes in certificate data"); log.info("Found attributes in certificate data");
// Check for floating license // Check for floating license
context.isFloatingLicense = attributesObj.optBoolean("floating", false); context.isFloatingLicense = attributesObj.path("floating").asBoolean(false);
context.maxMachines = attributesObj.optInt("maxMachines", 1); context.maxMachines = attributesObj.path("maxMachines").asInt(1);
// Extract metadata // Extract metadata
JSONObject metadataObj = attributesObj.optJSONObject("metadata"); JsonNode metadataObj = attributesObj.path("metadata");
if (metadataObj != null) { if (!metadataObj.isMissingNode() && metadataObj.isObject()) {
// Check if this is an old license (no planType) with isEnterprise flag // Check if this is an old license (no planType) with isEnterprise flag
context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false); context.isEnterpriseLicense = metadataObj.path("isEnterprise").asBoolean(false);
// Extract user count - default based on license type // Extract user count - default based on license type
// Old licenses: Only had isEnterprise flag // Old licenses: Only had isEnterprise flag
// New licenses: Have planType field with "server" or "enterprise" // New licenses: Have planType field with "server" or "enterprise"
int users = metadataObj.optInt("users", context.isEnterpriseLicense ? 1 : 0); int users =
metadataObj.path("users").asInt(context.isEnterpriseLicense ? 1 : 0);
// SERVER license (isEnterprise=false, users=0) = unlimited // SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats // ENTERPRISE license (isEnterprise=true, users>0) = limited seats
@@ -282,7 +287,7 @@ public class KeygenLicenseVerifier {
} }
// Check license status if available // Check license status if available
String status = attributesObj.optString("status", null); String status = attributesObj.path("status").asText(null);
if (status != null if (status != null
&& !"ACTIVE".equals(status) && !"ACTIVE".equals(status)
&& !"EXPIRING".equals(status)) { // Accept "EXPIRING" status as valid && !"EXPIRING".equals(status)) { // Accept "EXPIRING" status as valid
@@ -372,11 +377,11 @@ public class KeygenLicenseVerifier {
try { try {
log.info("Processing license payload: {}", payload); log.info("Processing license payload: {}", payload);
JSONObject licenseData = new JSONObject(payload); JsonNode licenseData = objectMapper.readTree(payload);
JSONObject licenseObj = licenseData.optJSONObject("license"); JsonNode licenseObj = licenseData.path("license");
if (licenseObj == null) { if (licenseObj.isMissingNode() || !licenseObj.isObject()) {
String id = licenseData.optString("id", null); String id = licenseData.path("id").asText(null);
if (id != null) { if (id != null) {
log.info("Found license ID: {}", id); log.info("Found license ID: {}", id);
licenseObj = licenseData; // Use the root object as the license object licenseObj = licenseData; // Use the root object as the license object
@@ -386,18 +391,18 @@ public class KeygenLicenseVerifier {
} }
} }
String licenseId = licenseObj.optString("id", "unknown"); String licenseId = licenseObj.path("id").asText("unknown");
log.info("Processing license with ID: {}", licenseId); log.info("Processing license with ID: {}", licenseId);
// Check for floating license in license object // Check for floating license in license object
context.isFloatingLicense = licenseObj.optBoolean("floating", false); context.isFloatingLicense = licenseObj.path("floating").asBoolean(false);
context.maxMachines = licenseObj.optInt("maxMachines", 1); context.maxMachines = licenseObj.path("maxMachines").asInt(1);
if (context.isFloatingLicense) { if (context.isFloatingLicense) {
log.info("Detected floating license with max machines: {}", context.maxMachines); log.info("Detected floating license with max machines: {}", context.maxMachines);
} }
// Check expiry date // Check expiry date
String expiryStr = licenseObj.optString("expiry", null); String expiryStr = licenseObj.path("expiry").asText(null);
if (expiryStr != null && !"null".equals(expiryStr)) { if (expiryStr != null && !"null".equals(expiryStr)) {
java.time.Instant expiry = java.time.Instant.parse(expiryStr); java.time.Instant expiry = java.time.Instant.parse(expiryStr);
java.time.Instant now = java.time.Instant.now(); java.time.Instant now = java.time.Instant.now();
@@ -413,9 +418,9 @@ public class KeygenLicenseVerifier {
} }
// Extract account, product, policy info // Extract account, product, policy info
JSONObject accountObj = licenseData.optJSONObject("account"); JsonNode accountObj = licenseData.path("account");
if (accountObj != null) { if (!accountObj.isMissingNode() && accountObj.isObject()) {
String accountId = accountObj.optString("id", "unknown"); String accountId = accountObj.path("id").asText("unknown");
log.info("License belongs to account: {}", accountId); log.info("License belongs to account: {}", accountId);
// Verify this matches your expected account ID // Verify this matches your expected account ID
@@ -426,14 +431,14 @@ public class KeygenLicenseVerifier {
} }
// Extract policy information if available // Extract policy information if available
JSONObject policyObj = licenseData.optJSONObject("policy"); JsonNode policyObj = licenseData.path("policy");
if (policyObj != null) { if (!policyObj.isMissingNode() && policyObj.isObject()) {
String policyId = policyObj.optString("id", "unknown"); String policyId = policyObj.path("id").asText("unknown");
log.info("License uses policy: {}", policyId); log.info("License uses policy: {}", policyId);
// Check for floating license in policy // Check for floating license in policy
boolean policyFloating = policyObj.optBoolean("floating", false); boolean policyFloating = policyObj.path("floating").asBoolean(false);
int policyMaxMachines = policyObj.optInt("maxMachines", 1); int policyMaxMachines = policyObj.path("maxMachines").asInt(1);
// Policy settings take precedence // Policy settings take precedence
if (policyFloating) { if (policyFloating) {
@@ -445,16 +450,21 @@ public class KeygenLicenseVerifier {
} }
// Extract max users and isEnterprise from policy or metadata // Extract max users and isEnterprise from policy or metadata
context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false); context.isEnterpriseLicense = policyObj.path("isEnterprise").asBoolean(false);
int users = policyObj.optInt("users", -1); int users = policyObj.path("users").asInt(-1);
if (users == -1) { if (users == -1) {
// Try to get users from metadata if not at policy level // Try to get users from metadata if not at policy level
Object metadataObj = policyObj.opt("metadata"); JsonNode metadataObj = policyObj.path("metadata");
if (metadataObj instanceof JSONObject metadata) { if (!metadataObj.isMissingNode() && metadataObj.isObject()) {
context.isEnterpriseLicense = context.isEnterpriseLicense =
metadata.optBoolean("isEnterprise", context.isEnterpriseLicense); metadataObj
users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0); .path("isEnterprise")
.asBoolean(context.isEnterpriseLicense);
users =
metadataObj
.path("users")
.asInt(context.isEnterpriseLicense ? 1 : 0);
} else { } else {
// Default based on license type // Default based on license type
users = context.isEnterpriseLicense ? 1 : 0; users = context.isEnterpriseLicense ? 1 : 0;
@@ -493,9 +503,9 @@ public class KeygenLicenseVerifier {
validateLicense(licenseKey, machineFingerprint, context); validateLicense(licenseKey, machineFingerprint, context);
if (validationResponse != null) { if (validationResponse != null) {
boolean isValid = validationResponse.path("meta").path("valid").asBoolean(); boolean isValid = validationResponse.path("meta").path("valid").asBoolean();
String licenseId = validationResponse.path("data").path("id").asText(); String licenseId = validationResponse.path("data").path("id").asText("");
if (!isValid) { if (!isValid) {
String code = validationResponse.path("meta").path("code").asText(); String code = validationResponse.path("meta").path("code").asText("");
log.info(code); log.info(code);
if ("NO_MACHINE".equals(code) if ("NO_MACHINE".equals(code)
|| "NO_MACHINES".equals(code) || "NO_MACHINES".equals(code)
@@ -579,8 +589,8 @@ public class KeygenLicenseVerifier {
JsonNode metaNode = jsonResponse.path("meta"); JsonNode metaNode = jsonResponse.path("meta");
boolean isValid = metaNode.path("valid").asBoolean(); boolean isValid = metaNode.path("valid").asBoolean();
String detail = metaNode.path("detail").asText(); String detail = metaNode.path("detail").asText("");
String code = metaNode.path("code").asText(); String code = metaNode.path("code").asText("");
log.info("License validity: {}", isValid); log.info("License validity: {}", isValid);
log.info("Validation detail: {}", detail); log.info("Validation detail: {}", detail);
@@ -604,7 +614,7 @@ public class KeygenLicenseVerifier {
if (includedNode.isArray()) { if (includedNode.isArray()) {
for (JsonNode node : includedNode) { for (JsonNode node : includedNode) {
if ("policies".equals(node.path("type").asText())) { if ("policies".equals(node.path("type").asText(""))) {
policyNode = node; policyNode = node;
break; break;
} }
@@ -690,9 +700,9 @@ public class KeygenLicenseVerifier {
for (JsonNode machine : machines) { for (JsonNode machine : machines) {
if (machineFingerprint.equals( if (machineFingerprint.equals(
machine.path("attributes").path("fingerprint").asText())) { machine.path("attributes").path("fingerprint").asText(""))) {
isCurrentMachineActivated = true; isCurrentMachineActivated = true;
currentMachineId = machine.path("id").asText(); currentMachineId = machine.path("id").asText("");
log.info( log.info(
"Current machine is already activated with ID: {}", "Current machine is already activated with ID: {}",
currentMachineId); currentMachineId);
@@ -726,7 +736,7 @@ public class KeygenLicenseVerifier {
java.time.Instant.parse(createdStr); java.time.Instant.parse(createdStr);
if (oldestTime == null || createdTime.isBefore(oldestTime)) { if (oldestTime == null || createdTime.isBefore(oldestTime)) {
oldestTime = createdTime; oldestTime = createdTime;
oldestMachineId = machine.path("id").asText(); oldestMachineId = machine.path("id").asText("");
} }
} catch (Exception e) { } catch (Exception e) {
log.warn( log.warn(
@@ -740,7 +750,7 @@ public class KeygenLicenseVerifier {
if (oldestMachineId == null) { if (oldestMachineId == null) {
log.warn( log.warn(
"Could not determine oldest machine by timestamp, using first machine in list"); "Could not determine oldest machine by timestamp, using first machine in list");
oldestMachineId = machines.path(0).path("id").asText(); oldestMachineId = machines.path(0).path("id").asText("");
} }
log.info("Deregistering machine with ID: {}", oldestMachineId); log.info("Deregistering machine with ID: {}", oldestMachineId);
@@ -770,35 +780,28 @@ public class KeygenLicenseVerifier {
hostname = "Unknown"; hostname = "Unknown";
} }
JSONObject body = tools.jackson.databind.node.ObjectNode attributes = objectMapper.createObjectNode();
new JSONObject() attributes.put("fingerprint", machineFingerprint);
.put( attributes.put("platform", System.getProperty("os.name"));
"data", attributes.put("name", hostname);
new JSONObject()
.put("type", "machines") tools.jackson.databind.node.ObjectNode licenseRef = objectMapper.createObjectNode();
.put( licenseRef.put("type", "licenses");
"attributes", licenseRef.put("id", licenseId);
new JSONObject()
.put("fingerprint", machineFingerprint) tools.jackson.databind.node.ObjectNode licenseRelation = objectMapper.createObjectNode();
.put( licenseRelation.set("data", licenseRef);
"platform",
System.getProperty("os.name")) tools.jackson.databind.node.ObjectNode relationships = objectMapper.createObjectNode();
.put("name", hostname)) relationships.set("license", licenseRelation);
.put(
"relationships", tools.jackson.databind.node.ObjectNode data = objectMapper.createObjectNode();
new JSONObject() data.put("type", "machines");
.put( data.set("attributes", attributes);
"license", data.set("relationships", relationships);
new JSONObject()
.put( tools.jackson.databind.node.ObjectNode body = objectMapper.createObjectNode();
"data", body.set("data", data);
new JSONObject()
.put(
"type",
"licenses")
.put(
"id",
licenseId)))));
HttpRequest request = HttpRequest request =
HttpRequest.newBuilder() HttpRequest.newBuilder()
@@ -27,9 +27,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.util.HtmlUtils; import org.springframework.web.util.HtmlUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -49,6 +46,9 @@ import stirling.software.proprietary.security.model.api.admin.SettingValueRespon
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest; import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest; import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@AdminApi @AdminApi
@RequiredArgsConstructor @RequiredArgsConstructor
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
@@ -543,7 +543,8 @@ public class AdminSettingsController {
pendingChanges.clear(); pendingChanges.clear();
// Give the HTTP response time to complete, then exit // Give the HTTP response time to complete, then exit
new Thread( Thread.ofVirtual()
.start(
() -> { () -> {
try { try {
Thread.sleep(1000); Thread.sleep(1000);
@@ -554,8 +555,7 @@ public class AdminSettingsController {
log.error("Restart interrupted: {}", e.getMessage(), e); log.error("Restart interrupted: {}", e.getMessage(), e);
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
}) });
.start();
return ResponseEntity.ok( return ResponseEntity.ok(
Map.of( Map.of(
@@ -20,9 +20,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.Data; import lombok.Data;
@@ -31,6 +28,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/ui-data") @RequestMapping("/api/v1/ui-data")
@@ -39,6 +39,7 @@ public class UIDataTessdataController {
private static final Pattern INVALID_LANG_CHARS_PATTERN = Pattern.compile("[^A-Za-z0-9_+\\-]"); private static final Pattern INVALID_LANG_CHARS_PATTERN = Pattern.compile("[^A-Za-z0-9_+\\-]");
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final ObjectMapper objectMapper;
private static volatile List<String> cachedRemoteTessdata = null; private static volatile List<String> cachedRemoteTessdata = null;
private static volatile long cachedRemoteTessdataExpiry = 0L; private static volatile long cachedRemoteTessdataExpiry = 0L;
private static final long REMOTE_TESSDATA_TTL_MS = 10 * 60 * 1000; // 10 minutes private static final long REMOTE_TESSDATA_TTL_MS = 10 * 60 * 1000; // 10 minutes
@@ -223,9 +224,9 @@ public class UIDataTessdataController {
} }
try (InputStream is = connection.getInputStream()) { try (InputStream is = connection.getInputStream()) {
ObjectMapper mapper = new ObjectMapper();
List<Map<String, Object>> items = List<Map<String, Object>> items =
mapper.readValue(is, new TypeReference<List<Map<String, Object>>>() {}); objectMapper.readValue(
is, new TypeReference<List<Map<String, Object>>>() {});
List<String> languages = List<String> languages =
items.stream() items.stream()
.map(item -> (String) item.get("name")) .map(item -> (String) item.get("name"))
@@ -11,7 +11,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private Object credentials; private Object credentials;
public ApiKeyAuthenticationToken(String apiKey) { public ApiKeyAuthenticationToken(String apiKey) {
super(null); super((Collection<? extends GrantedAuthority>) null);
this.principal = null; this.principal = null;
this.credentials = apiKey; this.credentials = apiKey;
setAuthenticated(false); setAuthenticated(false);
@@ -14,7 +14,7 @@ import org.opensaml.saml.saml2.core.AuthnStatement;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider.ResponseToken; import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseToken;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -15,7 +15,7 @@ import org.springframework.security.saml2.provider.service.registration.InMemory
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver; import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -151,10 +151,10 @@ public class Saml2Configuration {
@Bean @Bean
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver( public OpenSaml5AuthenticationRequestResolver authenticationRequestResolver(
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
OpenSaml4AuthenticationRequestResolver resolver = OpenSaml5AuthenticationRequestResolver resolver =
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); new OpenSaml5AuthenticationRequestResolver(relyingPartyRegistrationRepository);
resolver.setRelayStateResolver( resolver.setRelayStateResolver(
request -> { request -> {
@@ -20,9 +20,6 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
@@ -40,21 +37,25 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Slf4j @Slf4j
@Service @Service
public class JwtService implements JwtServiceInterface { public class JwtService implements JwtServiceInterface {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final ObjectMapper objectMapper;
private final KeyPersistenceServiceInterface keyPersistenceService; private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled; private final boolean v2Enabled;
private final ApplicationProperties.Security securityProperties; private final ApplicationProperties.Security securityProperties;
@Autowired @Autowired
public JwtService( public JwtService(
ObjectMapper objectMapper,
@Qualifier("v2Enabled") boolean v2Enabled, @Qualifier("v2Enabled") boolean v2Enabled,
KeyPersistenceServiceInterface keyPersistenceService, KeyPersistenceServiceInterface keyPersistenceService,
ApplicationProperties applicationProperties) { ApplicationProperties applicationProperties) {
this.objectMapper = objectMapper;
this.v2Enabled = v2Enabled; this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService; this.keyPersistenceService = keyPersistenceService;
this.securityProperties = applicationProperties.getSecurity(); this.securityProperties = applicationProperties.getSecurity();
@@ -374,14 +375,14 @@ public class JwtService implements JwtServiceInterface {
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]); byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
Map<String, Object> header = Map<String, Object> header =
OBJECT_MAPPER.readValue( objectMapper.readValue(
headerBytes, new TypeReference<Map<String, Object>>() {}); headerBytes, new TypeReference<Map<String, Object>>() {});
Object keyId = header.get("kid"); Object keyId = header.get("kid");
return keyId instanceof String ? (String) keyId : null; return keyId instanceof String ? (String) keyId : null;
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.debug("Failed to decode Base64 JWT header: {}", e.getMessage()); log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
return null; return null;
} catch (java.io.IOException e) { } catch (tools.jackson.core.JacksonException e) {
log.debug("Failed to parse JWT header as JSON: {}", e.getMessage()); log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
return null; return null;
} }
@@ -15,8 +15,6 @@ import java.util.stream.Stream;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
@@ -24,6 +22,8 @@ import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
import tools.jackson.databind.ObjectMapper;
/** /**
* Service for managing user signatures with authentication and storage limits. This proprietary * Service for managing user signatures with authentication and storage limits. This proprietary
* version enforces per-user quotas and requires authentication. Provides access to personal * version enforces per-user quotas and requires authentication. Provides access to personal
@@ -36,15 +36,16 @@ public class SignatureService implements PersonalSignatureServiceInterface {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$"); private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH; private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS"; private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper;
// Storage limits per user // Storage limits per user
private static final int MAX_SIGNATURES_PER_USER = 20; private static final int MAX_SIGNATURES_PER_USER = 20;
private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000; // 2MB per signature private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000; // 2MB per signature
private static final long MAX_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user private static final long MAX_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user
public SignatureService() { public SignatureService(ObjectMapper objectMapper) {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath(); SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = objectMapper;
} }
/** /**
@@ -16,8 +16,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.config.AuditConfigurationProperties;
@@ -35,6 +33,9 @@ import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService; import stirling.software.proprietary.service.UserLicenseSettingsService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class ProprietaryUIDataControllerTest { class ProprietaryUIDataControllerTest {
@@ -63,7 +64,7 @@ class ProprietaryUIDataControllerTest {
applicationProperties.getSecurity().getSaml2().setEnabled(false); applicationProperties.getSecurity().getSaml2().setEnabled(false);
auditConfig = new AuditConfigurationProperties(applicationProperties); auditConfig = new AuditConfigurationProperties(applicationProperties);
objectMapper = new ObjectMapper(); objectMapper = JsonMapper.builder().build();
controller = controller =
new ProprietaryUIDataController( new ProprietaryUIDataController(
@@ -26,8 +26,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.AuthenticationType;
@@ -42,10 +40,13 @@ import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService; import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.service.UserService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class AuthControllerLoginTest { class AuthControllerLoginTest {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = JsonMapper.builder().build();
private MockMvc mockMvc; private MockMvc mockMvc;
private ApplicationProperties.Security securityProperties; private ApplicationProperties.Security securityProperties;
@@ -26,8 +26,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.CustomUserDetailsService;
@@ -37,12 +35,15 @@ import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TotpService; import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.service.UserService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class AuthControllerMfaTest { class AuthControllerMfaTest {
private static final String USERNAME = "[email protected]"; private static final String USERNAME = "[email protected]";
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = JsonMapper.builder().build();
private MockMvc mockMvc; private MockMvc mockMvc;
private Authentication authentication; private Authentication authentication;
@@ -19,6 +19,8 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import tools.jackson.databind.json.JsonMapper;
class UIDataTessdataControllerTest { class UIDataTessdataControllerTest {
@Test @Test
@@ -27,7 +29,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn("ignored/path"); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn("ignored/path");
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -49,7 +51,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -77,7 +79,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -100,7 +102,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng", "fra"); return List.of("eng", "fra");
@@ -139,7 +141,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -164,7 +166,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected boolean isWritableDirectory(Path dir) { protected boolean isWritableDirectory(Path dir) {
return false; return false;
@@ -186,7 +188,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -217,7 +219,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -258,7 +260,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng", "fra"); return List.of("eng", "fra");
@@ -282,7 +284,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -307,7 +309,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -330,7 +332,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(missingDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(missingDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected List<String> getRemoteTessdataLanguages() { protected List<String> getRemoteTessdataLanguages() {
return List.of("eng"); return List.of("eng");
@@ -352,7 +354,7 @@ class UIDataTessdataControllerTest {
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller = UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) { new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) {
@Override @Override
protected boolean isWritableDirectory(Path dir) { protected boolean isWritableDirectory(Path dir) {
return false; return false;
@@ -21,8 +21,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -35,10 +33,13 @@ import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService; import stirling.software.proprietary.service.UserLicenseSettingsService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class UserControllerTest { class UserControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = JsonMapper.builder().build();
@Mock private UserService userService; @Mock private UserService userService;
@Mock private SessionPersistentRegistry sessionRegistry; @Mock private SessionPersistentRegistry sessionRegistry;
@@ -36,6 +36,9 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class JwtServiceTest { class JwtServiceTest {
@@ -66,7 +69,8 @@ class JwtServiceTest {
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
ApplicationProperties applicationProperties = new ApplicationProperties(); ApplicationProperties applicationProperties = new ApplicationProperties();
jwtService = new JwtService(true, keystoreService, applicationProperties); ObjectMapper objectMapper = JsonMapper.builder().build();
jwtService = new JwtService(objectMapper, true, keystoreService, applicationProperties);
} }
@Test @Test
+51 -16
View File
@@ -2,7 +2,7 @@ plugins {
id "java" id "java"
id "jacoco" id "jacoco"
id "io.spring.dependency-management" version "1.1.7" id "io.spring.dependency-management" version "1.1.7"
id "org.springframework.boot" version "3.5.9" id "org.springframework.boot" version "4.0.3"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2" id "io.swagger.swaggerhub" version "1.3.2"
id "com.diffplug.spotless" version "8.1.0" id "com.diffplug.spotless" version "8.1.0"
@@ -15,20 +15,31 @@ import com.github.jk1.license.render.*
import groovy.json.JsonOutput import groovy.json.JsonOutput
import groovy.json.JsonSlurper import groovy.json.JsonSlurper
import groovy.xml.XmlSlurper import groovy.xml.XmlSlurper
import org.gradle.api.JavaVersion
import org.gradle.api.tasks.testing.Test import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
ext { ext {
springBootVersion = "3.5.9" springBootVersion = "4.0.3"
pdfboxVersion = "3.0.6" pdfboxVersion = "3.0.6"
imageioVersion = "3.13.0" imageioVersion = "3.13.0"
lombokVersion = "1.18.42" lombokVersion = "1.18.42"
bouncycastleVersion = "1.83" bouncycastleVersion = "1.83"
springSecuritySamlVersion = "6.5.6" springSecuritySamlVersion = "7.0.2"
openSamlVersion = "4.3.2" openSamlVersion = "4.3.2"
commonmarkVersion = "0.27.0" commonmarkVersion = "0.27.1"
googleJavaFormatVersion = "1.28.0" googleJavaFormatVersion = "1.34.1"
logback = "1.5.25" logback = "1.5.28"
junitPlatformVersion = "1.12.2" junitPlatformVersion = "1.12.2"
modernJavaVersion = 21
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(project.findProperty('javaVersion')?.toString() ?: '25')
}
} }
ext.isSecurityDisabled = { -> ext.isSecurityDisabled = { ->
@@ -70,7 +81,6 @@ allprojects {
version = '2.5.3' version = '2.5.3'
configurations.configureEach { configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
} }
} }
@@ -138,8 +148,11 @@ subprojects {
apply plugin: 'jacoco' apply plugin: 'jacoco'
java { java {
// 17 is lowest but we support and recommend 21 sourceCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
} }
if (project.name != "stirling-pdf") { if (project.name != "stirling-pdf") {
@@ -167,12 +180,14 @@ subprojects {
} }
configurations.configureEach { configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
// Exclude vulnerable BouncyCastle version used in tableau // Exclude vulnerable BouncyCastle version used in tableau
exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on' exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on'
exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on' exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on'
exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on' exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on'
// google-java-format 1.34+ requires Guava 33.x (ImmutableSortedMapFauxverideShim);
// force it here so Spotless's FeatureClassLoader resolves the correct version.
resolutionStrategy.force 'com.google.guava:guava:33.4.8-jre'
} }
dependencyManagement { dependencyManagement {
@@ -191,7 +206,11 @@ subprojects {
compileOnly "org.projectlombok:lombok:$lombokVersion" compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor "org.projectlombok:lombok:$lombokVersion" annotationProcessor "org.projectlombok:lombok:$lombokVersion"
// Jackson 3 (Spring Boot 4 uses tools.jackson)
implementation 'org.springframework.boot:spring-boot-starter-jackson'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0' testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly "org.junit.platform:junit-platform-launcher" testRuntimeOnly "org.junit.platform:junit-platform-launcher"
@@ -201,13 +220,15 @@ subprojects {
tasks.withType(JavaCompile).configureEach { tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8" options.encoding = "UTF-8"
options.release = rootProject.ext.modernJavaVersion
if (!project.hasProperty("noSpotless")) { if (!project.hasProperty("noSpotless")) {
dependsOn "spotlessApply" dependsOn "spotlessApply"
} }
} }
tasks.named("compileJava", JavaCompile).configure { tasks.named("compileJava", JavaCompile).configure {
options.compilerArgs.add("-parameters") // options.compilerArgs.add("-Xlint:deprecation")
// options.compilerArgs.add("-Xlint:unchecked")
} }
def jacocoReport = tasks.named("jacocoTestReport") def jacocoReport = tasks.named("jacocoTestReport")
@@ -387,6 +408,17 @@ subprojects {
finalizedBy("copySwaggerDoc") finalizedBy("copySwaggerDoc")
doNotTrackState("OpenAPI plugin writes outside build directory") doNotTrackState("OpenAPI plugin writes outside build directory")
} }
tasks.named("bootRun") {
jvmArgs = [
"-XX:+UseG1GC",
"-XX:MaxGCPauseMillis=200",
"-XX:G1HeapRegionSize=4m",
"-XX:+ExplicitGCInvokesConcurrent",
"-XX:+UseStringDeduplication",
"-XX:+UseCompactObjectHeaders"
]
}
} }
} }
@@ -533,9 +565,12 @@ tasks.register('compileRestartHelper', JavaCompile) {
source = fileTree(dir: 'scripts', include: 'RestartHelper.java') source = fileTree(dir: 'scripts', include: 'RestartHelper.java')
classpath = files() classpath = files()
destinationDirectory = file("${buildDir}/restart-helper-classes") destinationDirectory = layout.buildDirectory.dir("restart-helper-classes")
sourceCompatibility = JavaVersion.VERSION_17 def restartMajorVersion = project.ext.modernJavaVersion
targetCompatibility = JavaVersion.VERSION_17 def restartCompatibility = JavaVersion.toVersion(restartMajorVersion.toString())
sourceCompatibility = restartCompatibility
targetCompatibility = restartCompatibility
options.release.set(restartMajorVersion)
} }
// Task to create restart-helper.jar // Task to create restart-helper.jar
@@ -544,9 +579,9 @@ tasks.register('buildRestartHelper', Jar) {
description = 'Builds the restart-helper.jar' description = 'Builds the restart-helper.jar'
dependsOn 'compileRestartHelper' dependsOn 'compileRestartHelper'
from "${buildDir}/restart-helper-classes" from layout.buildDirectory.dir("restart-helper-classes")
archiveFileName = 'restart-helper.jar' archiveFileName = 'restart-helper.jar'
destinationDirectory = file("${buildDir}/libs") destinationDirectory = layout.buildDirectory.dir("libs")
manifest { manifest {
attributes 'Main-Class': 'RestartHelper' attributes 'Main-Class': 'RestartHelper'
+19 -5
View File
@@ -24,6 +24,13 @@ COPY gradle gradle/
COPY app/core/build.gradle core/. COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/. COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/. COPY app/proprietary/build.gradle proprietary/.
ENV JAVA_TOOL_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0 RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
WORKDIR /app WORKDIR /app
@@ -54,7 +61,11 @@ LABEL org.opencontainers.image.keywords="PDF, manipulation, unified, API, Spring
# Copy backend files # Copy backend files
COPY scripts /scripts COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY --from=backend-build /app/app/core/build/libs/*.jar app.jar
# Copy built JAR
# Use numeric UID:GID (1000:1000) since the named user doesn't exist yet at COPY time
COPY --from=backend-build --chown=1000:1000 \
/app/app/core/build/libs/*.jar app.jar
# Copy frontend files # Copy frontend files
COPY --from=frontend-build /app/dist /usr/share/nginx/html COPY --from=frontend-build /app/dist /usr/share/nginx/html
@@ -84,6 +95,7 @@ ENV VERSION_TAG=$VERSION_TAG \
VITE_API_BASE_URL=http://localhost:8080 VITE_API_BASE_URL=http://localhost:8080
# Install all dependencies # Install all dependencies
# Removed wasteful pip upgrade; chown moved to COPY above
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
@@ -122,11 +134,14 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
py3-pillow@testing \ py3-pillow@testing \
py3-pdf2image@testing && \ py3-pdf2image@testing && \
python3 -m venv /opt/venv && \ python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \ /opt/venv/bin/pip install --no-cache-dir unoserver weasyprint && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \ ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
# Clean up pip + setuptools from venv
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true; \
rm -rf /opt/venv/lib/python*/site-packages/pip \
/opt/venv/lib/python*/site-packages/setuptools && \
mv /usr/share/tessdata /usr/share/tessdata-original && \ mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \ mkdir -p $HOME /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p /var/lib/nginx/tmp /var/log/nginx && \ mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
@@ -135,8 +150,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
chmod +x /entrypoint.sh && \ chmod +x /entrypoint.sh && \
# User permissions # User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp EXPOSE 8080/tcp
+14 -3
View File
@@ -24,6 +24,13 @@ COPY gradle gradle/
COPY app/core/build.gradle core/. COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/. COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/. COPY app/proprietary/build.gradle proprietary/.
ENV JAVA_TOOL_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0 RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
WORKDIR /app WORKDIR /app
@@ -53,7 +60,11 @@ LABEL org.opencontainers.image.keywords="PDF, manipulation, unified, ultra-lite,
# Copy backend files # Copy backend files
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY --from=backend-build /app/app/core/build/libs/*.jar app.jar
# Copy built JAR
# Use numeric UID:GID (1000:1000) since the named user doesn't exist yet at COPY time
COPY --from=backend-build --chown=1000:1000 \
/app/app/core/build/libs/*.jar app.jar
# Copy frontend files # Copy frontend files
COPY --from=frontend-build /app/dist /usr/share/nginx/html COPY --from=frontend-build /app/dist /usr/share/nginx/html
@@ -80,6 +91,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \
ENDPOINTS_GROUPS_TO_REMOVE=CLI ENDPOINTS_GROUPS_TO_REMOVE=CLI
# Install minimal dependencies # Install minimal dependencies
# /app.jar chown moved to COPY above
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
@@ -100,8 +112,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
chmod +x /entrypoint.sh && \ chmod +x /entrypoint.sh && \
# User permissions # User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp EXPOSE 8080/tcp
-191
View File
@@ -1,191 +0,0 @@
# ==============================================================================
# Multi-stage Dockerfile for Stirling-PDF image with everything included
# Includes: LibreOffice, Calibre, Tesseract, OCRmyPDF, unoserver, WeasyPrint, etc.
# ==============================================================================
# ========================================
# STAGE 1: Build stage - Alpine with Gradle
# ========================================
FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# ========================================
# STAGE 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
# ========================================
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
ENV TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
# Install core runtime dependencies + tools required by Stirling-PDF features
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata tini bash fontconfig \
openjdk-21-jre-headless \
ffmpeg poppler-utils ocrmypdf \
libreoffice-nogui libreoffice-java-common \
python3 python3-venv python3-uno \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
gosu unpaper \
# AWT headless support (required for some Java graphics operations)
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
# Qt WebEngine dependencies for Calibre
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
# Virtual framebuffer (required for headless LibreOffice)
xvfb x11-utils coreutils \
# Temporary packages only needed for Calibre installer
xz-utils gpgv curl xdg-utils \
\
# Install Calibre from official installer script
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
\
# Clean up installer-only packages
&& apt-get purge -y xz-utils gpgv xdg-utils \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Make ebook-convert available in PATH
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
&& /opt/calibre/ebook-convert --version
# ==============================================================================
# Create non-root user (stirlingpdfuser) with configurable UID/GID
# ==============================================================================
ARG PUID=1000
ARG PGID=1000
RUN set -eux; \
# Create group if it doesn't exist
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
if getent group "${PGID}" >/dev/null 2>&1; then \
groupadd -o -g "${PGID}" stirlingpdfgroup; \
else \
groupadd -g "${PGID}" stirlingpdfgroup; \
fi; \
fi; \
# Create user if it doesn't exist, avoid UID conflicts
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \
echo "UID ${PUID} already in use creating stirlingpdfuser with automatic UID"; \
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
else \
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
fi; \
fi
# Compatibility alias for older entrypoint scripts expecting su-exec
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
# Copy application files from build stage
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/app/core/build/libs/*.jar /app.jar
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/build/libs/restart-helper.jar /restart-helper.jar
COPY scripts/ /scripts/
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
# Optional version tag (can be passed at build time)
ARG VERSION_TAG
LABEL org.opencontainers.image.title="Stirling-PDF Backend"
LABEL org.opencontainers.image.description="Backend service for Stirling-PDF - Java Spring Boot with PDF processing capabilities"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
# ==============================================================================
# Runtime environment variables
# ==============================================================================
ENV VERSION_TAG=$VERSION_TAG \
DISABLE_ADDITIONAL_FEATURES=true \
JAVA_BASE_OPTS="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps \
-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 \
-XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 \
-XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 \
-Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=${PUID} \
PGID=${PGID} \
UMASK=022 \
UNO_PATH=/usr/lib/libreoffice/program \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# ==============================================================================
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.)
# ==============================================================================
RUN python3 -m venv /opt/venv --system-site-packages \
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)"
# Separate venv for unoserver (keeps it isolated)
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
&& /opt/unoserver-venv/bin/pip install --no-cache-dir unoserver
# Make unoserver tools available in main venv PATH
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
# Extend PATH to include both virtual environments
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
# ==============================================================================
# Final permissions, directories and font cache
# ==============================================================================
RUN set -eux; \
chmod +x /scripts/*; \
mkdir -p /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
/app.jar /restart-helper.jar /usr/share/fonts/truetype /scripts; \
chmod -R 755 /tmp/stirling-pdf
# Rebuild font cache
RUN fc-cache -f -v
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
ENV QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
# Expose web UI port
EXPOSE 8080/tcp
STOPSIGNAL SIGTERM
# Use tini as init (handles signals and zombies correctly)
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
# CMD is empty actual start command is defined in init.sh
CMD []
-192
View File
@@ -1,192 +0,0 @@
# ==============================================================================
# Multi-stage Dockerfile for Stirling-PDF "fat" image with everything included
# Includes: LibreOffice, Calibre, Tesseract, OCRmyPDF, unoserver, WeasyPrint, etc.
# ==============================================================================
# ========================================
# STAGE 1: Build stage - Gradle
# ========================================
FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# ========================================
# STAGE 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
# ========================================
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
# Install core runtime dependencies + tools required by Stirling-PDF features
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata tini bash fontconfig \
openjdk-21-jre-headless \
ffmpeg poppler-utils qpdf ghostscript ocrmypdf \
libreoffice-nogui libreoffice-java-common \
python3 python3-venv python3-uno \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
gosu unpaper \
# AWT headless support (required for some Java graphics operations)
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
# Qt WebEngine dependencies for Calibre
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
# Virtual framebuffer (required for headless LibreOffice)
xvfb x11-utils coreutils \
# Temporary packages only needed for Calibre installer
xz-utils gpgv curl xdg-utils \
\
# Install Calibre from official installer script
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
\
# Clean up installer-only packages
&& apt-get purge -y xz-utils gpgv xdg-utils \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Make ebook-convert available in PATH
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
&& /opt/calibre/ebook-convert --version
# ==============================================================================
# Create non-root user (stirlingpdfuser) with configurable UID/GID
# ==============================================================================
ARG PUID=1000
ARG PGID=1000
RUN set -eux; \
# Create group if it doesn't exist
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
if getent group "${PGID}" >/dev/null 2>&1; then \
groupadd -o -g "${PGID}" stirlingpdfgroup; \
else \
groupadd -g "${PGID}" stirlingpdfgroup; \
fi; \
fi; \
# Create user if it doesn't exist, avoid UID conflicts
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \
echo "UID ${PUID} already in use creating stirlingpdfuser with automatic UID"; \
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
else \
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
fi; \
fi
# Compatibility alias for older entrypoint scripts expecting su-exec
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
# Copy application files from build stage
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/app/core/build/libs/*.jar /app.jar
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/build/libs/restart-helper.jar /restart-helper.jar
COPY scripts/ /scripts/
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
# Optional version tag (can be passed at build time)
ARG VERSION_TAG
# Metadata labels
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more."
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
# ==============================================================================
# Runtime environment variables
# ==============================================================================
ENV VERSION_TAG=$VERSION_TAG \
DISABLE_ADDITIONAL_FEATURES=true \
JAVA_BASE_OPTS="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps \
-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 \
-XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 \
-XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 \
-Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=${PUID} \
PGID=${PGID} \
UMASK=022 \
FAT_DOCKER=true \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
UNO_PATH=/usr/lib/libreoffice/program \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# ==============================================================================
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.)
# ==============================================================================
RUN python3 -m venv /opt/venv --system-site-packages \
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)"
# Separate venv for unoserver (keeps it isolated)
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
&& /opt/unoserver-venv/bin/pip install --no-cache-dir unoserver
# Make unoserver tools available in main venv PATH
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
# Extend PATH to include both virtual environments
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
# ==============================================================================
# Final permissions, directories and font cache
# ==============================================================================
RUN set -eux; \
chmod +x /scripts/*; \
mkdir -p /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
/app.jar /restart-helper.jar /usr/share/fonts/truetype /scripts; \
chmod -R 755 /tmp/stirling-pdf
# Rebuild font cache
RUN fc-cache -f -v
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
ENV QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
# Expose web UI port
EXPOSE 8080/tcp
STOPSIGNAL SIGTERM
# Use tini as init (handles signals and zombies correctly)
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
# CMD is empty actual start command is defined in init.sh
CMD []
-87
View File
@@ -1,87 +0,0 @@
# Backend ultra-lite Dockerfile - Java Spring Boot with minimal dependencies and build stage
# ========================================
# STAGE 1: Build stage - Gradle
# ========================================
FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=true
RUN DISABLE_ADDITIONAL_FEATURES=true \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# ========================================
# STAGE 2: Runtime stage - Alpine minimal
# ========================================
FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62
ARG VERSION_TAG
# Set Environment Variables
ENV HOME=/home/stirlingpdfuser \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
PUID=1000 \
PGID=1000 \
UMASK=022 \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Copy necessary files
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/app/core/build/libs/*.jar /app.jar
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/build/libs/restart-helper.jar /restart-helper.jar
# Set up necessary directories and permissions
RUN apk add --no-cache bash \
&& ln -sf /bin/bash /bin/sh \
&& printf '%s\n' \
'https://dl-cdn.alpinelinux.org/alpine/edge/main' \
'https://dl-cdn.alpinelinux.org/alpine/edge/community' \
'https://dl-cdn.alpinelinux.org/alpine/edge/testing' \
> /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openjdk21-jre \
ghostscript \
fontforge && \
# User permissions
mkdir -p /configs /configs/heap_dumps /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar && \
ln -sf /bin/busybox /bin/sh
# Set environment variables
ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI
EXPOSE 8080/tcp
# Run the application
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD []
+4 -24
View File
@@ -1,9 +1,9 @@
services: services:
backend: stirling-pdf:
build: build:
context: ../.. context: ../..
dockerfile: docker/backend/Dockerfile.fat dockerfile: docker/embedded/Dockerfile.fat
container_name: stirling-pdf-backend-fat container_name: stirling-pdf-fat
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
@@ -15,9 +15,7 @@ services:
timeout: 10s timeout: 10s
retries: 16 retries: 16
ports: ports:
- "8080:8080" # TODO: Remove in production - for debugging only - "8080:8080"
expose:
- "8080"
volumes: volumes:
- ../../stirling/latest/data:/usr/share/tessdata:rw - ../../stirling/latest/data:/usr/share/tessdata:rw
- ../../stirling/latest/config:/configs:rw - ../../stirling/latest/config:/configs:rw
@@ -37,24 +35,6 @@ services:
networks: networks:
- stirling-network - stirling-network
frontend:
build:
context: ../..
dockerfile: docker/frontend/Dockerfile
container_name: stirling-pdf-frontend-fat
restart: unless-stopped
ports:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
- stirling-network
networks: networks:
stirling-network: stirling-network:
driver: bridge driver: bridge
+4 -24
View File
@@ -1,9 +1,9 @@
services: services:
backend: stirling-pdf:
build: build:
context: ../.. context: ../..
dockerfile: docker/backend/Dockerfile.ultra-lite dockerfile: docker/embedded/Dockerfile.ultra-lite
container_name: stirling-pdf-backend-ultra-lite container_name: stirling-pdf-ultra-lite
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
@@ -15,9 +15,7 @@ services:
timeout: 10s timeout: 10s
retries: 16 retries: 16
ports: ports:
- "8080:8080" # TODO: Remove in production - for debugging only - "8080:8080"
expose:
- "8080"
volumes: volumes:
- ../../stirling/latest/config:/configs:rw - ../../stirling/latest/config:/configs:rw
- ../../stirling/latest/logs:/logs:rw - ../../stirling/latest/logs:/logs:rw
@@ -34,24 +32,6 @@ services:
networks: networks:
- stirling-network - stirling-network
frontend:
build:
context: ../..
dockerfile: docker/frontend/Dockerfile
container_name: stirling-pdf-frontend-ultra-lite
restart: unless-stopped
ports:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
- stirling-network
networks: networks:
stirling-network: stirling-network:
driver: bridge driver: bridge
+4 -24
View File
@@ -1,9 +1,9 @@
services: services:
backend: stirling-pdf:
build: build:
context: ../.. context: ../..
dockerfile: docker/backend/Dockerfile dockerfile: docker/embedded/Dockerfile
container_name: stirling-pdf-backend container_name: stirling-pdf
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"] test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
@@ -11,9 +11,7 @@ services:
timeout: 10s timeout: 10s
retries: 16 retries: 16
ports: ports:
- "8080:8080" # TODO: Remove in production - for debugging only - "8080:8080"
expose:
- "8080"
volumes: volumes:
- ../../stirling/latest/data:/usr/share/tessdata:rw - ../../stirling/latest/data:/usr/share/tessdata:rw
- ../../stirling/latest/config:/configs:rw - ../../stirling/latest/config:/configs:rw
@@ -32,24 +30,6 @@ services:
networks: networks:
- stirling-network - stirling-network
frontend:
build:
context: ../..
dockerfile: docker/frontend/Dockerfile
container_name: stirling-pdf-frontend
restart: unless-stopped
ports:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
- stirling-network
networks: networks:
stirling-network: stirling-network:
driver: bridge driver: bridge
+553 -174
View File
@@ -1,213 +1,592 @@
# Stirling-PDF Dockerfile - Full version with embedded frontend # Stirling-PDF - Full version (embedded frontend)
# Single JAR contains both frontend and backend
# Stage 1: Build application with embedded frontend FROM ubuntu:noble AS calibre-build
FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build
# Install Node.js and npm for frontend build ARG CALIBRE_VERSION=9.3.1
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
FROM debian:stable-slim@sha256:ed542b2d269ff08139fc5ab8c762efe8c8986b564a423d5241a5ce9fb09b6c08
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8
ENV TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
# Install core runtime dependencies + tools required by Stirling-PDF features
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata tini bash fontconfig \
openjdk-21-jre-headless \
ffmpeg poppler-utils ocrmypdf imagemagick fontforge ghostscript \
fonts-dejavu \
fonts-liberation fonts-liberation2 \
fonts-crosextra-caladea fonts-crosextra-carlito \
fonts-linuxlibertine \
fonts-noto-core fonts-noto-cjk fonts-noto-mono fonts-noto-ui-core \
fonts-noto-color-emoji \
ttf-wqy-zenhei \
fonts-arphic-ukai fonts-arphic-uming \
python3 python3-venv python3-uno \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
gosu unpaper qpdf \
# AWT headless support (required for some Java graphics operations)
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
# Qt WebEngine dependencies for Calibre
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
# Virtual framebuffer (required for headless LibreOffice)
xvfb x11-utils coreutils \
# Temporary packages only needed for Calibre installer
xz-utils gpgv curl xdg-utils \
\
# Install Calibre from official installer script
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
\
# Clean up installer-only packages
&& apt-get purge -y xz-utils gpgv xdg-utils \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \ RUN set -eux; \
. /etc/os-release; \
echo "deb http://deb.debian.org/debian ${VERSION_CODENAME}-backports main" > /etc/apt/sources.list.d/backports.list; \
apt-get update; \ apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -t ${VERSION_CODENAME}-backports \ apt-get install -y --no-install-recommends \
libreoffice libreoffice-java-common; \ ca-certificates curl xz-utils libnss3 libfontconfig1 \
libgl1 libegl1 libdbus-1-3 libasound2t64 libxcomposite1 \
libxrandr2 libxkbcommon0 libxi6 libxtst6 libopengl0; \
rm -rf /var/lib/apt/lists/*; \ rm -rf /var/lib/apt/lists/*; \
libreoffice --version \
case "$(uname -m)" in \
x86_64) CALIBRE_ARCH="x86_64" ;; \
aarch64) CALIBRE_ARCH="arm64" ;; \
*) echo "Unsupported arch: $(uname -m)"; exit 1 ;; \
esac; \
\
curl -fsSL \
"https://download.calibre-ebook.com/${CALIBRE_VERSION}/calibre-${CALIBRE_VERSION}-${CALIBRE_ARCH}.txz" \
-o /tmp/calibre.txz; \
mkdir -p /opt/calibre; \
tar xJf /tmp/calibre.txz -C /opt/calibre; \
rm /tmp/calibre.txz; \
\
# Remove GUI-only shared libraries.
# Libs required by WebEngine PDF output are preserved.
rm -f /opt/calibre/lib/libQt6Designer* \
/opt/calibre/lib/libQt6Multimedia* \
/opt/calibre/lib/libQt6SpatialAudio.so.* \
/opt/calibre/lib/libQt6NetworkAuth.so.* \
/opt/calibre/lib/libQt6Concurrent.so.* \
/opt/calibre/lib/libQt6OpenGLWidgets.so.* \
/opt/calibre/lib/libQt6QuickWidgets.so.* \
/opt/calibre/lib/libavcodec.so.* \
/opt/calibre/lib/libavfilter.so.* \
/opt/calibre/lib/libavformat.so.* \
/opt/calibre/lib/libavutil.so.* \
/opt/calibre/lib/libavdevice.so.* \
/opt/calibre/lib/libpostproc.so.* \
/opt/calibre/lib/libswresample.so.* \
/opt/calibre/lib/libswscale.so.* \
/opt/calibre/lib/libspeex.so.* \
/opt/calibre/lib/libFLAC.so.* \
/opt/calibre/lib/libopus.so.* \
/opt/calibre/lib/libvorbis*.so.* \
/opt/calibre/lib/libasyncns.so.* \
/opt/calibre/lib/libspeechd.so.* \
/opt/calibre/lib/libespeak-ng.so.* \
/opt/calibre/lib/libonnxruntime.so.* \
/opt/calibre/lib/libgio-2.0.so.* \
/opt/calibre/lib/libzstd.so.* \
/opt/calibre/lib/libhunspell-1.7.so.* \
/opt/calibre/lib/libbrotlienc.so.* \
/opt/calibre/lib/libbrotlicommon.so.* \
/opt/calibre/lib/libbrotlidec.so.* \
/opt/calibre/lib/libstemmer.so.* \
/opt/calibre/lib/libmtp.so.* \
/opt/calibre/lib/libncursesw.so.* \
/opt/calibre/lib/libchm.so.* \
/opt/calibre/lib/libgcrypt.so.* \
/opt/calibre/lib/libgpg-error.so.* \
/opt/calibre/lib/libicuio.so.* \
/opt/calibre/lib/libreadline.so.* \
/opt/calibre/lib/libusb-1.0.so.*; \
rm -rf /opt/calibre/lib/qt6/plugins/platformthemes \
/opt/calibre/lib/qt6/plugins/multimedia \
/opt/calibre/lib/qt6/plugins/designer \
/opt/calibre/lib/qt6/plugins/qmltooling; \
\
# Remove GUI executables but keep ebook-convert, ebook-meta, and calibre-parallel.
rm -f /opt/calibre/calibre \
/opt/calibre/calibre-server \
/opt/calibre/calibre-smtp \
/opt/calibre/calibre-debug \
/opt/calibre/calibre-customize \
/opt/calibre/calibredb \
/opt/calibre/ebook-viewer \
/opt/calibre/ebook-edit \
/opt/calibre/ebook-polish \
/opt/calibre/ebook-device \
/opt/calibre/fetch-ebook-metadata \
/opt/calibre/lrf2lrs \
/opt/calibre/lrs2lrf \
/opt/calibre/markdown-calibre \
/opt/calibre/web2disk; \
\
# Remove Python modules not needed for conversion.
rm -rf /opt/calibre/lib/calibre/gui2 \
/opt/calibre/lib/calibre/devices \
/opt/calibre/lib/calibre/library \
/opt/calibre/lib/calibre/db \
/opt/calibre/lib/calibre/srv \
/opt/calibre/lib/calibre/spell \
/opt/calibre/lib/calibre/live; \
\
# Remove resources not needed for CLI conversion.
rm -rf /opt/calibre/resources/images \
/opt/calibre/resources/icons \
/opt/calibre/resources/icons.rcc \
/opt/calibre/resources/content-server \
/opt/calibre/resources/editor* \
/opt/calibre/resources/viewer \
/opt/calibre/resources/viewer.js \
/opt/calibre/resources/viewer.html \
/opt/calibre/resources/recipes \
/opt/calibre/resources/dictionaries \
/opt/calibre/resources/hyphenation \
/opt/calibre/resources/catalog \
/opt/calibre/resources/calibre-mimetypes.xml \
/opt/calibre/resources/changelog.json \
/opt/calibre/resources/user-agent-data.json \
/opt/calibre/resources/builtin_recipes.zip \
/opt/calibre/resources/builtin_recipes.xml \
/opt/calibre/resources/builtin_recipes.xml \
/opt/calibre/resources/stylelint-bundle.min.js \
/opt/calibre/resources/stylelint.js \
/opt/calibre/resources/rapydscript \
/opt/calibre/resources/quick_start \
/opt/calibre/resources/piper-voices.json \
/opt/calibre/resources/images.qrc \
/opt/calibre/resources/mozilla-ca-certs.pem \
/opt/calibre/resources/ebook-convert-complete.calibre_msgpack \
/opt/calibre/resources/mathjax \
/opt/calibre/resources/common-english-words.txt \
/opt/calibre/resources/calibre-portable.sh \
/opt/calibre/resources/calibre-portable.bat \
/opt/calibre/resources/metadata_sqlite.sql \
/opt/calibre/resources/notes_sqlite.sql \
/opt/calibre/resources/fts_sqlite.sql \
/opt/calibre/resources/fts_triggers.sql \
/opt/calibre/resources/jacket \
/opt/calibre/resources/editor-functions.json \
/opt/calibre/resources/calibre-ebook-root-CA.crt \
/opt/calibre/resources/csscolorparser.js \
/opt/calibre/resources/lookup.js \
/opt/calibre/resources/pdf-mathjax-loader.js \
/opt/calibre/resources/scraper.js \
/opt/calibre/resources/toc.js \
/opt/calibre/resources/user-manual-translation-stats.json \
/opt/calibre/resources/pin-template.svg \
/opt/calibre/resources/scripts.calibre_msgpack \
/opt/calibre/lib/calibre/ebooks/docx/images \
/opt/calibre/share \
/opt/calibre/man; \
\
# Remove translations and localization while keeping required libraries.
# Keep iso639.calibre_msgpack (required)
# Keep qtwebengine_locales (required for WebEngine)
rm -rf /opt/calibre/lib/qt6/translations; \
find /opt/calibre/translations -mindepth 1 -maxdepth 1 ! -name 'qtwebengine_locales' -exec rm -rf {} +; \
if [ -d /opt/calibre/resources/localization ]; then \
rm -rf /opt/calibre/resources/localization/locales.zip \
/opt/calibre/resources/localization/stats.calibre_msgpack \
/opt/calibre/resources/localization/website-languages.txt; \
find /opt/calibre/resources/localization -mindepth 1 -maxdepth 1 ! -name 'iso639.calibre_msgpack' -exec rm -rf {} +; \
fi; \
\
# Strip debug symbols from calibre extension modules.
# Exclude Qt6 libs: libQt6WebEngineCore and friends embed Chromium V8 JIT code
# and internal resource blobs that strip corrupts, causing segfaults at render time.
find /opt/calibre/lib -name '*.so*' \
! -name 'libQt6*' \
-exec strip --strip-unneeded {} + 2>/dev/null || true; \
\
# Remove Python bytecode caches.
find /opt/calibre -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /opt/calibre -name '*.pyc' -delete 2>/dev/null || true; \
\
# ── Verify conversion still works ──
# NOTE: txt→epub used intentionally NOT txt→pdf.
# Calibre 7+ uses WebEngine (Chromium) for PDF output, which requires kernel
# capabilities unavailable in Docker RUN steps and segfaults under QEMU.
# epub output exercises the same Python/plugin stack without touching WebEngine.
/opt/calibre/ebook-convert --version; \
echo "Hello" > /tmp/test.txt; \
/opt/calibre/ebook-convert /tmp/test.txt /tmp/test.epub; \
rm -f /tmp/test.txt /tmp/test.epub; \
echo "=== Calibre stripped successfully ==="
# Make ebook-convert available in PATH
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
&& /opt/calibre/ebook-convert --version
# ============================================================================== # Build the Java application and frontend.
# Create non-root user (stirlingpdfuser) with configurable UID/GID FROM gradle:9.3.1-jdk25 AS app-build
# ==============================================================================
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& update-ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
WORKDIR /app
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
# Use system gradle instead of gradlew to avoid SSL issues downloading gradle distribution on emulated arm64
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
gradle dependencies --no-daemon || true
COPY . .
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
DISABLE_ADDITIONAL_FEATURES=false \
gradle clean build \
-PbuildWithFrontend=true \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
# Extract Spring Boot Layers.
FROM eclipse-temurin:25-jre-noble AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Build Ghostscript 10.06.0 from source in an isolated stage (avoids library conflicts).
FROM ubuntu:noble AS gs-build
ARG GS_VERSION=10.06.0
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl ca-certificates libfontconfig1-dev && \
rm -rf /var/lib/apt/lists/* && \
GS_TAG="gs$(printf '%s' "${GS_VERSION}" | tr -d '.')" && \
curl -fsSL "https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/${GS_TAG}/ghostscript-${GS_VERSION}.tar.gz" | tar xz && \
cd "ghostscript-${GS_VERSION}" && \
./configure \
--prefix=/usr/local \
--without-x \
--disable-cups \
--disable-gtk && \
make -j"$(nproc)" && \
make install && \
cd ..
# Build PDF Tools (QPDF and ImageMagick 7).
FROM ubuntu:noble AS pdf-tools-build
ARG QPDF_VERSION=12.3.2
ARG IM_VERSION=7.1.2-13
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake libssl-dev libjpeg-dev zlib1g-dev curl ca-certificates pkg-config \
libpng-dev libtiff-dev libwebp-dev libxml2-dev libfreetype6-dev liblcms2-dev libzip-dev liblqr-1-0-dev \
libltdl-dev libtool && \
# Build QPDF
curl -fsSL "https://github.com/qpdf/qpdf/releases/download/v${QPDF_VERSION}/qpdf-${QPDF_VERSION}.tar.gz" | tar xz && \
cd "qpdf-${QPDF_VERSION}" && \
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DALLOW_CRYPTO_OPENSSL=ON -DDEFAULT_CRYPTO=openssl && \
cmake --build build --parallel "$(nproc)" && \
cmake --install build && \
cd .. && \
# Build ImageMagick 7
curl -fsSL "https://github.com/ImageMagick/ImageMagick/archive/refs/tags/${IM_VERSION}.tar.gz" | tar xz && \
cd "ImageMagick-${IM_VERSION}" && \
./configure --prefix=/usr/local --with-modules --with-perl=no --with-magick-plus-plus=no --with-quantum-depth=16 --disable-static --enable-shared && \
make -j"$(nproc)" && \
make install && \
# Enable PDF/PS/EPS in policy
sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /usr/local/etc/ImageMagick-7/policy.xml && \
sed -i 's/rights="none" pattern="PS"/rights="read|write" pattern="PS"/' /usr/local/etc/ImageMagick-7/policy.xml && \
sed -i 's/rights="none" pattern="EPS"/rights="read|write" pattern="EPS"/' /usr/local/etc/ImageMagick-7/policy.xml && \
cd .. && \
ldconfig /usr/local/lib
# Final runtime image.
FROM eclipse-temurin:25-jre-noble AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
ARG UNOSERVER_VERSION=3.6
RUN set -eux; \
apt-get update; \
# Add LibreOffice Fresh PPA for latest version (26.2.x)
apt-get install -y --no-install-recommends software-properties-common; \
add-apt-repository -y ppa:libreoffice/ppa; \
apt-get update; \
apt-get install -y --no-install-recommends \
# Core tools
ca-certificates tzdata tini bash fontconfig curl \
ffmpeg poppler-utils fontforge \
gosu unpaper \
# Fonts: full CJK coverage retained
fonts-dejavu \
fonts-liberation2 \
fonts-crosextra-caladea fonts-crosextra-carlito \
fonts-noto-core fonts-noto-mono fonts-noto-extra \
fonts-noto-cjk poppler-data \
# We install these via apt to avoid downloading "fat wheels" from pip
# python3-full replaced with minimal set
python3 python3-dev python3-venv python3-uno \
# Python dependencies via pip to avoid conflicts, so we don't install them here
# OCR
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
# Tesseract OSD for orientation detection
tesseract-ocr-osd \
# Graphics / AWT headless
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 \
libxtst6 libxi6 libxinerama1 libxkbcommon0 libsm6 libice6 \
# Qt/EGL for Calibre CLI
libegl1 libgl1 libopengl0 libdbus-1-3 libglib2.0-0 libnss3 \
libasound2t64 libxcomposite1 libxrandr2 \
# Virtual framebuffer (required for headless LibreOffice Impress/Draw)
xvfb x11-utils coreutils \
libreoffice-writer-nogui libreoffice-calc-nogui \
libreoffice-impress-nogui libreoffice-draw-nogui \
libreoffice-java-common \
; \
\
\
# Note: We do NOT install numpy/pillow/cv2 here; it uses the system versions
python3 -m venv /opt/venv --system-site-packages; \
/opt/venv/bin/pip install --no-cache-dir \
weasyprint pdf2image opencv-python-headless ocrmypdf \
"unoserver==${UNOSERVER_VERSION}"; \
\
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
\
# Verify and fix LibreOffice
libreoffice --version; \
soffice --version 2>/dev/null || true; \
# Rebuild UNO bridge type database
/usr/lib/libreoffice/program/soffice.bin --headless --convert-to pdf /dev/null 2>/dev/null || true; \
# Force font cache rebuild and verify filters are available
fc-cache -f -v 2>&1 | awk 'NR <= 20'; \
/opt/venv/bin/python -c "import cv2; print('OpenCV', cv2.__version__)"; \
/opt/venv/bin/python -c "import ocrmypdf; print('ocrmypdf OK')"; \
\
# Cleanup stage.
\
# apt clean needed without cache mounts
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
\
# Docs / man / info / icons / themes / GUI assets (headless server)
rm -rf /usr/share/doc/* /usr/share/man/* /usr/share/info/* \
/usr/share/lintian/* /usr/share/linda/* \
/usr/share/icons/* /usr/share/themes/* \
/usr/share/javascript/* \
/usr/share/gtk-3.0/* \
/usr/share/fontforge/pixmaps \
/usr/share/liblangtag/* \
/usr/share/tcltk/* \
/usr/share/python-wheels/*; \
\
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
! -name 'en*' -exec rm -rf {} + 2>/dev/null || true; \
rm -rf /usr/share/i18n/locales /usr/share/i18n/charmaps; \
\
# LibreOffice extras: Only remove specific directories as requested
rm -rf /usr/lib/libreoffice/share/gallery \
/usr/lib/libreoffice/share/template \
/usr/lib/libreoffice/share/wizards \
/usr/lib/libreoffice/share/autotext \
/usr/lib/libreoffice/help \
/usr/lib/libreoffice/share/config/images_*.zip \
/usr/lib/libreoffice/share/basic \
/usr/lib/libreoffice/share/Scripts \
/usr/lib/libreoffice/share/autocorr \
/usr/lib/libreoffice/share/classification \
/usr/lib/libreoffice/share/wordbook \
/usr/lib/libreoffice/share/fingerprint \
/usr/lib/libreoffice/share/xdg \
/usr/lib/libreoffice/share/numbertext \
/usr/lib/libreoffice/share/shell \
/usr/lib/libreoffice/share/palette \
/usr/lib/libreoffice/share/theme_definitions \
/usr/lib/libreoffice/share/xslt \
/usr/lib/libreoffice/share/labels \
/usr/lib/libreoffice/share/dtd \
/usr/lib/libreoffice/share/tipoftheday \
/usr/lib/libreoffice/share/toolbarmode \
/usr/lib/libreoffice/share/psprint; \
\
# Preserving soffice.cfg because LibreOffice needs it to load documents.
\
\
# Python caches + pip/setuptools cleanup
find /opt/venv -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /opt/venv \
\( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true; \
rm -rf /opt/venv/lib/python*/site-packages/pip \
/opt/venv/lib/python*/site-packages/pip-*.dist-info \
/opt/venv/lib/python*/site-packages/setuptools \
/opt/venv/lib/python*/site-packages/setuptools-*.dist-info; \
\
# Python stdlib: remove unused modules (~71 MB)
rm -rf /usr/lib/python3.12/test \
/usr/lib/python3.12/idlelib \
/usr/lib/python3.12/tkinter \
/usr/lib/python3.12/lib2to3 \
/usr/lib/python3.12/pydoc_data; \
\
# System Python packages not needed at runtime (~153 MB)
rm -rf /usr/lib/python3/dist-packages/scipy \
/usr/lib/python3/dist-packages/sympy \
/usr/lib/python3/dist-packages/mpmath; \
\
# Remove system cffi superseded by venv cffi 2.0
rm -rf \
/usr/lib/python3/dist-packages/cffi \
/usr/lib/python3/dist-packages/cffi-*.dist-info \
/usr/lib/python3/dist-packages/_cffi_backend*.so \
/usr/lib/python3/dist-packages/_cffi_backend*.cpython*.so \
2>/dev/null || true; \
# Verify cffi is still importable from the venv after system package removal
/opt/venv/bin/python -c "import cffi; print('cffi OK:', cffi.__version__)" \
|| { echo 'ERROR: cffi broken after system package cleanup'; exit 1; }; \
\
# Strip debug symbols from ALL shared libraries
find /usr/lib -name '*.so*' -type f \
-not -path '*/jvm/*' \
-not -path '*/libreoffice/*' \
-exec strip --strip-unneeded {} + 2>/dev/null || true; \
\
# Preserving ffmpeg codec libs as they are directly linked.
\
# Remove GPU backends not needed for headless operation.
MULTIARCH_LIBDIR=$(dpkg-architecture -qDEB_HOST_MULTIARCH 2>/dev/null \
|| find /usr/lib -maxdepth 1 -type d -name '*-linux-gnu' | head -1); \
rm -f \
"${MULTIARCH_LIBDIR}"/libLLVM*.so* \
"${MULTIARCH_LIBDIR}"/libgallium*.so* \
2>/dev/null || true; \
\
# System-wide Python cache cleanup
find /usr/lib/python3* -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /usr/lib/python3* \( -name '*.pyc' -o -name '*.pyi' \) \
-delete 2>/dev/null || true; \
\
# Additional metadata cleanup
# FIX: Only remove ImageMagick doc/www, NOT the whole dir (preserves policy.xml/delegates.xml)
rm -rf /usr/share/bug /usr/share/lintian /usr/share/linda \
/var/lib/dpkg/info/*.md5sums \
/var/log/dpkg.log /var/log/apt/* \
/usr/local/share/ghostscript/*/doc \
/usr/local/share/ghostscript/*/examples \
/usr/share/ImageMagick-*/doc \
/usr/share/ImageMagick-*/www; \
\
\
# NEW: Tesseract training configs (not needed for OCR, but keep configs/ for hocr/txt output)
rm -rf /usr/share/tesseract-ocr/*/tessdata/tessconfigs; \
\
# Trim CJK fonts to Regular weight only (FIX: Broadened path)
find /usr/share/fonts -name '*CJK*' \
! -name '*Regular*' -type f -delete 2>/dev/null || true; \
\
# Misc caches
rm -rf /var/cache/fontconfig/* /tmp/*
# Calibre and QPDF tools.
COPY --from=calibre-build /opt/calibre /opt/calibre
COPY --from=pdf-tools-build /usr/local/bin/qpdf /usr/bin/qpdf
COPY --from=pdf-tools-build /usr/local/bin/magick /usr/bin/magick
COPY --from=pdf-tools-build /usr/local/lib/libMagick* /usr/local/lib/
# Copy loadable coder/filter modules (required when built with --with-modules)
COPY --from=pdf-tools-build /usr/local/lib/ImageMagick-7* /usr/local/lib/
COPY --from=pdf-tools-build /usr/local/etc/ImageMagick-7 /usr/local/etc/ImageMagick-7
COPY --from=gs-build /usr/local/bin/gs /usr/local/bin/gs
COPY --from=gs-build /usr/local/share/ghostscript /usr/local/share/ghostscript
RUN ldconfig /usr/local/lib
# ---
# Non-root user
# ---
ARG PUID=1000 ARG PUID=1000
ARG PGID=1000 ARG PGID=1000
RUN set -eux; \ RUN set -eux; \
# Create group if it doesn't exist
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \ if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
if getent group "${PGID}" >/dev/null 2>&1; then \ groupadd -g "${PGID}" stirlingpdfgroup 2>/dev/null \
groupadd -o -g "${PGID}" stirlingpdfgroup; \ || groupadd stirlingpdfgroup; \
else \
groupadd -g "${PGID}" stirlingpdfgroup; \
fi; \ fi; \
fi; \
# Create user if it doesn't exist, avoid UID conflicts
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \ if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \ useradd -m -u "${PUID}" -g stirlingpdfgroup \
echo "UID ${PUID} already in use creating stirlingpdfuser with automatic UID"; \ -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser 2>/dev/null \
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \ || useradd -m -g stirlingpdfgroup \
else \ -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
fi; \ fi; \
fi ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
# Compatibility alias for older entrypoint scripts expecting su-exec # Application files.
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec WORKDIR /app
COPY --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
COPY --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
COPY --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
COPY --from=jar-extract --chown=1000:1000 /layers/application/ /app/
# Copy application files from build stage COPY --from=app-build --chown=1000:1000 \
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/app/core/build/libs/*.jar /app.jar /app/build/libs/restart-helper.jar /restart-helper.jar
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/build/libs/restart-helper.jar /restart-helper.jar COPY --chown=1000:1000 scripts/ /scripts/
COPY scripts/ /scripts/
# Fonts go to system dir — root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/ COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
# Optional version tag (can be passed at build time) # Permissions and configuration.
RUN set -eux; \
ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert; \
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
ln -sf /opt/venv/bin/ocrmypdf /usr/local/bin/ocrmypdf; \
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
chmod +x /scripts/*; \
mkdir -p /configs /logs /customFiles \
/pipeline/watchedFolders /pipeline/finishedFolders \
/tmp/stirling-pdf/heap_dumps; \
# Create symlinks to allow app to find these in /app/
ln -s /logs /app/logs; \
ln -s /configs /app/configs; \
ln -s /customFiles /app/customFiles; \
ln -s /pipeline /app/pipeline; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline \
/tmp/stirling-pdf; \
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \
chown stirlingpdfuser:stirlingpdfgroup /app; \
chmod 750 /tmp/stirling-pdf; \
chmod 750 /tmp/stirling-pdf/heap_dumps; \
fc-cache -f
# NOTE: Project Leyden AOT cache is generated in the background on first boot
# by init-without-ocr.sh. The cache is picked up on subsequent boots for
# 15-25% faster startup. See: JEP 483 + 514 + 515 (JDK 25).
# Environment variables.
ARG VERSION_TAG ARG VERSION_TAG
# Metadata labels
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Full version with Calibre, LibreOffice, Tesseract, OCRmyPDF, and more"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, API, Spring Boot, React"
# ==============================================================================
# Runtime environment variables
# ==============================================================================
ENV VERSION_TAG=$VERSION_TAG \ ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 -Djava.awt.headless=true" \ STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \ JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \
PUID=${PUID} \ PUID=${PUID} \
PGID=${PGID} \ PGID=${PGID} \
UMASK=022 \ UMASK=022 \
PATH="/opt/venv/bin:${PATH}" \
UNO_PATH=/usr/lib/libreoffice/program \ UNO_PATH=/usr/lib/libreoffice/program \
LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin \ LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \ TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \ TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf TMP=/tmp/stirling-pdf \
QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu --disable-software-rasterizer" \
DBUS_SESSION_BUS_ADDRESS=/dev/null
# ============================================================================== # Metadata labels.
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.) LABEL org.opencontainers.image.title="Stirling-PDF" \
# ============================================================================== org.opencontainers.image.description="Full version with Calibre, LibreOffice, Tesseract, OCRmyPDF" \
RUN python3 -m venv /opt/venv --system-site-packages \ org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \ org.opencontainers.image.licenses="MIT" \
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)" org.opencontainers.image.vendor="Stirling-Tools" \
org.opencontainers.image.url="https://www.stirlingpdf.com" \
org.opencontainers.image.documentation="https://docs.stirlingpdf.com" \
maintainer="Stirling-Tools" \
org.opencontainers.image.authors="Stirling-Tools" \
org.opencontainers.image.version="${VERSION_TAG}" \
org.opencontainers.image.keywords="PDF, manipulation, API, Spring Boot, React"
# Separate venv for unoserver (keeps it isolated)
ARG UNOSERVER_VERSION=3.6
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
&& /opt/unoserver-venv/bin/pip install --no-cache-dir "unoserver==${UNOSERVER_VERSION}"
# Make unoserver tools available in main venv PATH
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
# Extend PATH to include both virtual environments
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
# ==============================================================================
# Final permissions, directories and font cache
# ==============================================================================
RUN set -eux; \
chmod +x /scripts/*; \
mkdir -p /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
/app.jar /restart-helper.jar /usr/share/fonts/truetype /scripts; \
chmod -R 755 /tmp/stirling-pdf
# Rebuild font cache
RUN fc-cache -f -v
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
ENV QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
# Expose web UI port
EXPOSE 8080/tcp EXPOSE 8080/tcp
STOPSIGNAL SIGTERM STOPSIGNAL SIGTERM
# Use tini as init (handles signals and zombies correctly) HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
ENTRYPOINT ["tini", "--", "/scripts/init.sh"] CMD curl -fs --show-error http://localhost:8080/api/v1/info/status || exit 1
# CMD is empty actual start command is defined in init.sh ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD [] CMD []
+558 -176
View File
@@ -1,158 +1,563 @@
# Stirling-PDF Dockerfile - Fat version with embedded frontend # Stirling-PDF - Fat version (embedded frontend)
# Single JAR contains both frontend and backend with extra fonts for air-gapped environments # Extra fonts for air-gapped environments
# Stage 1: Build application with embedded frontend FROM ubuntu:noble AS calibre-build
FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build
# Install Node.js and npm for frontend build ARG CALIBRE_VERSION=9.3.1
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools + extra fonts for air-gapped environments
FROM debian:stable-slim@sha256:ed542b2d269ff08139fc5ab8c762efe8c8986b564a423d5241a5ce9fb09b6c08
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8
ENV TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
# Install core runtime dependencies + tools required by Stirling-PDF features + extra fonts for fat version
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata tini bash fontconfig \
openjdk-21-jre-headless \
ffmpeg poppler-utils ocrmypdf imagemagick fontforge ghostscript \
fonts-dejavu \
fonts-liberation fonts-liberation2 \
fonts-crosextra-caladea fonts-crosextra-carlito \
fonts-linuxlibertine \
fonts-noto-core fonts-noto-cjk fonts-noto-mono fonts-noto-ui-core \
fonts-noto-color-emoji \
ttf-wqy-zenhei \
fonts-arphic-ukai fonts-arphic-uming \
fonts-freefont-ttf fonts-terminus \
python3 python3-venv python3-uno \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
gosu unpaper qpdf \
# AWT headless support (required for some Java graphics operations)
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
# Qt WebEngine dependencies for Calibre
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
# Virtual framebuffer (required for headless LibreOffice)
xvfb x11-utils coreutils \
# Temporary packages only needed for Calibre installer
xz-utils gpgv curl xdg-utils \
\
# Install Calibre from official installer script
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
\
# Clean up installer-only packages
&& apt-get purge -y xz-utils gpgv xdg-utils \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \ RUN set -eux; \
. /etc/os-release; \
echo "deb http://deb.debian.org/debian ${VERSION_CODENAME}-backports main" > /etc/apt/sources.list.d/backports.list; \
apt-get update; \ apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -t ${VERSION_CODENAME}-backports \ apt-get install -y --no-install-recommends \
libreoffice libreoffice-java-common; \ ca-certificates curl xz-utils libnss3 libfontconfig1 \
libgl1 libegl1 libdbus-1-3 libasound2t64 libxcomposite1 \
libxrandr2 libxkbcommon0 libxi6 libxtst6 libopengl0; \
rm -rf /var/lib/apt/lists/*; \ rm -rf /var/lib/apt/lists/*; \
libreoffice --version \
case "$(uname -m)" in \
x86_64) CALIBRE_ARCH="x86_64" ;; \
aarch64) CALIBRE_ARCH="arm64" ;; \
*) echo "Unsupported arch: $(uname -m)"; exit 1 ;; \
esac; \
\
curl -fsSL \
"https://download.calibre-ebook.com/${CALIBRE_VERSION}/calibre-${CALIBRE_VERSION}-${CALIBRE_ARCH}.txz" \
-o /tmp/calibre.txz; \
mkdir -p /opt/calibre; \
tar xJf /tmp/calibre.txz -C /opt/calibre; \
rm /tmp/calibre.txz; \
\
# Remove GUI-only shared libraries.
# Libs required by WebEngine PDF output are preserved.
rm -f /opt/calibre/lib/libQt6Designer* \
/opt/calibre/lib/libQt6Multimedia* \
/opt/calibre/lib/libQt6SpatialAudio.so.* \
/opt/calibre/lib/libQt6NetworkAuth.so.* \
/opt/calibre/lib/libQt6Concurrent.so.* \
/opt/calibre/lib/libQt6OpenGLWidgets.so.* \
/opt/calibre/lib/libQt6QuickWidgets.so.* \
# AV / multimedia
/opt/calibre/lib/libavcodec.so.* \
/opt/calibre/lib/libavfilter.so.* \
/opt/calibre/lib/libavformat.so.* \
/opt/calibre/lib/libavutil.so.* \
/opt/calibre/lib/libavdevice.so.* \
/opt/calibre/lib/libpostproc.so.* \
/opt/calibre/lib/libswresample.so.* \
/opt/calibre/lib/libswscale.so.* \
# Audio / speech / TTS
/opt/calibre/lib/libspeex.so.* \
/opt/calibre/lib/libFLAC.so.* \
/opt/calibre/lib/libopus.so.* \
/opt/calibre/lib/libvorbis*.so.* \
/opt/calibre/lib/libasyncns.so.* \
/opt/calibre/lib/libspeechd.so.* \
/opt/calibre/lib/libespeak-ng.so.* \
# Other unused libs
/opt/calibre/lib/libonnxruntime.so.* \
/opt/calibre/lib/libgio-2.0.so.* \
/opt/calibre/lib/libzstd.so.* \
/opt/calibre/lib/libhunspell-1.7.so.* \
/opt/calibre/lib/libbrotlienc.so.* \
/opt/calibre/lib/libbrotlicommon.so.* \
/opt/calibre/lib/libbrotlidec.so.* \
/opt/calibre/lib/libstemmer.so.* \
/opt/calibre/lib/libmtp.so.* \
/opt/calibre/lib/libncursesw.so.* \
/opt/calibre/lib/libchm.so.* \
/opt/calibre/lib/libgcrypt.so.* \
/opt/calibre/lib/libgpg-error.so.* \
/opt/calibre/lib/libicuio.so.* \
/opt/calibre/lib/libreadline.so.* \
/opt/calibre/lib/libusb-1.0.so.*; \
rm -rf /opt/calibre/lib/qt6/plugins/platformthemes \
/opt/calibre/lib/qt6/plugins/multimedia \
/opt/calibre/lib/qt6/plugins/designer \
/opt/calibre/lib/qt6/plugins/qmltooling; \
\
# Remove GUI executables but keep ebook-convert, ebook-meta, and calibre-parallel.
rm -f /opt/calibre/calibre \
/opt/calibre/calibre-server \
/opt/calibre/calibre-smtp \
/opt/calibre/calibre-debug \
/opt/calibre/calibre-customize \
/opt/calibre/calibredb \
/opt/calibre/ebook-viewer \
/opt/calibre/ebook-edit \
/opt/calibre/ebook-polish \
/opt/calibre/ebook-device \
/opt/calibre/fetch-ebook-metadata \
/opt/calibre/lrf2lrs \
/opt/calibre/lrs2lrf \
/opt/calibre/markdown-calibre \
/opt/calibre/web2disk; \
\
# Remove Python modules not needed for conversion.
rm -rf /opt/calibre/lib/calibre/gui2 \
/opt/calibre/lib/calibre/devices \
/opt/calibre/lib/calibre/library \
/opt/calibre/lib/calibre/db \
/opt/calibre/lib/calibre/srv \
/opt/calibre/lib/calibre/spell \
/opt/calibre/lib/calibre/live; \
\
# Remove resources not needed for CLI conversion.
rm -rf /opt/calibre/resources/images \
/opt/calibre/resources/icons \
/opt/calibre/resources/icons.rcc \
/opt/calibre/resources/content-server \
/opt/calibre/resources/editor* \
/opt/calibre/resources/viewer \
/opt/calibre/resources/viewer.js \
/opt/calibre/resources/viewer.html \
/opt/calibre/resources/recipes \
/opt/calibre/resources/dictionaries \
/opt/calibre/resources/hyphenation \
/opt/calibre/resources/catalog \
/opt/calibre/resources/calibre-mimetypes.xml \
/opt/calibre/resources/changelog.json \
/opt/calibre/resources/user-agent-data.json \
/opt/calibre/resources/builtin_recipes.zip \
/opt/calibre/resources/builtin_recipes.xml \
/opt/calibre/resources/builtin_recipes.xml \
/opt/calibre/resources/stylelint-bundle.min.js \
/opt/calibre/resources/stylelint.js \
/opt/calibre/resources/rapydscript \
/opt/calibre/resources/quick_start \
/opt/calibre/resources/piper-voices.json \
/opt/calibre/resources/images.qrc \
/opt/calibre/resources/mozilla-ca-certs.pem \
/opt/calibre/resources/ebook-convert-complete.calibre_msgpack \
/opt/calibre/resources/mathjax \
/opt/calibre/resources/common-english-words.txt \
/opt/calibre/resources/calibre-portable.sh \
/opt/calibre/resources/calibre-portable.bat \
/opt/calibre/resources/metadata_sqlite.sql \
/opt/calibre/resources/notes_sqlite.sql \
/opt/calibre/resources/fts_sqlite.sql \
/opt/calibre/resources/fts_triggers.sql \
/opt/calibre/resources/jacket \
/opt/calibre/resources/editor-functions.json \
/opt/calibre/resources/calibre-ebook-root-CA.crt \
/opt/calibre/resources/csscolorparser.js \
/opt/calibre/resources/lookup.js \
/opt/calibre/resources/pdf-mathjax-loader.js \
/opt/calibre/resources/scraper.js \
/opt/calibre/resources/toc.js \
/opt/calibre/resources/user-manual-translation-stats.json \
/opt/calibre/resources/pin-template.svg \
/opt/calibre/resources/scripts.calibre_msgpack \
/opt/calibre/lib/calibre/ebooks/docx/images \
/opt/calibre/share \
/opt/calibre/man; \
\
# Remove translations and localization while keeping required libraries.
rm -rf /opt/calibre/lib/qt6/translations; \
find /opt/calibre/translations -mindepth 1 -maxdepth 1 ! -name 'qtwebengine_locales' -exec rm -rf {} +; \
rm -rf /opt/calibre/resources/localization/locales.zip \
/opt/calibre/resources/localization/stats.calibre_msgpack \
/opt/calibre/resources/localization/website-languages.txt; \
find /opt/calibre/resources/localization -mindepth 1 -maxdepth 1 ! -name 'iso639.calibre_msgpack' -exec rm -rf {} +; \
\
# Strip debug symbols from calibre extension modules.
# Exclude Qt6 libs and all qt6/ subdirectory files to prevent Chromium renderer crashes.
find /opt/calibre/lib -name '*.so*' \
! -name 'libQt6*' \
! -path '*/qt6/*' \
-exec strip --strip-unneeded {} + 2>/dev/null || true; \
\
find /opt/calibre -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /opt/calibre -name '*.pyc' -delete 2>/dev/null || true; \
\
# Verify conversion functionality.
# NOTE: txt→epub used intentionally NOT txt→pdf.
# Calibre 7+ uses WebEngine (Chromium) for PDF output, which requires kernel
# capabilities unavailable in Docker RUN steps and segfaults under QEMU.
# epub output exercises the same Python/plugin stack without touching WebEngine.
/opt/calibre/ebook-convert --version; \
echo "Hello" > /tmp/test.txt; \
/opt/calibre/ebook-convert /tmp/test.txt /tmp/test.epub; \
rm -f /tmp/test.txt /tmp/test.epub; \
echo "=== Calibre stripped successfully ==="
# Make ebook-convert available in PATH
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
&& /opt/calibre/ebook-convert --version
# ============================================================================== # Build the Java application and frontend.
# Create non-root user (stirlingpdfuser) with configurable UID/GID FROM gradle:9.3.1-jdk25 AS app-build
# ==============================================================================
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& update-ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
WORKDIR /app
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
# Use system gradle instead of gradlew to avoid SSL issues downloading gradle distribution on emulated arm64
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
gradle dependencies --no-daemon || true
COPY . .
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
DISABLE_ADDITIONAL_FEATURES=false \
gradle clean build \
-PbuildWithFrontend=true \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
# Python Builder stage.
FROM ubuntu:noble AS python-build
ARG UNOSERVER_VERSION=3.6
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-venv python3-dev \
python3-packaging \
build-essential \
# Build dependencies for ocrmypdf/weasyprint/opencv
zlib1g-dev libjpeg-dev libffi-dev libpango1.0-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/venv --system-site-packages
ENV PATH="/opt/venv/bin:$PATH"
# Build all heavy python packages here
RUN --mount=type=cache,target=/root/.cache/pip \
pip install \
weasyprint pdf2image opencv-python-headless ocrmypdf \
"unoserver==${UNOSERVER_VERSION}"
# Build Ghostscript 10.06.0 from source in an isolated stage (avoids library conflicts).
FROM ubuntu:noble AS gs-build
ARG GS_VERSION=10.06.0
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl ca-certificates libfontconfig1-dev && \
rm -rf /var/lib/apt/lists/* && \
GS_TAG="gs$(printf '%s' "${GS_VERSION}" | tr -d '.')" && \
curl -fsSL "https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/${GS_TAG}/ghostscript-${GS_VERSION}.tar.gz" | tar xz && \
cd "ghostscript-${GS_VERSION}" && \
./configure \
--prefix=/usr/local \
--without-x \
--disable-cups \
--disable-gtk && \
make -j"$(nproc)" && \
make install && \
cd ..
# Build PDF Tools (QPDF and ImageMagick 7).
FROM ubuntu:noble AS pdf-tools-build
ARG QPDF_VERSION=12.3.2
ARG IM_VERSION=7.1.2-13
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake libssl-dev libjpeg-dev zlib1g-dev curl ca-certificates pkg-config \
libpng-dev libtiff-dev libwebp-dev libxml2-dev libfreetype6-dev liblcms2-dev libzip-dev liblqr-1-0-dev \
libltdl-dev libtool && \
# Build QPDF
curl -fsSL "https://github.com/qpdf/qpdf/releases/download/v${QPDF_VERSION}/qpdf-${QPDF_VERSION}.tar.gz" | tar xz && \
cd "qpdf-${QPDF_VERSION}" && \
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DALLOW_CRYPTO_OPENSSL=ON -DDEFAULT_CRYPTO=openssl && \
cmake --build build --parallel "$(nproc)" && \
cmake --install build && \
cd .. && \
# Build ImageMagick 7
curl -fsSL "https://github.com/ImageMagick/ImageMagick/archive/refs/tags/${IM_VERSION}.tar.gz" | tar xz && \
cd "ImageMagick-${IM_VERSION}" && \
./configure --prefix=/usr/local --with-modules --with-perl=no --with-magick-plus-plus=no --with-quantum-depth=16 --disable-static --enable-shared && \
make -j"$(nproc)" && \
make install && \
# Enable PDF/PS/EPS in policy
sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /usr/local/etc/ImageMagick-7/policy.xml && \
sed -i 's/rights="none" pattern="PS"/rights="read|write" pattern="PS"/' /usr/local/etc/ImageMagick-7/policy.xml && \
sed -i 's/rights="none" pattern="EPS"/rights="read|write" pattern="EPS"/' /usr/local/etc/ImageMagick-7/policy.xml && \
cd .. && \
ldconfig /usr/local/lib
# Final runtime image.
FROM eclipse-temurin:25-jre AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
ARG UNOSERVER_VERSION=3.6
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=cache,target=/root/.cache/pip \
set -eux; \
apt-get update; \
# Add LibreOffice Fresh PPA for latest version (26.2.x)
apt-get install -y --no-install-recommends software-properties-common; \
add-apt-repository -y ppa:libreoffice/ppa; \
apt-get update; \
apt-get install -y --no-install-recommends \
# Core tools
ca-certificates tzdata tini bash fontconfig curl \
ffmpeg poppler-utils fontforge \
gosu unpaper pngquant \
fonts-liberation2 \
fonts-crosextra-caladea fonts-crosextra-carlito \
fonts-noto-core fonts-noto-mono fonts-noto-extra \
fonts-noto-cjk poppler-data \
fonts-freefont-ttf fonts-terminus \
# Python runtime & UNO bridge (python3-full -> python3 optimization)
python3 python3-uno python3-packaging \
# OCR
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
# Graphics / AWT headless
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 \
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 \
libxtst6 libxi6 libxinerama1 libxkbcommon0 libsm6 libice6 \
# Qt/EGL for Calibre CLI
libegl1 libgl1 libopengl0 libdbus-1-3 libglib2.0-0 libnss3 \
libasound2t64 libxcomposite1 libxrandr2 \
# Virtual framebuffer (required for headless LibreOffice Impress/Draw)
xvfb x11-utils coreutils \
libreoffice-writer-nogui libreoffice-calc-nogui \
libreoffice-impress-nogui libreoffice-draw-nogui \
libreoffice-base-nogui libreoffice-java-common \
; \
\
# Fix LibreOffice UNO bridge and filter availability
libreoffice --version; \
soffice --version 2>/dev/null || true; \
# Rebuild UNO bridge type database
/usr/lib/libreoffice/program/soffice.bin --headless --convert-to pdf /dev/null 2>/dev/null || true; \
# Force font cache rebuild and verify filters are available
fc-cache -f -v 2>&1 | awk 'NR <= 20'; \
\
# Cleanup stage.
\
# apt clean not strictly needed with cache mounts, but good for reducing layer metadata
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
\
# Docs / man / info / icons / themes / GUI assets (headless server)
rm -rf /usr/share/doc/* /usr/share/man/* /usr/share/info/* \
/usr/share/lintian/* /usr/share/linda/* \
/usr/share/icons/* /usr/share/themes/* \
/usr/share/javascript/* \
/usr/share/gtk-3.0/* \
/usr/share/fontforge/pixmaps \
/usr/share/liblangtag/* \
/usr/share/tcltk/* \
/usr/share/python-wheels/*; \
\
# Clean up system locale data (LANG=C.UTF-8 doesn't use them)
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
! -name 'en*' -exec rm -rf {} + 2>/dev/null || true; \
rm -rf /usr/share/i18n/locales /usr/share/i18n/charmaps; \
\
rm -rf /usr/lib/libreoffice/share/gallery \
/usr/lib/libreoffice/share/template \
/usr/lib/libreoffice/share/wizards \
/usr/lib/libreoffice/share/autotext \
/usr/lib/libreoffice/help \
/usr/lib/libreoffice/share/config/images_*.zip \
/usr/lib/libreoffice/share/basic \
/usr/lib/libreoffice/share/Scripts \
/usr/lib/libreoffice/share/autocorr \
/usr/lib/libreoffice/share/classification \
/usr/lib/libreoffice/share/wordbook \
/usr/lib/libreoffice/share/fingerprint \
/usr/lib/libreoffice/share/xdg \
/usr/lib/libreoffice/share/numbertext \
/usr/lib/libreoffice/share/shell \
/usr/lib/libreoffice/share/palette \
/usr/lib/libreoffice/share/theme_definitions \
/usr/lib/libreoffice/share/xslt \
/usr/lib/libreoffice/share/labels \
/usr/lib/libreoffice/share/dtd \
/usr/lib/libreoffice/share/tipoftheday \
/usr/lib/libreoffice/share/toolbarmode \
/usr/lib/libreoffice/share/psprint; \
\
# Preserving soffice.cfg because LibreOffice needs it to load documents.
\
\
\
find /usr/lib -name '*.so*' -type f \
-not -path '*/jvm/*' \
-not -path '*/libreoffice/*' \
-exec strip --strip-unneeded {} + 2>/dev/null || true; \
\
# Preserving ffmpeg codec libs as they are directly linked.
\
# Remove Mesa/LLVM GPU backends (~179 MB, not needed for headless/offscreen)
MULTIARCH_LIBDIR=$(dpkg-architecture -qDEB_HOST_MULTIARCH 2>/dev/null \
|| find /usr/lib -maxdepth 1 -type d -name '*-linux-gnu' | head -1); \
rm -f \
"${MULTIARCH_LIBDIR}"/libLLVM*.so* \
"${MULTIARCH_LIBDIR}"/libgallium*.so* \
2>/dev/null || true; \
\
# Python stdlib: remove unused modules (~71 MB)
rm -rf /usr/lib/python3.12/test \
/usr/lib/python3.12/idlelib \
/usr/lib/python3.12/tkinter \
/usr/lib/python3.12/lib2to3 \
/usr/lib/python3.12/pydoc_data; \
\
# System Python packages not needed at runtime (~153 MB)
rm -rf /usr/lib/python3/dist-packages/scipy \
/usr/lib/python3/dist-packages/sympy \
/usr/lib/python3/dist-packages/mpmath; \
\
# Duplicate system packages (superseded by venv versions, ~55 MB)
rm -rf /usr/lib/python3/dist-packages/numpy \
/usr/lib/python3/dist-packages/fontTools \
/usr/lib/python3/dist-packages/PIL; \
\
# System-wide Python cache cleanup
find /usr/lib/python3* -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /usr/lib/python3* \( -name '*.pyc' -o -name '*.pyi' \) \
-delete 2>/dev/null || true; \
\
# Additional metadata cleanup
# FIX: Only remove ImageMagick doc/www, NOT the whole dir (preserves policy.xml/delegates.xml)
rm -rf /usr/share/bug /usr/share/lintian /usr/share/linda \
/var/lib/dpkg/info/*.md5sums \
/var/log/dpkg.log /var/log/apt/* \
/usr/local/share/ghostscript/*/doc \
/usr/local/share/ghostscript/*/examples \
/usr/share/ImageMagick-*/doc \
/usr/share/ImageMagick-*/www; \
\
\
# NEW: Tesseract training configs (not needed for OCR, but keep configs/ for hocr/txt output)
rm -rf /usr/share/tesseract-ocr/*/tessdata/tessconfigs; \
\
# Trim CJK fonts to Regular weight only (FIX: Broadened path)
find /usr/share/fonts -name '*CJK*' \
! -name '*Regular*' -type f -delete 2>/dev/null || true; \
\
# Misc caches
rm -rf /var/cache/fontconfig/* /tmp/*
# Python virtual environment.
COPY --from=python-build /opt/venv /opt/venv
RUN set -eux; \
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
# Verify python libs are accessible
/opt/venv/bin/python -c "import cv2; import ocrmypdf; import weasyprint; print('Python libs verified')"; \
# Cleanup venv from builder leftovers
find /opt/venv -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /opt/venv \( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true; \
rm -rf /opt/venv/lib/python*/site-packages/pip \
/opt/venv/lib/python*/site-packages/pip-*.dist-info \
/opt/venv/lib/python*/site-packages/setuptools \
/opt/venv/lib/python*/site-packages/setuptools-*.dist-info;
# Calibre and PDF Tools.
COPY --link --from=calibre-build /opt/calibre /opt/calibre
COPY --link --from=pdf-tools-build /usr/local/bin/qpdf /usr/bin/qpdf
COPY --link --from=pdf-tools-build /usr/local/bin/magick /usr/bin/magick
COPY --link --from=pdf-tools-build /usr/local/lib/libMagick* /usr/local/lib/
COPY --link --from=pdf-tools-build /usr/local/etc/ImageMagick-7 /usr/local/etc/ImageMagick-7
COPY --link --from=gs-build /usr/local/bin/gs /usr/local/bin/gs
COPY --link --from=gs-build /usr/local/share/ghostscript /usr/local/share/ghostscript
RUN set -eux; \
ldconfig /usr/local/lib; \
# Clean pycache that may have been generated during stage-1 verify
find /opt/calibre -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true;
# Non-root user.
ARG PUID=1000 ARG PUID=1000
ARG PGID=1000 ARG PGID=1000
RUN set -eux; \ RUN set -eux; \
# Create group if it doesn't exist
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \ if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
if getent group "${PGID}" >/dev/null 2>&1; then \ groupadd -g "${PGID}" stirlingpdfgroup 2>/dev/null \
groupadd -o -g "${PGID}" stirlingpdfgroup; \ || groupadd stirlingpdfgroup; \
else \
groupadd -g "${PGID}" stirlingpdfgroup; \
fi; \ fi; \
fi; \
# Create user if it doesn't exist, avoid UID conflicts
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \ if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \ useradd -m -u "${PUID}" -g stirlingpdfgroup \
echo "UID ${PUID} already in use creating stirlingpdfuser with automatic UID"; \ -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser 2>/dev/null \
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \ || useradd -m -g stirlingpdfgroup \
else \ -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
fi; \ fi; \
fi ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
# Compatibility alias for older entrypoint scripts expecting su-exec # Application files.
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec COPY --link --from=app-build --chown=1000:1000 \
/app/app/core/build/libs/*.jar /app.jar
COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --link --chown=1000:1000 scripts/ /scripts/
# Copy application files from build stage # Fonts go to system dir — root ownership is correct (world-readable)
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/app/core/build/libs/*.jar /app.jar COPY --link app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/build/libs/restart-helper.jar /restart-helper.jar
COPY scripts/ /scripts/
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
# Optional version tag (can be passed at build time) # Permissions and configuration.
RUN set -eux; \
ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert; \
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
ln -sf /opt/venv/bin/ocrmypdf /usr/local/bin/ocrmypdf; \
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
chmod +x /scripts/*; \
mkdir -p /configs /logs /customFiles \
/pipeline/watchedFolders /pipeline/finishedFolders \
/tmp/stirling-pdf/heap_dumps; \
# Create symlinks to allow app to find these in /app/
mkdir -p /app; \
ln -s /logs /app/logs; \
ln -s /configs /app/configs; \
ln -s /customFiles /app/customFiles; \
ln -s /pipeline /app/pipeline; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline \
/tmp/stirling-pdf; \
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \
chown stirlingpdfuser:stirlingpdfgroup /app; \
chmod 1777 /tmp/stirling-pdf; \
fc-cache -f; \
# NOTE: Project Leyden AOT cache is generated in the background on first boot
# by init-without-ocr.sh. The cache is picked up on subsequent boots for
# 15-25% faster startup. See: JEP 483 + 514 + 515 (JDK 25).
\
# Clean Calibre pycache that may have been generated during stage-1 verify
find /opt/calibre -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true
# Environment variables.
ARG VERSION_TAG ARG VERSION_TAG
# Metadata labels
LABEL org.opencontainers.image.title="Stirling-PDF Fat"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Fat version with extra fonts for air-gapped environments, includes Calibre, LibreOffice, Tesseract, OCRmyPDF, and more"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, fat, air-gapped, API, Spring Boot, React"
# ==============================================================================
# Runtime environment variables
# ==============================================================================
ENV VERSION_TAG=$VERSION_TAG \ ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 -Djava.awt.headless=true" \ STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \ JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \
PUID=${PUID} \ PUID=${PUID} \
@@ -160,57 +565,34 @@ ENV VERSION_TAG=$VERSION_TAG \
UMASK=022 \ UMASK=022 \
FAT_DOCKER=true \ FAT_DOCKER=true \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \ INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
PATH="/opt/venv/bin:${PATH}" \
UNO_PATH=/usr/lib/libreoffice/program \ UNO_PATH=/usr/lib/libreoffice/program \
LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin \ LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \ TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \ TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf TMP=/tmp/stirling-pdf \
QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu --disable-software-rasterizer" \
DBUS_SESSION_BUS_ADDRESS=/dev/null
# ============================================================================== # Metadata labels.
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.) LABEL org.opencontainers.image.title="Stirling-PDF Fat" \
# ============================================================================== org.opencontainers.image.description="Fat version with extra fonts for air-gapped environments, includes Calibre, LibreOffice, Tesseract, OCRmyPDF" \
RUN python3 -m venv /opt/venv --system-site-packages \ org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \ org.opencontainers.image.licenses="MIT" \
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)" org.opencontainers.image.vendor="Stirling-Tools" \
org.opencontainers.image.url="https://www.stirlingpdf.com" \
org.opencontainers.image.documentation="https://docs.stirlingpdf.com" \
maintainer="Stirling-Tools" \
org.opencontainers.image.authors="Stirling-Tools" \
org.opencontainers.image.version="${VERSION_TAG}" \
org.opencontainers.image.keywords="PDF, manipulation, fat, air-gapped, API, Spring Boot, React"
# Separate venv for unoserver (keeps it isolated)
ARG UNOSERVER_VERSION=3.6
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
&& /opt/unoserver-venv/bin/pip install --no-cache-dir "unoserver==${UNOSERVER_VERSION}"
# Make unoserver tools available in main venv PATH
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
# Extend PATH to include both virtual environments
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
# ==============================================================================
# Final permissions, directories and font cache
# ==============================================================================
RUN set -eux; \
chmod +x /scripts/*; \
mkdir -p /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
/app.jar /restart-helper.jar /usr/share/fonts/truetype /scripts; \
chmod -R 755 /tmp/stirling-pdf
# Rebuild font cache
RUN fc-cache -f -v
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
ENV QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
# Expose web UI port
EXPOSE 8080/tcp EXPOSE 8080/tcp
STOPSIGNAL SIGTERM STOPSIGNAL SIGTERM
# Use tini as init (handles signals and zombies correctly) HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
ENTRYPOINT ["tini", "--", "/scripts/init.sh"] CMD curl -f http://localhost:8080/api/v1/info/status || exit 1
# CMD is empty actual start command is defined in init.sh ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD [] CMD []
+54 -38
View File
@@ -2,40 +2,50 @@
# Single JAR contains both frontend and backend with minimal dependencies # Single JAR contains both frontend and backend with minimal dependencies
# Stage 1: Build application with embedded frontend # Stage 1: Build application with embedded frontend
FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build FROM gradle:9.3.1-jdk25 AS build
# Install Node.js and npm for frontend build # Install Node.js and npm for frontend build
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& npm --version \ && npm --version \
&& node --version \ && node --version \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app WORKDIR /app
# Copy gradle files for dependency resolution
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
./gradlew dependencies --no-daemon || true
# Copy entire project # Copy entire project
COPY . . COPY . .
# Build ultra-lite JAR with embedded frontend (minimal features) # Build ultra-lite JAR with embedded frontend (minimal features)
RUN DISABLE_ADDITIONAL_FEATURES=true \ RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube DISABLE_ADDITIONAL_FEATURES=true \
./gradlew clean build \
-PbuildWithFrontend=true \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
# Stage 2: Runtime image # Stage 2: Runtime image
FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 FROM eclipse-temurin:25-jre-alpine
ENV LANG=C.UTF-8 \ ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 LC_ALL=C.UTF-8
@@ -43,30 +53,37 @@ ENV LANG=C.UTF-8 \
ARG VERSION_TAG ARG VERSION_TAG
# Labels # Labels
LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite" LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite" \
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Ultra-lite version with minimal dependencies" org.opencontainers.image.description="Stirling-PDF with embedded frontend - Ultra-lite version with minimal dependencies" \
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \
LABEL org.opencontainers.image.licenses="MIT" org.opencontainers.image.licenses="MIT" \
LABEL org.opencontainers.image.vendor="Stirling-Tools" org.opencontainers.image.vendor="Stirling-Tools" \
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com" org.opencontainers.image.url="https://www.stirlingpdf.com" \
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com" org.opencontainers.image.documentation="https://docs.stirlingpdf.com" \
LABEL maintainer="Stirling-Tools" maintainer="Stirling-Tools" \
LABEL org.opencontainers.image.authors="Stirling-Tools" org.opencontainers.image.authors="Stirling-Tools" \
LABEL org.opencontainers.image.version="${VERSION_TAG}" org.opencontainers.image.version="${VERSION_TAG}" \
LABEL org.opencontainers.image.keywords="PDF, manipulation, ultra-lite, API, Spring Boot, React" org.opencontainers.image.keywords="PDF, manipulation, ultra-lite, API, Spring Boot, React"
# Copy scripts # Copy scripts
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY scripts/stirling-diagnostics.sh /scripts/stirling-diagnostics.sh COPY scripts/stirling-diagnostics.sh /scripts/stirling-diagnostics.sh
# Copy built JAR from build stage # Copy built JARs from build stage
COPY --from=build /app/app/core/build/libs/*.jar /app.jar # Use numeric UID:GID (1000:1000) since the named user doesn't exist yet at COPY time
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar COPY --from=build --chown=1000:1000 \
/app/app/core/build/libs/*.jar /app.jar
COPY --from=build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
# Environment Variables # Environment Variables
# NOTE: Memory flags (InitialRAMPercentage, MaxRAMPercentage, MaxMetaspaceSize)
# are computed dynamically by init-without-ocr.sh based on container memory limits.
ENV VERSION_TAG=$VERSION_TAG \ ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \ STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \ JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \
PUID=1000 \ PUID=1000 \
@@ -79,6 +96,7 @@ ENV VERSION_TAG=$VERSION_TAG \
ENDPOINTS_GROUPS_TO_REMOVE=CLI ENDPOINTS_GROUPS_TO_REMOVE=CLI
# Install minimal dependencies # Install minimal dependencies
# --chown on COPY above removes need to chown JARs here
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
@@ -90,18 +108,16 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
bash \ bash \
curl \ curl \
shadow \ shadow \
su-exec \ su-exec && \
openjdk21-jre && \ mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
mkdir -p $HOME /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
mkdir -p /usr/share/fonts/opentype/noto && \ mkdir -p /usr/share/fonts/opentype/noto && \
chmod +x /scripts/*.sh && \ chmod +x /scripts/*.sh && \
# User permissions # User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp EXPOSE 8080/tcp
# Set user and run command # Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"] ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"] CMD []
@@ -22,6 +22,8 @@ services:
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "false" DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
# STIRLING_JVM_PROFILE: "balanced" # Options: balanced (G1GC, default), performance (Shenandoah)
# JAVA_CUSTOM_OPTS: "" # Additional JVM flags appended to the active profile
PUID: 1002 PUID: 1002
PGID: 1002 PGID: 1002
UMASK: "022" UMASK: "022"
@@ -20,6 +20,8 @@ services:
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "false" DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
# STIRLING_JVM_PROFILE: "balanced" # Options: balanced (G1GC, default), performance (Shenandoah)
# JAVA_CUSTOM_OPTS: "" # Additional JVM flags appended to the active profile
PUID: 1002 PUID: 1002
PGID: 1002 PGID: 1002
UMASK: "022" UMASK: "022"
@@ -33,6 +33,8 @@ services:
PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_PROTOCOL: "http" PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_PROTOCOL: "http"
# Session limit should match endpoint count for optimal concurrency # Session limit should match endpoint count for optimal concurrency
PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "2" PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "2"
# STIRLING_JVM_PROFILE: "balanced" # Options: balanced (G1GC, default), performance (Shenandoah)
# JAVA_CUSTOM_OPTS: "" # Additional JVM flags appended to the active profile
PUID: 1002 PUID: 1002
PGID: 1002 PGID: 1002
UMASK: "022" UMASK: "022"
@@ -21,6 +21,8 @@ services:
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true" PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true"
PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "1" PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "1"
# STIRLING_JVM_PROFILE: "balanced" # Options: balanced (G1GC, default), performance (Shenandoah)
# JAVA_CUSTOM_OPTS: "" # Additional JVM flags appended to the active profile
PUID: 1002 PUID: 1002
PGID: 1002 PGID: 1002
UMASK: "022" UMASK: "022"

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