diff --git a/.dockerignore b/.dockerignore index 50f737668..54dbc4232 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,75 +1,52 @@ -# Node modules and build artifacts -node_modules -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 +# Version control +.git/ .gitignore -# IDE -.vscode -.idea +# Build outputs +build/ +*/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 -*.iws *.ipr +*.iws -# Logs +# Logs and temp files *.log -logs - -# Environment files -.env -.env.* -!.env.example - -# OS files +*.tmp +*.pid .DS_Store Thumbs.db -# Java compiled files -*.class -*.jar -*.war -*.ear - -# Test reports -test-results -coverage - -# Docker -docker-compose.override.yml +# Docker itself +Dockerfile* .dockerignore -# Temporary files -tmp -temp -*.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 +# CI / CD configs (not needed in build context) +.github/ +.circleci/ .gitlab-ci.yml + +# Test reports +**/test-results/ +**/jacoco/ + +# Local env +.env +.env.* +!.env.example diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 580f4bc5e..06454ece2 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -150,10 +150,10 @@ jobs: ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge token: ${{ steps.setup-bot.outputs.token }} - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38e610ed2..aa5b2990c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,7 @@ jobs: strategy: fail-fast: false matrix: - jdk-version: [17, 21] + jdk-version: [21, 25] spring-security: [true, false] steps: - name: Harden Runner @@ -140,7 +140,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle @@ -209,10 +209,10 @@ jobs: - name: Checkout repository 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle @@ -221,7 +221,12 @@ jobs: gradle-version: 8.14 - 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: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} @@ -265,10 +270,10 @@ jobs: - name: Checkout Repository 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle @@ -355,10 +360,10 @@ jobs: docker system prune -af || true 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 0b7fbd20c..5fdd69f4d 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -330,10 +330,10 @@ jobs: app-id: ${{ secrets.GH_APP_ID }} 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle @@ -344,7 +344,12 @@ jobs: - name: Check licenses and generate report id: license-check 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: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index ad91b2878..1595a62c2 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -44,10 +44,10 @@ jobs: - 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle @@ -117,10 +117,10 @@ jobs: - 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle @@ -197,10 +197,10 @@ jobs: toolchain: stable 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index a54365d8b..3146ebb0e 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -39,10 +39,10 @@ jobs: - 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index f7b4b8c25..c4b940e76 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -33,10 +33,10 @@ jobs: - 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 with: - java-version: "21" + java-version: "25" distribution: "temurin" - name: Setup Gradle diff --git a/app/common/build.gradle b/app/common/build.gradle index c2719d29f..be7016aaa 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -27,8 +27,8 @@ spotless { } } dependencies { - api 'org.springframework.boot:spring-boot-starter-web' - api 'org.springframework.boot:spring-boot-starter-aop' + api 'org.springframework.boot:spring-boot-starter-webmvc' + api 'org.springframework.boot:spring-boot-starter-aspectj' api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1' api 'com.fathzer:javaluator:3.0.6' api 'com.posthog.java:posthog:1.2.0' @@ -42,7 +42,7 @@ dependencies { api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' 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) api 'org.simplejavamail:simple-java-mail:8.12.6' api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support diff --git a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java index 49ed068e2..b1ecc1020 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java @@ -91,9 +91,9 @@ public class RuntimePathConfig { // Initialize Operation paths 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 defaultOcrMyPdfPath = isDocker ? "/usr/bin/ocrmypdf" : "ocrmypdf"; + String defaultOcrMyPdfPath = isDocker ? "/opt/venv/bin/ocrmypdf" : "ocrmypdf"; String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice"; Operations operations = customPaths.getOperations(); diff --git a/app/common/src/main/java/stirling/software/common/configuration/SchedulingConfig.java b/app/common/src/main/java/stirling/software/common/configuration/SchedulingConfig.java new file mode 100644 index 000000000..650f8d947 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/configuration/SchedulingConfig.java @@ -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; + } +} diff --git a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java index eff801858..7cc01541e 100644 --- a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java +++ b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java @@ -1,175 +1,562 @@ package stirling.software.common.service; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.util.Locale; -import java.util.concurrent.atomic.AtomicLong; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +import java.util.function.Consumer; import org.apache.pdfbox.Loader; import org.apache.pdfbox.examples.util.DeletingRandomAccessFile; import org.apache.pdfbox.io.IOUtils; import org.apache.pdfbox.io.MemoryUsageSetting; +import org.apache.pdfbox.io.RandomAccessReadBufferedFile; import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction; import org.apache.pdfbox.io.ScratchFile; import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.PDFFile; -import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.TempFileManager; -import stirling.software.common.util.TempFileRegistry; -/** - * Adaptive PDF document factory that optimizes memory usage based on file size and available system - * resources. - */ @Component @Slf4j -@RequiredArgsConstructor public class CustomPDFDocumentFactory { private final PdfMetadataService pdfMetadataService; - // Memory thresholds and limits + // TempFileManager is optional at construction time so that test code can instantiate this + // class without a full Spring context. When null, falls back to Files.createTempFile(). + private final TempFileManager tempFileManager; - public static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB - // Files smaller than this threshold are loaded entirely in memory for better performance. - // These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM. - // No temp files are created for document data, reducing I/O operations but consuming more - // memory. - - private static final long LARGE_FILE_THRESHOLD = 50 * 1024 * 1024; // 50 MB - // Files between SMALL and LARGE thresholds use file-based caching with ScratchFile, - // but are loaded directly from byte arrays if provided that way. - // When loading from byte arrays, once size exceeds this threshold, bytes are first - // written to temp files before loading to reduce memory pressure. - - private static final long LARGE_FILE_USAGE = 10 * 1024 * 1024; - - private static final long EXTREMELY_LARGE_THRESHOLD = 100 * 1024 * 1024; // 100 MB - // Files exceeding this threshold use specialized loading with RandomAccessReadBufferedFile - // which provides buffered access to the file without loading the entire content at once. - // These files are always processed using file-based caching with minimal memory footprint, - // trading some performance for significantly reduced memory usage. - // For extremely large PDFs, this prevents OutOfMemoryErrors at the cost of being more I/O - // bound. - - private static final double MIN_FREE_MEMORY_PERCENTAGE = 30.0; // 30% - private static final long MIN_FREE_MEMORY_BYTES = 4L * 1024 * 1024 * 1024; // 4 GB - - // Counter for tracking temporary resources - private static final AtomicLong tempCounter = new AtomicLong(0); + /** Primary constructor used by Spring. Both collaborators are required in production. */ + @Autowired + public CustomPDFDocumentFactory( + PdfMetadataService pdfMetadataService, TempFileManager tempFileManager) { + this.pdfMetadataService = pdfMetadataService; + this.tempFileManager = tempFileManager; + } /** - * Main entry point for loading a PDF document from a file. Automatically selects the most - * appropriate loading strategy. + * Test-only convenience constructor. {@link TempFileManager} falls back to {@link + * Files#createTempFile}. */ + public CustomPDFDocumentFactory(PdfMetadataService pdfMetadataService) { + this(pdfMetadataService, null); + } + + /** Documents ≤ this size are loaded entirely into heap — no temp files needed. */ + public static final long SMALL_FILE_THRESHOLD = 10L * 1024 * 1024; // 10 MB + + /** Upper boundary of the "mixed" memory+file zone; above this always file-backed. */ + private static final long LARGE_FILE_THRESHOLD = 50L * 1024 * 1024; // 50 MB + + /** Heap budget reserved for a document loaded in mixed mode. */ + private static final long MIXED_MODE_MEMORY_LIMIT = 10L * 1024 * 1024; // 10 MB + + /** Minimum free-heap fraction before falling back to file-backed caching. */ + private static final double MIN_FREE_MEMORY_PERCENTAGE = 30.0; + + /** + * Absolute free-heap floor. Kept well below typical JVM heap sizes (256 MB) so that the + * percentage gate above remains the primary trigger. The previous value of 4 GB caused + * file-backed caching to fire on every request against any JVM with a heap smaller than 4 GB, + * defeating the purpose of the threshold hierarchy entirely. + */ + private static final long MIN_FREE_MEMORY_FLOOR = 256L * 1024 * 1024; // 256 MB + + /** Maximum number of concurrent PDF operations in batch methods. */ + private static final int MAX_CONCURRENT_OPS = + Math.max(4, Runtime.getRuntime().availableProcessors()); + + private static final Semaphore CONCURRENT_GATE = new Semaphore(MAX_CONCURRENT_OPS); + + /** + * Immutable point-in-time snapshot of JVM heap metrics. Capturing all three {@code + * Runtime.getRuntime()} values in one call prevents the race where {@code freeMemory()} and + * {@code totalMemory()} are read milliseconds apart under GC pressure, yielding a logically + * inconsistent picture. + */ + record MemorySnapshot(long maxBytes, long usedBytes, long freeBytes, double freePct) { + + static MemorySnapshot capture() { + Runtime rt = Runtime.getRuntime(); + long max = rt.maxMemory(); + long used = rt.totalMemory() - rt.freeMemory(); + long free = max - used; + return new MemorySnapshot(max, used, free, (double) free / max * 100.0); + } + + /** {@code true} when available heap is too low for in-memory PDF caching. */ + boolean isLow() { + return freePct < MIN_FREE_MEMORY_PERCENTAGE || freeBytes < MIN_FREE_MEMORY_FLOOR; + } + } + public PDDocument load(File file) throws IOException { return load(file, false); } /** - * Main entry point for loading a PDF document from a file with read-only option. Automatically - * selects the most appropriate loading strategy. + * Loads a PDF from a caller-owned {@link File}. Small files (<= {@link #SMALL_FILE_THRESHOLD}) + * are slurped into a byte array. Larger files are loaded directly using a non-destructive + * {@link RandomAccessReadBufferedFile} so the caller's original is never modified or deleted. + * + *

Note: for files larger than {@link #SMALL_FILE_THRESHOLD}, the returned document holds an + * open file handle to the original file until {@link PDDocument#close()} is called. */ public PDDocument load(File file, boolean readOnly) throws IOException { - if (file == null) { - throw ExceptionUtils.createNullArgumentException("File"); + if (file == null) throw ExceptionUtils.createNullArgumentException("File"); + long size = file.length(); + log.debug("Loading PDF from file: {} MB", size >> 20); + if (size < SMALL_FILE_THRESHOLD) { + return load(Files.readAllBytes(file.toPath()), readOnly); } - - long fileSize = file.length(); - log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024)); - - PDDocument doc = loadAdaptively(file, fileSize); - if (!readOnly) { - postProcessDocument(doc); + MemorySnapshot mem = MemorySnapshot.capture(); + // Use the overridable method so that test spies (SpyPDFDocumentFactory) can intercept. + StreamCacheCreateFunction cache = getStreamCacheFunction(size, mem); + // Non-destructive — caller's file is never deleted + RandomAccessReadBufferedFile raf = new RandomAccessReadBufferedFile(file); + PDDocument doc; + try { + doc = Loader.loadPDF(raf, "", null, null, cache); + } catch (IOException e) { + try { + raf.close(); + } catch (IOException ce) { + e.addSuppressed(ce); + } + ExceptionUtils.logException("PDF loading from file", e); + throw ExceptionUtils.handlePdfException(e); + } + if (size > LARGE_FILE_THRESHOLD || mem.isLow()) { + doc.setResourceCache(null); + } + try { + return maybePostProcess(doc, readOnly); + } catch (IOException | RuntimeException ex) { + doc.close(); + throw ex; } - return doc; } - /** - * Main entry point for loading a PDF document from a Path. Automatically selects the most - * appropriate loading strategy. - */ public PDDocument load(Path path) throws IOException { return load(path, false); } - /** - * Main entry point for loading a PDF document from a Path with read-only option. Automatically - * selects the most appropriate loading strategy. - */ + /** Loads a PDF from a caller-owned {@link Path}. Delegates to {@link #load(File, boolean)}. */ public PDDocument load(Path path, boolean readOnly) throws IOException { - if (path == null) { - throw ExceptionUtils.createNullArgumentException("File"); - } - - long fileSize = Files.size(path); - log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024)); - - PDDocument doc = loadAdaptively(path.toFile(), fileSize); - if (!readOnly) { - postProcessDocument(doc); - } - return doc; + if (path == null) throw ExceptionUtils.createNullArgumentException("Path"); + return load(path.toFile(), readOnly); } - /** Load a PDF from byte array with automatic optimization. */ public PDDocument load(byte[] input) throws IOException { return load(input, false); } - /** Load a PDF from byte array with automatic optimization and read-only option. */ public PDDocument load(byte[] input, boolean readOnly) throws IOException { - if (input == null) { - throw ExceptionUtils.createNullArgumentException("Input bytes"); + if (input == null) throw ExceptionUtils.createNullArgumentException("Input bytes"); + long size = input.length; + log.debug("Loading PDF from byte[]: {} MB", size >> 20); + PDDocument doc = loadAdaptively(input, size, null); + try { + return maybePostProcess(doc, readOnly); + } catch (IOException | RuntimeException ex) { + doc.close(); + throw ex; } - - long dataSize = input.length; - log.debug("Loading PDF from byte array, size: {}MB", dataSize / (1024 * 1024)); - - PDDocument doc = loadAdaptively(input, dataSize); - if (!readOnly) { - postProcessDocument(doc); - } - return doc; } - /** Load a PDF from InputStream with automatic optimization. */ public PDDocument load(InputStream input) throws IOException { return load(input, false); } - /** Load a PDF from InputStream with automatic optimization and read-only option. */ public PDDocument load(InputStream input, boolean readOnly) throws IOException { - if (input == null) { - throw ExceptionUtils.createNullArgumentException("InputStream"); + if (input == null) throw ExceptionUtils.createNullArgumentException("InputStream"); + return streamToTemp(input, null, readOnly); + } + + public PDDocument load(InputStream input, String password) throws IOException { + return load(input, password, false); + } + + public PDDocument load(InputStream input, String password, boolean readOnly) + throws IOException { + if (input == null) throw ExceptionUtils.createNullArgumentException("InputStream"); + return streamToTemp(input, password, readOnly); + } + + public PDDocument load(String path) throws IOException { + return load(path, false); + } + + public PDDocument load(String path, boolean readOnly) throws IOException { + return load(new File(path), readOnly); + } + + public PDDocument load(PDFFile pdfFile) throws IOException { + return load(pdfFile, false); + } + + public PDDocument load(PDFFile pdfFile, boolean readOnly) throws IOException { + return load(pdfFile.getFileInput(), readOnly); + } + + public PDDocument load(MultipartFile pdfFile) throws IOException { + return load(pdfFile, false); + } + + /** + * Loads a {@link MultipartFile}. Small uploads (<= {@link #SMALL_FILE_THRESHOLD}) are read + * directly into a byte array, bypassing the InputStream → temp-file round-trip and saving one + * disk write + read cycle on the hot path. + */ + public PDDocument load(MultipartFile pdfFile, boolean readOnly) throws IOException { + long size = pdfFile.getSize(); + if (size > 0 && size <= SMALL_FILE_THRESHOLD) { + return load(pdfFile.getBytes(), readOnly); + } + return streamToTemp(pdfFile.getInputStream(), null, readOnly); + } + + public PDDocument load(MultipartFile fileInput, String password) throws IOException { + return load(fileInput, password, false); + } + + public PDDocument load(MultipartFile fileInput, String password, boolean readOnly) + throws IOException { + return streamToTemp(fileInput.getInputStream(), password, readOnly); + } + + /** + * Returns the {@link StreamCacheCreateFunction} appropriate for a document of the given byte + * size given the current heap state. Captures a fresh {@link MemorySnapshot} on each call. + * + *

Overridden by {@code SpyPDFDocumentFactory} in tests to record which strategy was chosen. + */ + public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) { + return getStreamCacheFunction(contentSize, MemorySnapshot.capture()); + } + + /** + * Overload accepting a pre-captured {@link MemorySnapshot} so that internal callers can reuse a + * single snapshot for both cache selection and resource-cache decisions. Overridable so that + * test spies ({@code SpyPDFDocumentFactory}) can intercept. + */ + protected StreamCacheCreateFunction getStreamCacheFunction( + long contentSize, MemorySnapshot mem) { + return selectCacheFunction(contentSize, mem); + } + + public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException { + PDDocument doc = new PDDocument(scratchCache(settings)); + pdfMetadataService.setDefaultMetadata(doc); + return doc; + } + + /** + * Creates a new empty document using a memory-only cache. New documents start at zero bytes, so + * file-backed scratch space is wasteful until the caller has actually written content. Callers + * that build very large documents can use {@link #createNewDocument(MemoryUsageSetting)} to opt + * in to file-backed caching from the start. + */ + public PDDocument createNewDocument() throws IOException { + PDDocument doc = new PDDocument(IOUtils.createMemoryOnlyStreamCache()); + pdfMetadataService.setDefaultMetadata(doc); + return doc; + } + + /** + * Serialises a {@link PDDocument} to a byte array. The document is written to a temp file first + * so that the PDDocument's internal object graph can be GC'd before {@link Files#readAllBytes} + * allocates the returned byte array, preventing double-peak memory for large documents. The OS + * buffer cache absorbs the I/O overhead for small documents. + */ + public byte[] saveToBytes(PDDocument document) throws IOException { + Path temp = createTempFilePath("pdf-save-"); + try { + document.save(temp.toFile()); + return Files.readAllBytes(temp); + } finally { + try { + Files.deleteIfExists(temp); + } catch (IOException e) { + log.warn("Failed to delete temp file: {}", temp, e); + } + } + } + + public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException { + try (PDDocument document = load(oldDocument)) { + return saveToBytes(document); + } + } + + public PDDocument createNewDocumentBasedOnOldDocument(byte[] oldDocument) throws IOException { + try (PDDocument document = load(oldDocument)) { + return createNewDocumentBasedOnOldDocument(document); + } + } + + public PDDocument createNewDocumentBasedOnOldDocument(File oldDocument) throws IOException { + try (PDDocument document = load(oldDocument)) { + return createNewDocumentBasedOnOldDocument(document); + } + } + + public PDDocument createNewDocumentBasedOnOldDocument(PDDocument oldDocument) + throws IOException { + PDDocument document = createNewDocument(); + try { + pdfMetadataService.setMetadataToPdf( + document, pdfMetadataService.extractMetadataFromPdf(oldDocument), true); + return document; + } catch (RuntimeException ex) { + document.close(); + throw ex; + } + } + + public byte[] loadToBytes(File file) throws IOException { + try (PDDocument document = load(file)) { + return saveToBytes(document); + } + } + + public byte[] loadToBytes(byte[] bytes) throws IOException { + try (PDDocument document = load(bytes)) { + return saveToBytes(document); + } + } + + /** + * Loads all {@code files} concurrently, one virtual thread per file. PDF loading is I/O-bound; + * virtual threads yield their carrier threads during blocking reads, so the JVM can serve other + * requests while each document is being parsed from disk. + * + *

Concurrency is bounded by {@link #MAX_CONCURRENT_OPS} to prevent unbounded memory pressure + * when many files are submitted simultaneously. + * + *

If any single load fails, all pending tasks are cancelled, any already-open documents are + * closed, and the first {@link IOException} is rethrown. The caller retains ownership of all + * returned documents and must close them. + * + * @param files ordered list of files to load; the returned list preserves insertion order + * @throws InterruptedException if the calling thread is interrupted while waiting + */ + public List loadAll(List files) throws IOException, InterruptedException { + List> tasks = + files.stream() + .>map( + f -> + () -> { + CONCURRENT_GATE.acquire(); + try { + return load(f); + } finally { + CONCURRENT_GATE.release(); + } + }) + .toList(); + return runConcurrently(tasks, CustomPDFDocumentFactory::closeQuietly); + } + + /** + * Loads all multipart uploads concurrently, one virtual thread per upload. Small uploads (≤ + * {@link #SMALL_FILE_THRESHOLD}) are read into heap; larger uploads spill to temp files. + * Concurrency is bounded by {@link #MAX_CONCURRENT_OPS}. Failure semantics are identical to + * {@link #loadAll(List)}. + * + * @param files ordered list of uploads; the returned list preserves insertion order + * @throws InterruptedException if the calling thread is interrupted while waiting + */ + public List loadAllMultipart(List files) + throws IOException, InterruptedException { + List> tasks = + files.stream() + .>map( + f -> + () -> { + CONCURRENT_GATE.acquire(); + try { + return load(f); + } finally { + CONCURRENT_GATE.release(); + } + }) + .toList(); + return runConcurrently(tasks, CustomPDFDocumentFactory::closeQuietly); + } + + /** + * Serialises all documents to byte arrays concurrently, one virtual thread per document. + * Concurrency is bounded by {@link #MAX_CONCURRENT_OPS}. Each document is written to a temp + * file (preventing double-peak-memory, see {@link #saveToBytes}); the concurrent writes proceed + * in parallel. The returned list preserves insertion order. + * + * @throws InterruptedException if the calling thread is interrupted while waiting + */ + public List saveAllToBytes(List documents) + throws IOException, InterruptedException { + List> tasks = + documents.stream() + .>map( + doc -> + () -> { + CONCURRENT_GATE.acquire(); + try { + return saveToBytes(doc); + } finally { + CONCURRENT_GATE.release(); + } + }) + .toList(); + return runConcurrently(tasks, null); + } + + /** + * Runs {@code tasks} concurrently on virtual threads (one per task), collecting results in + * insertion order. The executor is scoped to this call - no shared mutable state between + * invocations. On any failure: pending tasks are cancelled, {@code onFailureCleanup} is applied + * to every result collected before the failure, and the first exception is rethrown. + * + * @param onFailureCleanup may be {@code null} when no result-level cleanup is needed + */ + private static List runConcurrently( + List> tasks, Consumer onFailureCleanup) + throws IOException, InterruptedException { + try (ExecutorService vte = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = tasks.stream().map(vte::submit).toList(); + List results = new ArrayList<>(futures.size()); + try { + for (Future future : futures) { + results.add(future.get()); + } + return List.copyOf(results); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + futures.forEach(f -> f.cancel(true)); + cleanupFutureResults(futures, onFailureCleanup); + throw ie; + } catch (ExecutionException e) { + futures.forEach(f -> f.cancel(true)); + cleanupFutureResults(futures, onFailureCleanup); + Throwable cause = e.getCause(); + if (cause instanceof IOException ioe) throw ioe; + if (cause instanceof InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ie; + } + throw new IOException("Concurrent PDF operation failed", cause); + } + } + } + + /** Cleans up results from concurrent execution on failure. */ + private static void cleanupFutureResults( + List> futures, Consumer onFailureCleanup) { + if (onFailureCleanup == null) return; + for (Future f : futures) { + if (f.isDone() && !f.isCancelled()) { + try { + onFailureCleanup.accept(f.get()); + } catch (Exception ignored) { + // best-effort cleanup + } + } + } + } + + private static void closeQuietly(PDDocument doc) { + try { + doc.close(); + } catch (IOException e) { + log.warn("Failed to close PDF during error cleanup", e); + } + } + + /** + * Selects a loading strategy and loads the document from {@code source} (a {@link File} or + * {@code byte[]}). A single {@link MemorySnapshot} is captured once and reused for both cache + * selection and resource-cache configuration, guaranteeing that both decisions see the same + * heap state. + * + * @param password {@code null} for unencrypted (or to-be-decrypted-later) documents + */ + private PDDocument loadAdaptively(Object source, long contentSize, String password) + throws IOException { + Object sourceObj = source; + // Capture a single snapshot for both cache selection and resource-cache decision. + MemorySnapshot mem = MemorySnapshot.capture(); + // Use the overridable method so that test spies (SpyPDFDocumentFactory) can intercept. + StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize, mem); + + // Slurp small on-disk files into heap immediately so the temp file can be removed and its + // file descriptor released before PDFBox opens its own internal scratch space. + if (contentSize < SMALL_FILE_THRESHOLD && sourceObj instanceof File f) { + byte[] bytes = Files.readAllBytes(f.toPath()); + Files.deleteIfExists(f.toPath()); + sourceObj = bytes; } - // Since we don't know the size upfront, buffer to a temp file - Path tempFile = createTempFile("pdf-stream-"); + PDDocument document = + switch (sourceObj) { + case File f -> + password != null + ? loadFromFileWithPassword(f, cacheFunction, password) + : loadFromFile(f, cacheFunction); + case byte[] b -> + password != null + ? loadFromBytesWithPassword( + b, contentSize, cacheFunction, password) + : loadFromBytes(b, contentSize, cacheFunction); + default -> + throw new IllegalArgumentException( + "Unsupported source type: " + + sourceObj.getClass().getSimpleName()); + }; + + // Use the same snapshot captured above for consistent resource-cache decision. + if (contentSize > LARGE_FILE_THRESHOLD || mem.isLow()) { + document.setResourceCache(null); + } + return document; + } + + /** Buffers an {@link InputStream} to a managed temp file then delegates to the core loader. */ + private PDDocument streamToTemp(InputStream input, String password, boolean readOnly) + throws IOException { + Path tempFile = createTempFilePath("pdf-stream-"); boolean success = false; try { Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); - PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile)); - if (!readOnly) { - postProcessDocument(doc); + PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile), password); + try { + PDDocument result = maybePostProcess(doc, readOnly); + success = true; + return result; + } catch (IOException | RuntimeException ex) { + doc.close(); + throw ex; } - success = true; - return doc; } finally { - // On success: small files are deleted inside loadAdaptively; large files are deleted - // by DeletingRandomAccessFile when the returned PDDocument is closed. + // On success: small files are deleted inside loadAdaptively; large files are owned by + // DeletingRandomAccessFile and deleted when the PDDocument closes. // On failure: clean up the temp file ourselves since no one else will. if (!success) { Files.deleteIfExists(tempFile); @@ -177,261 +564,87 @@ public class CustomPDFDocumentFactory { } } - /** Load with password from InputStream */ - public PDDocument load(InputStream input, String password) throws IOException { - return load(input, password, false); - } - - /** Load with password from InputStream and read-only option */ - public PDDocument load(InputStream input, String password, boolean readOnly) - throws IOException { - if (input == null) { - throw ExceptionUtils.createNullArgumentException("InputStream"); - } - - // Since we don't know the size upfront, buffer to a temp file - Path tempFile = createTempFile("pdf-stream-"); - boolean success = false; - try { - Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); - PDDocument doc = - loadAdaptivelyWithPassword(tempFile.toFile(), Files.size(tempFile), password); - if (!readOnly) { - postProcessDocument(doc); - } - success = true; - return doc; - } finally { - if (!success) { - Files.deleteIfExists(tempFile); - } - } - } - - /** Load from a file path string */ - public PDDocument load(String path) throws IOException { - return load(path, false); - } - - /** Load from a file path string with read-only option */ - public PDDocument load(String path, boolean readOnly) throws IOException { - return load(new File(path), readOnly); - } - - /** Load from a PDFFile object */ - public PDDocument load(PDFFile pdfFile) throws IOException { - return load(pdfFile, false); - } - - /** Load from a PDFFile object with read-only option */ - public PDDocument load(PDFFile pdfFile, boolean readOnly) throws IOException { - return load(pdfFile.getFileInput(), readOnly); - } - - /** Load from a MultipartFile */ - public PDDocument load(MultipartFile pdfFile) throws IOException { - return load(pdfFile, false); - } - - /** Load from a MultipartFile with read-only option */ - public PDDocument load(MultipartFile pdfFile, boolean readOnly) throws IOException { - return load(pdfFile.getInputStream(), readOnly); - } - - /** Load with password from MultipartFile */ - public PDDocument load(MultipartFile fileInput, String password) throws IOException { - return load(fileInput, password, false); - } - - /** Load with password from MultipartFile with read-only option */ - public PDDocument load(MultipartFile fileInput, String password, boolean readOnly) - throws IOException { - return load(fileInput.getInputStream(), password, readOnly); - } - /** - * Determine the appropriate caching strategy based on file size and available memory. This - * common method is used by both password and non-password loading paths. + * Internal helper that reuses an already-captured {@link MemorySnapshot}, avoiding a second + * {@code Runtime.getRuntime()} call from within the same load operation. */ - public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) { - long maxMemory = Runtime.getRuntime().maxMemory(); - long freeMemory = Runtime.getRuntime().freeMemory(); - long totalMemory = Runtime.getRuntime().totalMemory(); - long usedMemory = totalMemory - freeMemory; - - // Calculate percentage of free memory - double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100; - long actualFreeMemory = maxMemory - usedMemory; - - // Log memory status - log.debug( - "Memory status - Free: {}MB ({}%), Used: {}MB, Max: {}MB", - actualFreeMemory / (1024 * 1024), - String.format(Locale.ROOT, "%.2f", freeMemoryPercent), - usedMemory / (1024 * 1024), - maxMemory / (1024 * 1024)); - - // If free memory is critically low, always use file-based caching - if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE - || actualFreeMemory < MIN_FREE_MEMORY_BYTES) { + private static StreamCacheCreateFunction selectCacheFunction( + long contentSize, MemorySnapshot mem) { + if (mem.isLow()) { log.debug( - "Low memory detected ({}%), forcing file-based cache", - String.format(Locale.ROOT, "%.2f", freeMemoryPercent)); - return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly()); - } else if (contentSize < SMALL_FILE_THRESHOLD) { - log.debug("Using memory-only cache for small document ({}KB)", contentSize / 1024); + "Heap pressure ({}% free, {} MB free), forcing file-backed cache", + (int) mem.freePct(), mem.freeBytes() >> 20); + return scratchCache(MemoryUsageSetting.setupTempFileOnly()); + } + if (contentSize < SMALL_FILE_THRESHOLD) { + log.debug("Memory-only cache for {} KB document", contentSize >> 10); return IOUtils.createMemoryOnlyStreamCache(); - } else if (contentSize < LARGE_FILE_THRESHOLD) { - // For medium files (10-50MB), use a mixed approach - log.debug( - "Using mixed memory/file cache for medium document ({}MB)", - contentSize / (1024 * 1024)); - return createScratchFileCacheFunction(MemoryUsageSetting.setupMixed(LARGE_FILE_USAGE)); - } else { - log.debug("Using file-based cache for large document"); - return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly()); } + if (contentSize < LARGE_FILE_THRESHOLD) { + log.debug("Mixed cache for {} MB document", contentSize >> 20); + return scratchCache(MemoryUsageSetting.setupMixed(MIXED_MODE_MEMORY_LIMIT)); + } + log.debug("File-backed cache for {} MB document", contentSize >> 20); + return scratchCache(MemoryUsageSetting.setupTempFileOnly()); } - /** Update the existing loadAdaptively method to use the common function */ - private PDDocument loadAdaptively(Object source, long contentSize) throws IOException { - // Get the appropriate caching strategy - StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize); - - // If small handle as bytes and remove original file - if (contentSize <= SMALL_FILE_THRESHOLD && source instanceof File file) { - source = Files.readAllBytes(file.toPath()); - file.delete(); - } - PDDocument document; - if (source instanceof File file) { - document = loadFromFile(file, contentSize, cacheFunction); - } else if (source instanceof byte[] bytes) { - document = loadFromBytes(bytes, contentSize, cacheFunction); - } else { - throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); - } - - configureResourceCacheIfNeeded(document, contentSize); - - return document; - } - - /** - * Configure resource cache based on content size and memory constraints. Disables resource - * cache for large files or when memory is low to prevent OOM errors. - */ - private void configureResourceCacheIfNeeded(PDDocument document, long contentSize) { - if (contentSize > LARGE_FILE_THRESHOLD) { - document.setResourceCache(null); - } else { - // Check current memory status for smaller files - long maxMemory = Runtime.getRuntime().maxMemory(); - long usedMemory = - Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); - double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100; - - if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE) { - document.setResourceCache(null); - } - } - } - - /** Load a PDF with password protection using adaptive loading strategies */ - private PDDocument loadAdaptivelyWithPassword(Object source, long contentSize, String password) - throws IOException { - // Get the appropriate caching strategy - StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize); - // If small handle as bytes and remove original file - if (contentSize <= SMALL_FILE_THRESHOLD && source instanceof File file) { - source = Files.readAllBytes(file.toPath()); - file.delete(); - } - PDDocument document; - if (source instanceof File file) { - document = loadFromFileWithPassword(file, contentSize, cacheFunction, password); - } else if (source instanceof byte[] bytes) { - document = loadFromBytesWithPassword(bytes, contentSize, cacheFunction, password); - } else { - throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); - } - - configureResourceCacheIfNeeded(document, contentSize); - - return document; - } - - /** Load a file with password */ - private PDDocument loadFromFileWithPassword( - File file, long size, StreamCacheCreateFunction cache, String password) - throws IOException { - return Loader.loadPDF(new DeletingRandomAccessFile(file), password, null, null, cache); - } - - /** Load bytes with password */ - private PDDocument loadFromBytesWithPassword( - byte[] bytes, long size, StreamCacheCreateFunction cache, String password) - throws IOException { - if (size >= SMALL_FILE_THRESHOLD) { - log.debug("Writing large byte array to temp file for password-protected PDF"); - Path tempFile = createTempFile("pdf-bytes-"); - boolean success = false; - try { - Files.write(tempFile, bytes); - // Use DeletingRandomAccessFile so the temp file is removed when the document closes - PDDocument doc = - Loader.loadPDF( - new DeletingRandomAccessFile(tempFile.toFile()), - password, - null, - null, - cache); - success = true; - return doc; - } finally { - if (!success) { - Files.deleteIfExists(tempFile); - } - } - } - return Loader.loadPDF(bytes, password, null, null, cache); - } - - private StreamCacheCreateFunction createScratchFileCacheFunction(MemoryUsageSetting settings) { - return () -> { - try { - return new ScratchFile(settings); - } catch (IOException e) { - throw new RuntimeException("ScratchFile initialization failed", e); - } - }; - } - - private void postProcessDocument(PDDocument doc) throws IOException { - pdfMetadataService.setDefaultMetadata(doc); - removePassword(doc); - } - - private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache) + private static PDDocument loadFromFile(File file, StreamCacheCreateFunction cache) throws IOException { + DeletingRandomAccessFile raf = new DeletingRandomAccessFile(file); try { - return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache); + // Empty string password: PDFBox convention for unencrypted documents. + return Loader.loadPDF(raf, "", null, null, cache); } catch (IOException e) { + try { + raf.close(); + } catch (IOException ce) { + e.addSuppressed(ce); + } ExceptionUtils.logException("PDF loading from file", e); throw ExceptionUtils.handlePdfException(e); } } + /** + * Loads a password-protected PDF from a file. The {@link DeletingRandomAccessFile} is + * explicitly closed if {@link Loader#loadPDF} throws to prevent file descriptor leaks. + */ + private static PDDocument loadFromFileWithPassword( + File file, StreamCacheCreateFunction cache, String password) throws IOException { + DeletingRandomAccessFile raf = new DeletingRandomAccessFile(file); + try { + return Loader.loadPDF(raf, password, null, null, cache); + } catch (IOException e) { + try { + raf.close(); + } catch (IOException ce) { + e.addSuppressed(ce); + } + ExceptionUtils.logException("PDF loading from file with password", e); + throw ExceptionUtils.handlePdfException(e); + } + } + + /** + * Loads from a byte array. If the array exceeds {@link #SMALL_FILE_THRESHOLD} (which can happen + * when the caller passes a large byte[] directly through the public API), the bytes are first + * written to a temp file to limit simultaneous heap pressure. + */ private PDDocument loadFromBytes(byte[] bytes, long size, StreamCacheCreateFunction cache) throws IOException { if (size >= SMALL_FILE_THRESHOLD) { - log.debug("Writing large byte array to temp file"); - Path tempFile = createTempFile("pdf-bytes-"); - - Files.write(tempFile, bytes); - return loadFromFile(tempFile.toFile(), size, cache); + log.debug("Spilling {} MB byte[] to temp file before loading", size >> 20); + Path tmp = createTempFilePath("pdf-bytes-"); + boolean ok = false; + try { + Files.write(tmp, bytes); + PDDocument doc = loadFromFile(tmp.toFile(), cache); + ok = true; + return doc; + } finally { + if (!ok) Files.deleteIfExists(tmp); + } } - try { return Loader.loadPDF(bytes, "", null, null, cache); } catch (IOException e) { @@ -440,134 +653,80 @@ public class CustomPDFDocumentFactory { } } - public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException { - PDDocument doc = new PDDocument(createScratchFileCacheFunction(settings)); - pdfMetadataService.setDefaultMetadata(doc); + /** + * Loads a password-protected PDF from a byte array. Large arrays are spilled to a temp file. + * The {@link DeletingRandomAccessFile} is explicitly closed if loading throws to prevent file + * descriptor leaks on Windows. + */ + private PDDocument loadFromBytesWithPassword( + byte[] bytes, long size, StreamCacheCreateFunction cache, String password) + throws IOException { + if (size >= SMALL_FILE_THRESHOLD) { + Path tmp = createTempFilePath("pdf-bytes-"); + boolean success = false; + try { + Files.write(tmp, bytes); + DeletingRandomAccessFile raf = new DeletingRandomAccessFile(tmp.toFile()); + try { + PDDocument doc = Loader.loadPDF(raf, password, null, null, cache); + success = true; + return doc; + } catch (IOException e) { + try { + raf.close(); + } catch (IOException ce) { + e.addSuppressed(ce); + } + throw e; + } + } finally { + if (!success) Files.deleteIfExists(tmp); + } + } + return Loader.loadPDF(bytes, password, null, null, cache); + } + + private PDDocument maybePostProcess(PDDocument doc, boolean readOnly) throws IOException { + if (!readOnly) { + pdfMetadataService.setDefaultMetadata(doc); + removePassword(doc); + } return doc; } - public PDDocument createNewDocument() throws IOException { - return createNewDocument(MemoryUsageSetting.setupTempFileOnly()); - } - - public byte[] saveToBytes(PDDocument document) throws IOException { - if (document.getNumberOfPages() < 10) { // Simple heuristic - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - document.save(baos); - return baos.toByteArray(); - } - } else { - Path tempFile = createTempFile("pdf-save-"); - try { - document.save(tempFile.toFile()); - return Files.readAllBytes(tempFile); - } finally { - Files.deleteIfExists(tempFile); - } - } - } - - // Improved password handling - private void removePassword(PDDocument document) throws IOException { + private static void removePassword(PDDocument document) throws IOException { if (document.isEncrypted()) { try { document.setAllSecurityToBeRemoved(true); - } catch (Exception e) { + } catch (RuntimeException e) { ExceptionUtils.logException("PDF decryption", e); throw new IOException("PDF decryption failed", e); } } } - // Temp file handling with enhanced logging and registry integration - private Path createTempFile(String prefix) throws IOException { - // Check if TempFileManager is available in the application context - try { - TempFileManager tempFileManager = - ApplicationContextProvider.getBean(TempFileManager.class); - if (tempFileManager != null) { - // Use TempFileManager to create and register the temp file - File file = tempFileManager.createTempFile(".tmp"); - log.debug("Created and registered temp file via TempFileManager: {}", file); - return file.toPath(); + private static StreamCacheCreateFunction scratchCache(MemoryUsageSetting settings) { + return () -> { + try { + return new ScratchFile(settings); + } catch (IOException e) { + throw new RuntimeException("ScratchFile initialisation failed", e); } - } catch (Exception e) { - log.debug("TempFileManager not available, falling back to standard temp file creation"); - } - - // Fallback to standard temp file creation - Path file = Files.createTempFile(prefix + tempCounter.incrementAndGet() + "-", ".tmp"); - log.debug("Created temp file: {}", file); - - // Try to register the file with a static registry if possible - try { - TempFileRegistry registry = ApplicationContextProvider.getBean(TempFileRegistry.class); - if (registry != null) { - registry.register(file); - log.debug("Registered fallback temp file with registry: {}", file); - } - } catch (Exception e) { - log.debug("Could not register fallback temp file with registry: {}", file); - } - - return file; + }; } - /** Create new document bytes based on an existing document */ - public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException { - try (PDDocument document = load(oldDocument)) { - return saveToBytes(document); - } - } - - /** Create new document bytes based on an existing document file */ - public byte[] createNewBytesBasedOnOldDocument(File oldDocument) throws IOException { - try (PDDocument document = load(oldDocument)) { - return saveToBytes(document); - } - } - - /** Create new document bytes based on an existing PDDocument */ - public byte[] createNewBytesBasedOnOldDocument(PDDocument oldDocument) throws IOException { - pdfMetadataService.setMetadataToPdf( - oldDocument, pdfMetadataService.extractMetadataFromPdf(oldDocument), true); - return saveToBytes(oldDocument); - } - - /** Create a new document based on an existing document bytes */ - public PDDocument createNewDocumentBasedOnOldDocument(byte[] oldDocument) throws IOException { - try (PDDocument document = load(oldDocument)) { - return createNewDocumentBasedOnOldDocument(document); - } - } - - /** Create a new document based on an existing document file */ - public PDDocument createNewDocumentBasedOnOldDocument(File oldDocument) throws IOException { - try (PDDocument document = load(oldDocument)) { - return createNewDocumentBasedOnOldDocument(document); - } - } - - /** Create a new document based on an existing PDDocument */ - public PDDocument createNewDocumentBasedOnOldDocument(PDDocument oldDocument) - throws IOException { - PDDocument document = createNewDocument(); - pdfMetadataService.setMetadataToPdf( - document, pdfMetadataService.extractMetadataFromPdf(oldDocument), true); - return document; - } - - /** Load document from a file and convert it to bytes */ - public byte[] loadToBytes(File file) throws IOException { - try (PDDocument document = load(file)) { - return saveToBytes(document); - } - } - - /** Load document from bytes and convert it back to bytes */ - public byte[] loadToBytes(byte[] bytes) throws IOException { - try (PDDocument document = load(bytes)) { - return saveToBytes(document); + /** + * Creates a managed temp file. When {@link TempFileManager} is available (production Spring + * context) it registers the file for automatic cleanup. Falls back to {@link + * Files#createTempFile} for test environments where the full context is not present; the + * fallback file is registered for JVM-shutdown deletion. + */ + private Path createTempFilePath(String prefix) throws IOException { + if (tempFileManager != null) { + return tempFileManager.createTempFile(".tmp").toPath(); } + Path p = Files.createTempFile(prefix, ".tmp"); + p.toFile().deleteOnExit(); + return p; } } diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 3195bb89a..f8d3cc0a9 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -35,7 +35,7 @@ public class JobExecutorService { private final HttpServletRequest request; private final ResourceMonitor resourceMonitor; private final JobQueue jobQueue; - private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor(); + private final ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor(); private final long effectiveTimeoutMs; @Autowired(required = false) diff --git a/app/common/src/main/java/stirling/software/common/service/JobQueue.java b/app/common/src/main/java/stirling/software/common/service/JobQueue.java index df5394cee..595cc6e39 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobQueue.java +++ b/app/common/src/main/java/stirling/software/common/service/JobQueue.java @@ -44,8 +44,10 @@ public class JobQueue implements SmartLifecycle { private volatile BlockingQueue jobQueue; private final Map jobMap = new ConcurrentHashMap<>(); - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor(); + private final ScheduledExecutorService scheduler = + 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 boolean shuttingDown = false; diff --git a/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java b/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java index b22923959..031361d64 100644 --- a/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java +++ b/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java @@ -43,7 +43,9 @@ public class ResourceMonitor { @Value("${stirling.resource.monitor.interval-ms:60000}") 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 OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean(); diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java index 5d2a9edca..2ee880794 100644 --- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -42,7 +42,8 @@ public class TaskManager { private final FileStorage fileStorage; private final ScheduledExecutorService cleanupExecutor = - Executors.newSingleThreadScheduledExecutor(); + Executors.newSingleThreadScheduledExecutor( + Thread.ofVirtual().name("task-cleanup-", 0).factory()); /** Initialize the task manager and start the cleanup scheduler */ public TaskManager(FileStorage fileStorage) { diff --git a/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java b/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java index 13cca386e..0d9d340b5 100644 --- a/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java +++ b/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java @@ -2,30 +2,28 @@ package stirling.software.common.util; import java.util.concurrent.ExecutorService; 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 -public class ExecutorFactory { + private 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 - * cached thread pool on older Java versions. + * Creates a {@link ScheduledExecutorService} backed by a single virtual thread. Useful for + * periodic/delayed tasks that should not pin a platform thread. */ - public static ExecutorService newVirtualOrCachedThreadExecutor() { - try { - ExecutorService executor = - (ExecutorService) - Executors.class - .getMethod("newVirtualThreadPerTaskExecutor") - .invoke(null); - return executor; - } catch (NoSuchMethodException e) { - log.debug("Virtual threads not available; falling back to cached thread pool."); - } catch (Exception e) { - log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e); - } - - return Executors.newCachedThreadPool(); + public static ScheduledExecutorService newSingleVirtualThreadScheduledExecutor() { + return Executors.newSingleThreadScheduledExecutor( + Thread.ofVirtual().name("scheduled-vt-", 0).factory()); } } diff --git a/app/common/src/main/java/stirling/software/common/util/FileMonitor.java b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java index fac9bf650..09caccdb5 100644 --- a/app/common/src/main/java/stirling/software/common/util/FileMonitor.java +++ b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java @@ -68,6 +68,17 @@ public class FileMonitor { if (this.rootDirs.isEmpty()) { 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); + } + } + } } } diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index bb2adca4b..144759e55 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -649,7 +649,8 @@ public class GeneralUtils { } public List parsePageList(String[] pages, int totalPages, boolean oneBased) { - List result = new ArrayList<>(); + // Use LinkedHashSet to prevent duplicates from inflating size and triggering maxSize guard + Set result = new LinkedHashSet<>(); int offset = oneBased ? 1 : 0; int maxSize = Math.max(1000, totalPages * 3); for (String page : pages) { @@ -672,7 +673,7 @@ public class GeneralUtils { "Page list exceeds maximum allowed size of " + maxSize); } } - return result; + return new ArrayList<>(result); } /* diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index dea028fdf..0e48628d8 100644 --- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -226,50 +226,54 @@ public class ProcessExecutor { List outputLines = new ArrayList<>(); Thread errorReaderThread = - new Thread( - () -> { - try (BufferedReader errorReader = - new BufferedReader( - new InputStreamReader( - process.getErrorStream(), - StandardCharsets.UTF_8))) { - String line; - while ((line = - BoundedLineReader.readLine( - errorReader, 5_000_000)) - != null) { - errorLines.add(line); - if (liveUpdates) log.info(line); - } - } catch (InterruptedIOException e) { - log.warn("Error reader thread was interrupted due to timeout."); - } catch (IOException e) { - log.error("exception", e); - } - }); + Thread.ofVirtual() + .unstarted( + () -> { + try (BufferedReader errorReader = + new BufferedReader( + new InputStreamReader( + process.getErrorStream(), + StandardCharsets.UTF_8))) { + String line; + while ((line = + BoundedLineReader.readLine( + errorReader, 5_000_000)) + != null) { + errorLines.add(line); + if (liveUpdates) log.info(line); + } + } catch (InterruptedIOException e) { + log.warn( + "Error reader thread was interrupted due to timeout."); + } catch (IOException e) { + log.error("exception", e); + } + }); Thread outputReaderThread = - new Thread( - () -> { - try (BufferedReader outputReader = - new BufferedReader( - new InputStreamReader( - process.getInputStream(), - StandardCharsets.UTF_8))) { - String line; - while ((line = - BoundedLineReader.readLine( - outputReader, 5_000_000)) - != null) { - outputLines.add(line); - if (liveUpdates) log.info(line); - } - } catch (InterruptedIOException e) { - log.warn("Error reader thread was interrupted due to timeout."); - } catch (IOException e) { - log.error("exception", e); - } - }); + Thread.ofVirtual() + .unstarted( + () -> { + try (BufferedReader outputReader = + new BufferedReader( + new InputStreamReader( + process.getInputStream(), + StandardCharsets.UTF_8))) { + String line; + while ((line = + BoundedLineReader.readLine( + outputReader, 5_000_000)) + != null) { + outputLines.add(line); + if (liveUpdates) log.info(line); + } + } catch (InterruptedIOException e) { + log.warn( + "Error reader thread was interrupted due to timeout."); + } catch (IOException e) { + log.error("exception", e); + } + }); errorReaderThread.start(); outputReaderThread.start(); @@ -370,7 +374,7 @@ public class ProcessExecutor { } // Match common unoconvert variants (but NOT soffice) String lowerBasename = basename.toLowerCase(java.util.Locale.ROOT); - if (lowerBasename.contains("unoconvert") || lowerBasename.equals("unoconv")) { + if (lowerBasename.contains("unoconvert") || "unoconv".equals(lowerBasename)) { return true; } } diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java index 98cba7e8c..faaa1926f 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java @@ -4,18 +4,23 @@ import java.beans.PropertyEditorSupport; import java.util.ArrayList; import java.util.List; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.security.RedactionArea; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + @Slf4j 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 public void setAsText(String text) throws IllegalArgumentException { @@ -24,7 +29,6 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport { return; } try { - objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); TypeReference> typeRef = new TypeReference<>() {}; List list = objectMapper.readValue(text, typeRef); setValue(list); diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java index 4a9afc2f6..63476d556 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java @@ -4,12 +4,13 @@ import java.beans.PropertyEditorSupport; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; public class StringToMapPropertyEditor extends PropertyEditorSupport { - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = JsonMapper.builder().build(); @Override public void setAsText(String text) throws IllegalArgumentException { diff --git a/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java b/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java index 823a7e4d8..044e16f06 100644 --- a/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java +++ b/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java @@ -12,11 +12,12 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory { public StrategyType lastStrategyUsed; public SpyPDFDocumentFactory(PdfMetadataService service) { - super(service); + super(service); // TempFileManager falls back to Files.createTempFile in tests } @Override - public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) { + protected StreamCacheCreateFunction getStreamCacheFunction( + long contentSize, MemorySnapshot mem) { StrategyType type; if (contentSize < 10 * 1024 * 1024) { type = StrategyType.MEMORY_ONLY; @@ -26,6 +27,6 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory { type = StrategyType.TEMP_FILE; } this.lastStrategyUsed = type; - return super.getStreamCacheFunction(contentSize); // delegate to real behavior + return super.getStreamCacheFunction(contentSize, mem); } } diff --git a/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java b/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java index 99ea224be..424e66a80 100644 --- a/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java +++ b/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java @@ -121,17 +121,19 @@ public class UnoServerPoolTest { // Try to acquire a third endpoint in separate thread (should block) Thread blockingThread = - new Thread( - () -> { - try { - acquireLatch.countDown(); // Signal we're about to block - UnoServerPool.UnoServerLease lease3 = pool.acquireEndpoint(); - acquired.incrementAndGet(); - lease3.close(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); + Thread.ofVirtual() + .unstarted( + () -> { + try { + acquireLatch.countDown(); // Signal we're about to block + UnoServerPool.UnoServerLease lease3 = + pool.acquireEndpoint(); + acquired.incrementAndGet(); + lease3.close(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); blockingThread.start(); acquireLatch.await(); // Wait for thread to start diff --git a/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java b/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java index bda522e0f..f68879271 100644 --- a/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java +++ b/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java @@ -143,11 +143,21 @@ class StringToArrayListPropertyEditorTest { } @Test - void testSetAsText_InvalidStructure() { - // Arrange - this JSON doesn't match RedactionArea structure + void testSetAsText_UnknownProperties() { + // Arrange - this JSON contains properties not in RedactionArea + // With FAIL_ON_UNKNOWN_PROPERTIES disabled, this should ignore the unknown properties String json = "[{\"invalid\":\"structure\"}]"; - // Act & Assert - assertThrows(IllegalArgumentException.class, () -> editor.setAsText(json)); + // Act + 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 list = (List) value; + assertEquals(1, list.size(), "List should have 1 entry (empty object)"); } } diff --git a/app/core/build.gradle b/app/core/build.gradle index b75eaeea0..e03c487d5 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -46,8 +46,23 @@ dependencies { implementation project(':common') implementation 'org.springframework.boot:spring-boot-starter-jetty' - implementation 'com.posthog.java:posthog:1.2.0' - implementation 'org.telegram:telegrambots:6.9.7.1' + implementation 'org.eclipse.jetty.http2:jetty-http2-server' + 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 "org.bouncycastle:bcprov-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 'org.apache.poi:poi-ooxml:5.5.1' - // Batik - implementation 'org.apache.xmlgraphics:batik-all:1.19' + // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom) + // 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 implementation 'de.rototor.pdfbox:graphics2d:3.0.5' diff --git a/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java b/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java index 2be2a082c..78abea7ff 100644 --- a/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java +++ b/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java @@ -49,8 +49,8 @@ public class LibreOfficeListener { process = SystemCommand.runCommand(Runtime.getRuntime(), "unoconv --listener"); lastActivityTime = System.currentTimeMillis(); - // Start a background thread to monitor the activity timeout - executorService = Executors.newSingleThreadExecutor(); + // Start a virtual thread to monitor the activity timeout + executorService = Executors.newVirtualThreadPerTaskExecutor(); executorService.submit( () -> { while (true) { diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index 525b61e4d..c8258668d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -13,7 +13,7 @@ import java.util.regex.Pattern; import org.springframework.boot.SpringApplication; 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.core.env.Environment; import org.springframework.scheduling.annotation.EnableScheduling; @@ -176,11 +176,14 @@ public class SPDFApplication { } @EventListener - public void onWebServerInitialized(WebServerInitializedEvent event) { - int actualPort = event.getWebServer().getPort(); - serverPortStatic = String.valueOf(actualPort); + public void onApplicationReady(ApplicationReadyEvent event) { + String port = + event.getApplicationContext().getEnvironment().getProperty("local.server.port"); + if (port != null) { + serverPortStatic = port; + } // 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() { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 041439232..1a75c60f3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -51,9 +51,7 @@ public class ExternalAppDepConfig { */ private final Map> commandToGroupMapping; - private final ExecutorService pool = - Executors.newFixedThreadPool( - Math.max(2, Runtime.getRuntime().availableProcessors() / 2)); + private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); public ExternalAppDepConfig( EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java index e57b3d5af..9625f9e67 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java @@ -1,7 +1,7 @@ package stirling.software.SPDF.config; 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.Configuration; import org.springframework.context.annotation.DependsOn; diff --git a/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java b/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java index a3feded74..9ec1bb37e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java @@ -54,8 +54,8 @@ public class TauriProcessMonitor { scheduler = Executors.newSingleThreadScheduledExecutor( r -> { - Thread t = new Thread(r, "tauri-process-monitor"); - t.setDaemon(true); + Thread t = + Thread.ofVirtual().name("tauri-process-monitor").unstarted(r); return t; }); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 58887e822..4622bdb68 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -154,8 +154,8 @@ public class WebMvcConfig implements WebMvcConfigurer { .maxAge(3600); } else { // Default to allowing all origins when nothing is configured - logger.info( - "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins."); + logger.debug( + "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); WebMvcConfig allowing all origins."); registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java index 3cdf7fa2b..c97ca1125 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java @@ -15,9 +15,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; 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 lombok.Getter; @@ -32,6 +29,9 @@ import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @GeneralApi @Slf4j @RequiredArgsConstructor diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index c60965f72..b9af74ddb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -16,9 +16,6 @@ import org.springframework.core.io.ResourceLoader; import org.springframework.http.ResponseEntity; 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 lombok.Data; @@ -35,6 +32,9 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @Slf4j @UiDataApi public class UIDataController { @@ -44,18 +44,21 @@ public class UIDataController { private final UserServiceInterface userService; private final ResourceLoader resourceLoader; private final RuntimePathConfig runtimePathConfig; + private final ObjectMapper objectMapper; public UIDataController( ApplicationProperties applicationProperties, SharedSignatureService signatureService, @Autowired(required = false) UserServiceInterface userService, ResourceLoader resourceLoader, - RuntimePathConfig runtimePathConfig) { + RuntimePathConfig runtimePathConfig, + ObjectMapper objectMapper) { this.applicationProperties = applicationProperties; this.signatureService = signatureService; this.userService = userService; this.resourceLoader = resourceLoader; this.runtimePathConfig = runtimePathConfig; + this.objectMapper = objectMapper; } @GetMapping("/footer-info") @@ -93,9 +96,8 @@ public class UIDataController { try (InputStream is = resource.getInputStream()) { String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); - ObjectMapper mapper = new ObjectMapper(); Map> licenseData = - mapper.readValue(json, new TypeReference<>() {}); + objectMapper.readValue(json, new TypeReference<>() {}); data.setDependencies(licenseData.get("dependencies")); } catch (IOException e) { log.error("Failed to load licenses data", e); @@ -127,8 +129,8 @@ public class UIDataController { for (String config : pipelineConfigs) { Map jsonContent = - new ObjectMapper() - .readValue(config, new TypeReference>() {}); + objectMapper.readValue( + config, new TypeReference>() {}); String name = (String) jsonContent.get("name"); if (name == null || name.length() < 1) { String filename = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java index 6328e2eab..492febc49 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java @@ -91,22 +91,32 @@ public class ConvertOfficeController { Path libreOfficeProfile = null; try { - ProcessExecutorResult result; - // Run Unoconvert command - if (isUnoconvertAvailable()) { - // Unoconvert: schreibe direkt in outputPath innerhalb des workDir - List command = new ArrayList<>(); - command.add(runtimePathConfig.getUnoConvertPath()); - command.add("--convert-to"); - command.add("pdf"); - command.add(inputPath.toString()); - command.add(outputPath.toString()); + ProcessExecutorResult result = null; + IOException unoconvertException = null; - result = - ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE) - .runCommandWithOutputHandling(command); - } // Run soffice command - else { + // Try unoconvert first if available + if (isUnoconvertAvailable()) { + try { + List command = new ArrayList<>(); + 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_"); List command = new ArrayList<>(); command.add(runtimePathConfig.getSOfficePath()); @@ -114,14 +124,21 @@ public class ConvertOfficeController { command.add("--headless"); command.add("--nologo"); command.add("--convert-to"); - command.add("pdf:writer_pdf_Export"); + command.add("pdf"); command.add("--outdir"); command.add(workDir.toString()); command.add(inputPath.toString()); - result = - ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE) - .runCommandWithOutputHandling(command); + try { + result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE) + .runCommandWithOutputHandling(command); + } catch (IOException e) { + if (unoconvertException != null) { + e.addSuppressed(unoconvertException); + } + throw e; + } } // Check the result diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java index 60b5679c9..bafaf8631 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java @@ -70,10 +70,7 @@ public class ConvertPDFToExcelController { tables.size() == 1 ? String.format(Locale.ROOT, "Page %d", pageNum) : String.format( - Locale.ROOT, - "Page %d Table %d", - pageNum, - tableIdx + 1); + Locale.ROOT, "Page %d Table %d", pageNum, tableIdx + 1); sheetName = getUniqueSheetName(workbook, sheetName); Sheet sheet = workbook.createSheet(sheetName); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java index c82fe19e1..a7d9d8c5b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java @@ -9,13 +9,13 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.exception.CacheUnavailableException; +import tools.jackson.databind.ObjectMapper; + @ControllerAdvice(assignableTypes = ConvertPdfJsonController.class) @Slf4j @RequiredArgsConstructor diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index 094ed091a..5c96c7030 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -19,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.opencsv.CSVWriter; 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.WebResponseUtils; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @RestController @RequestMapping("/api/v1/form") @Tag( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f9cd97811..6c0c40ef0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -1,6 +1,5 @@ package stirling.software.SPDF.controller.api.form; -import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -8,13 +7,14 @@ import java.util.List; import java.util.Map; 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.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 { private static final String KEY_FIELDS = "fields"; @@ -32,8 +32,7 @@ final class FormPayloadParser { private FormPayloadParser() {} - static Map parseValueMap(ObjectMapper objectMapper, String json) - throws IOException { + static Map parseValueMap(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return Map.of(); } @@ -41,7 +40,7 @@ final class FormPayloadParser { JsonNode root; try { root = objectMapper.readTree(json); - } catch (IOException e) { + } catch (JacksonException e) { // Fallback to legacy direct map parse (will throw again if invalid) return objectMapper.readValue(json, MAP_TYPE); } @@ -88,14 +87,14 @@ final class FormPayloadParser { } static List parseModificationDefinitions( - ObjectMapper objectMapper, String json) throws IOException { + ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); } return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } - static List parseNameList(ObjectMapper objectMapper, String json) throws IOException { + static List parseNameList(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); } @@ -119,7 +118,7 @@ final class FormPayloadParser { } } } else if (root.isTextual()) { - final String single = trimToNull(root.asText()); + final String single = trimToNull(root.asText("")); if (single != null) { names.add(single); } @@ -131,7 +130,7 @@ final class FormPayloadParser { try { return objectMapper.readValue(json, STRING_LIST_TYPE); - } catch (IOException e) { + } catch (JacksonException e) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidFormat", "Invalid {0} format: {1}", @@ -199,7 +198,7 @@ final class FormPayloadParser { return null; } if (node.isTextual()) { - return trimToEmpty(node.asText()); + return trimToEmpty(node.asText("")); } if (node.isNumber()) { return node.numberValue().toString(); @@ -208,7 +207,7 @@ final class FormPayloadParser { return Boolean.toString(node.booleanValue()); } // Fallback for other scalar-like nodes - return trimToEmpty(node.asText()); + return trimToEmpty(node.asText("")); } private static void collectNames(JsonNode arrayNode, Set sink) { @@ -229,7 +228,7 @@ final class FormPayloadParser { } if (node.isTextual()) { - return trimToNull(node.asText()); + return trimToNull(node.asText("")); } if (node.isObject()) { @@ -264,8 +263,8 @@ final class FormPayloadParser { private static Map objectToLinkedMap(JsonNode objectNode) { final Map result = new LinkedHashMap<>(); objectNode - .fieldNames() - .forEachRemaining( + .propertyNames() + .forEach( key -> { final JsonNode v = objectNode.get(key); if (v == null || v.isNull()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java index 0f2ea431c..385e19ffa 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java @@ -82,9 +82,8 @@ public class ExtractImagesController { Set processedImages = new HashSet<>(); if (useMultithreading) { - // Executor service to handle multithreading - ExecutorService executor = - Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); + // Virtual thread executor — lightweight threads ideal for I/O-bound image extraction + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); Set> futures = new HashSet<>(); // Safely iterate over each page, handling corrupt PDFs where page count might be wrong diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index 7575261fb..cdb7c5384 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -246,10 +246,12 @@ public class OCRController { command.add("--clean-final"); } if (ocrType != null && !ocrType.isEmpty()) { - if ("skip-text".equals(ocrType)) { - command.add("--skip-text"); - } else if ("force-ocr".equals(ocrType)) { + if ("force-ocr".equals(ocrType)) { 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"); @@ -378,11 +380,12 @@ public class OCRController { List command = new ArrayList<>(); command.add("tesseract"); command.add(imagePath.toString()); - command.add( + String outputBase = new File( tempOutputDir, String.format(Locale.ROOT, "page_%d", pageNum)) - .toString()); + .toString(); + command.add(outputBase); command.add("-l"); command.add(String.join("+", selectedLanguages)); command.add("pdf"); // Always output PDF @@ -400,6 +403,18 @@ public class OCRController { 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 merger.addSource(pageOutputPath); } else { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java index 65e4ee1d0..c9387d889 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java @@ -14,10 +14,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; 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 lombok.RequiredArgsConstructor; @@ -34,6 +30,10 @@ import stirling.software.common.service.PostHogService; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.DatabindException; +import tools.jackson.databind.ObjectMapper; + @PipelineApi @Slf4j @RequiredArgsConstructor @@ -54,7 +54,7 @@ public class PipelineController { + "Users provide files and a JSON configuration defining the sequence of operations to perform. " + "Input:PDF Output:PDF/ZIP Type:MIMO") public ResponseEntity handleData(@ModelAttribute HandleDataRequest request) - throws JsonMappingException, JsonProcessingException { + throws DatabindException, JacksonException { MultipartFile[] files = request.getFileInput(); String jsonString = request.getJson(); if (files == null) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java index 8093f2fef..7892f7147 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java @@ -28,8 +28,6 @@ import org.springframework.core.io.Resource; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.extern.slf4j.Slf4j; 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.util.FileMonitor; +import tools.jackson.databind.ObjectMapper; + @Service @Slf4j public class PipelineDirectoryProcessor { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index 223d696ef..db4f32ecb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -47,10 +47,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; 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 lombok.RequiredArgsConstructor; @@ -66,6 +62,11 @@ import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.RegexPatternUtils; 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 @Slf4j @RequiredArgsConstructor @@ -77,7 +78,7 @@ public class GetInfoOnPDF { private static final String PAGE_PREFIX = "Page "; 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 VeraPDFService veraPDFService; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index 879f0a3ee..be50b059e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.web; +import java.io.IOException; +import java.io.InputStream; import java.time.Duration; import java.time.LocalDateTime; import java.util.*; @@ -54,10 +56,27 @@ public class MetricsController { } Map status = new HashMap<>(); 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); } + 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") @Operation( summary = "GET request count", diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java index 3244144aa..8ef865bd4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java +++ b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java @@ -190,6 +190,31 @@ public class GlobalExceptionHandler { 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 * codes. @@ -1107,6 +1132,15 @@ public class GlobalExceptionHandler { public ResponseEntity handleIOException( 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 IOException processedException = ExceptionUtils.handlePdfException(ex, request.getRequestURI()); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java b/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java index ba4a62eeb..79a910300 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java @@ -3,10 +3,10 @@ package stirling.software.SPDF.model; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.databind.JsonNode; - import lombok.Getter; +import tools.jackson.databind.JsonNode; + public class ApiEndpoint { private final String name; private Map parameters; @@ -18,10 +18,10 @@ public class ApiEndpoint { postNode.path("parameters") .forEach( paramNode -> { - String paramName = paramNode.path("name").asText(); + String paramName = paramNode.path("name").asText(""); parameters.put(paramName, paramNode); }); - this.description = postNode.path("description").asText(); + this.description = postNode.path("description").asText(""); } public boolean areParametersValid(Map providedParams) { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java b/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java index 787894a43..e60c74e22 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java @@ -15,9 +15,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.servlet.ServletContext; 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.util.RegexPatternUtils; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + @Service @Slf4j public class ApiDocService { @@ -36,12 +36,15 @@ public class ApiDocService { private final ServletContext servletContext; private final UserServiceInterface userService; + private final ObjectMapper objectMapper; Map> outputToFileTypes = new HashMap<>(); JsonNode apiDocsJsonRootNode; public ApiDocService( + ObjectMapper objectMapper, ServletContext servletContext, @Autowired(required = false) UserServiceInterface userService) { + this.objectMapper = objectMapper; this.servletContext = servletContext; this.userService = userService; } @@ -116,8 +119,7 @@ public class ApiDocService { ResponseEntity response = restTemplate.exchange(getApiDocsUrl(), HttpMethod.GET, entity, String.class); apiDocsJson = response.getBody(); - ObjectMapper mapper = new ObjectMapper(); - apiDocsJsonRootNode = mapper.readTree(apiDocsJson); + apiDocsJsonRootNode = objectMapper.readTree(apiDocsJson); JsonNode paths = apiDocsJsonRootNode.path("paths"); paths.propertyStream() .forEach( diff --git a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java index 3f02bd430..026a48b81 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java @@ -427,7 +427,8 @@ public class CertificateValidationService { try (InputStream certStream = getClass().getClassLoader().getResourceAsStream("certs/cacert.pem")) { 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; } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java index ae8e7f706..d0a80aa15 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java @@ -90,8 +90,6 @@ import org.apache.pdfbox.util.Matrix; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.annotation.PostConstruct; 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.TempFileManager; +import tools.jackson.databind.ObjectMapper; + @Slf4j @Service @RequiredArgsConstructor @@ -6834,7 +6834,8 @@ public class PdfJsonConversionService { /** Schedules automatic cleanup of cached documents after 30 minutes. */ private void scheduleDocumentCleanup(String jobId) { - new Thread( + Thread.ofVirtual() + .start( () -> { try { Thread.sleep(TimeUnit.MINUTES.toMillis(30)); @@ -6843,7 +6844,6 @@ public class PdfJsonConversionService { } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - }) - .start(); + }); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java index 53d43d26c..400c59e11 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java @@ -15,8 +15,6 @@ import java.util.stream.Stream; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.extern.slf4j.Slf4j; 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.common.configuration.InstallationPathConfig; +import tools.jackson.databind.ObjectMapper; + @Service @Slf4j public class SharedSignatureService { @@ -33,9 +33,9 @@ public class SharedSignatureService { private final String ALL_USERS_FOLDER = "ALL_USERS"; private final ObjectMapper objectMapper; - public SharedSignatureService() { + public SharedSignatureService(ObjectMapper objectMapper) { SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath(); - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; } public boolean hasAccessToFile(String username, String fileName) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingService.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingService.java index cd843ee07..97748fa97 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingService.java @@ -18,8 +18,6 @@ import org.apache.pdfbox.pdmodel.font.PDFont; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.Data; import lombok.RequiredArgsConstructor; 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.util.ExceptionUtils; +import tools.jackson.databind.ObjectMapper; + /** * Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce * memory usage for large PDFs. @@ -255,7 +255,8 @@ public class PdfLazyLoadingService { /** Schedules automatic cleanup of cached documents after 30 minutes. */ private void scheduleDocumentCleanup(String jobId) { - new Thread( + Thread.ofVirtual() + .start( () -> { try { Thread.sleep(TimeUnit.MINUTES.toMillis(30)); @@ -264,8 +265,7 @@ public class PdfLazyLoadingService { } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - }) - .start(); + }); } /** diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java index a061cff5e..0c885dfb2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java @@ -18,14 +18,14 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @Slf4j @Component @RequiredArgsConstructor @@ -202,20 +202,34 @@ public class Type3FontLibrary { if (payload == null) { return null; } - byte[] data = null; + String base64; 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 { - 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) { 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()) { - data = loadResourceBytes(payload.resource); - } - if (data == null || data.length == 0) { + byte[] data = loadResourceBytes(payload.resource); + if (data == null || data.length == 0) { + return null; + } + base64 = Base64.getEncoder().encodeToString(data); + } else { return null; } - String base64 = Base64.getEncoder().encodeToString(data); return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format)); } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureTool.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureTool.java index 968717411..ff1836913 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureTool.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureTool.java @@ -26,14 +26,15 @@ import org.apache.pdfbox.pdmodel.font.PDType3Font; import org.apache.pdfbox.pdmodel.graphics.PDXObject; 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.Type3GlyphExtractor; 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 * 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 { private static final ObjectMapper OBJECT_MAPPER = - new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); private Type3SignatureTool() {} diff --git a/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java b/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java index 1cadc1dbe..54be5ca0e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java +++ b/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java @@ -97,7 +97,7 @@ public class SvgToPdf { private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc) throws IOException { GVTBuilder builder = new GVTBuilder(); - ExecutorService executor = Executors.newSingleThreadExecutor(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); Callable buildTask = () -> builder.build(ctx, svgDoc); Future future = executor.submit(buildTask); diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index f3ef8d01f..fe54dd194 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -13,6 +13,18 @@ logging.level.stirling.software.common.service.JobExecutorService=INFO logging.level.stirling.software.common.service.TaskManager=INFO spring.jpa.open-in-view=false 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.whitelabel.enabled=false server.error.include-stacktrace=always @@ -38,7 +50,7 @@ spring.devtools.livereload.enabled=true spring.devtools.restart.exclude=stirling.software.proprietary.security/** spring.web.resources.mime-mappings.webmanifest=application/manifest+json 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.driver-class-name=org.h2.Driver diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java index e07090e7a..9ff4687d8 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java @@ -27,13 +27,13 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; 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.model.api.EditTableOfContentsRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @ExtendWith(MockitoExtension.class) class EditTableOfContentsControllerTest { diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java index 3682ede86..efc09cf19 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java @@ -40,14 +40,15 @@ import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; 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.service.VeraPDFService; import stirling.software.common.model.api.PDFFile; 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") @ExtendWith(MockitoExtension.class) class GetInfoOnPDFTest { @@ -64,7 +65,7 @@ class GetInfoOnPDFTest { @BeforeEach void setUp() { - objectMapper = new ObjectMapper(); + objectMapper = JsonMapper.builder().build(); } /** Helper method to load a PDF file from test resources */ @@ -216,8 +217,8 @@ class GetInfoOnPDFTest { Assertions.assertTrue(jsonNode.has("Permissions")); JsonNode metadata = jsonNode.get("Metadata"); - Assertions.assertEquals("Test Title", metadata.get("Title").asText()); - Assertions.assertEquals("Test Author", metadata.get("Author").asText()); + Assertions.assertEquals("Test Title", metadata.get("Title").asText("")); + Assertions.assertEquals("Test Author", metadata.get("Author").asText("")); } } @@ -315,12 +316,12 @@ class GetInfoOnPDFTest { JsonNode jsonNode = objectMapper.readTree(jsonResponse); JsonNode metadata = jsonNode.get("Metadata"); - Assertions.assertEquals("Test Title", metadata.get("Title").asText()); - Assertions.assertEquals("Test Author", metadata.get("Author").asText()); - Assertions.assertEquals("Test Subject", metadata.get("Subject").asText()); - Assertions.assertEquals("test, pdf, metadata", metadata.get("Keywords").asText()); - Assertions.assertEquals("Test Creator", metadata.get("Creator").asText()); - Assertions.assertEquals("Test Producer", metadata.get("Producer").asText()); + Assertions.assertEquals("Test Title", metadata.get("Title").asText("")); + Assertions.assertEquals("Test Author", metadata.get("Author").asText("")); + Assertions.assertEquals("Test Subject", metadata.get("Subject").asText("")); + Assertions.assertEquals("test, pdf, metadata", metadata.get("Keywords").asText("")); + Assertions.assertEquals("Test Creator", metadata.get("Creator").asText("")); + Assertions.assertEquals("Test Producer", metadata.get("Producer").asText("")); Assertions.assertTrue(metadata.has("CreationDate")); Assertions.assertTrue(metadata.has("ModificationDate")); @@ -512,10 +513,10 @@ class GetInfoOnPDFTest { JsonNode page1 = perPageInfo.get("Page 1"); Assertions.assertTrue(page1.has("Size")); 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"); - Assertions.assertEquals("Letter", page2.get("Size").get("Standard Page").asText()); + Assertions.assertEquals("Letter", page2.get("Size").get("Standard Page").asText("")); loadedDoc.close(); } @@ -569,7 +570,8 @@ class GetInfoOnPDFTest { JsonNode jsonNode = objectMapper.readTree(jsonResponse); 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 @@ -645,7 +647,7 @@ class GetInfoOnPDFTest { Assertions.assertTrue(jsonNode.has("error")); Assertions.assertTrue( - jsonNode.get("error").asText().contains("exceeds maximum allowed size")); + jsonNode.get("error").asText("").contains("exceeds maximum allowed size")); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java b/app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java index 769aa6b9b..85e1587a5 100644 --- a/app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java @@ -7,14 +7,15 @@ import java.util.Map; import org.junit.jupiter.api.Test; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; class ApiEndpointTest { - private final ObjectMapper mapper = new ObjectMapper(); + private final ObjectMapper mapper = JsonMapper.builder().build(); private JsonNode postNodeWithParams(String description, String... names) { ObjectNode post = mapper.createObjectNode(); diff --git a/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java index 2317abfb9..baa6d3f2f 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java @@ -12,14 +12,15 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.servlet.ServletContext; import stirling.software.SPDF.model.ApiEndpoint; 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) class ApiDocServiceTest { @@ -27,11 +28,11 @@ class ApiDocServiceTest { @Mock UserServiceInterface userService; ApiDocService apiDocService; - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = JsonMapper.builder().build(); @BeforeEach void setUp() { - apiDocService = new ApiDocService(servletContext, userService); + apiDocService = new ApiDocService(mapper, servletContext, userService); } private void setApiDocumentation(Map docs) throws Exception { diff --git a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java index 8c8ce97cf..e5d657aac 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java @@ -20,6 +20,8 @@ import org.mockito.MockedStatic; import stirling.software.SPDF.model.SignatureFile; import stirling.software.common.configuration.InstallationPathConfig; +import tools.jackson.databind.json.JsonMapper; + class SignatureServiceTest { @TempDir Path tempDir; @@ -53,7 +55,7 @@ class SignatureServiceTest { .thenReturn(tempDir.toString()); // Initialize the service with our temp directory - signatureService = new SharedSignatureService(); + signatureService = new SharedSignatureService(JsonMapper.builder().build()); } } diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index c05108266..4407ca63e 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -45,7 +45,7 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-jetty' api 'org.springframework.boot:spring-boot-starter-security' 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-cache' api 'com.github.ben-manes.caffeine:caffeine' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java index 54610f80a..aa79f9b05 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java @@ -2,21 +2,22 @@ package stirling.software.proprietary.config; import java.util.Map; import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import org.slf4j.MDC; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.task.TaskDecorator; +import org.springframework.core.task.support.TaskExecutorAdapter; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration @EnableAsync public class AsyncConfig { /** - * MDC context-propagating task decorator Copies MDC context from the caller thread to the async - * executor thread + * MDC context-propagating task decorator. Copies MDC context from the caller thread to the + * virtual thread executing the task. */ static class MDCContextTaskDecorator implements TaskDecorator { @Override @@ -42,16 +43,9 @@ public class AsyncConfig { @Bean(name = "auditExecutor") public Executor auditExecutor() { - ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); - exec.setCorePoolSize(2); - exec.setMaxPoolSize(8); - exec.setQueueCapacity(1_000); - exec.setThreadNamePrefix("audit-"); - - // Set the task decorator to propagate MDC context - exec.setTaskDecorator(new MDCContextTaskDecorator()); - - exec.initialize(); - return exec; + TaskExecutorAdapter adapter = + new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + adapter.setTaskDecorator(new MDCContextTaskDecorator()); + return adapter; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java index e69ef8c3c..1ff8f4eb9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java @@ -1,15 +1,12 @@ package stirling.software.proprietary.config; import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; -/** Configuration to enable scheduling for the audit system. */ +/** Configuration for audit system transaction management. */ @Configuration @EnableTransactionManagement -@EnableScheduling public class AuditJpaConfig { - // This configuration enables scheduling for audit cleanup tasks - // JPA repositories are now managed by DatabaseConfig to avoid conflicts - // No additional beans or methods needed + // Scheduling is enabled on SPDFApplication — no duplicate @EnableScheduling needed. + // JPA repositories are now managed by DatabaseConfig to avoid conflicts. } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java index c5ed53bf6..1dd8d5f29 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java @@ -12,8 +12,6 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; 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.util.SecretMasker; +import tools.jackson.databind.ObjectMapper; + @Component @Primary @RequiredArgsConstructor @@ -68,7 +68,10 @@ public class CustomAuditEventRepository implements AuditEventRepository { .build(); repo.save(ent); } catch (Exception e) { - e.printStackTrace(); // fail-open + log.error( + "Failed to persist audit event (fail-open); principal={}", + ev.getPrincipal(), + e); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java index a37d7f7a7..4a2f2df00 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java @@ -28,9 +28,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; 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.media.Schema; 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.security.config.EnterpriseEndpoint; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + /** REST endpoints for the audit dashboard. */ @Slf4j @RestController @@ -266,7 +266,7 @@ public class AuditDashboardController { headers.setContentDispositionFormData("attachment", "audit_export.json"); return ResponseEntity.ok().headers(headers).body(jsonBytes); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Error serializing audit events to JSON", e); return ResponseEntity.internalServerError().build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java index d3a176b68..468943974 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java @@ -20,9 +20,6 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; 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.security.config.EnterpriseEndpoint; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + /** REST API controller for audit data used by React frontend. */ @Slf4j @ProprietaryUiDataApi @@ -332,7 +332,7 @@ public class AuditRestController { @SuppressWarnings("unchecked") Map parsed = objectMapper.readValue(event.getData(), Map.class); details = parsed; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn("Failed to parse audit event data as JSON: {}", event.getData()); details.put("rawData", event.getData()); } @@ -380,7 +380,7 @@ public class AuditRestController { headers.setContentDispositionFormData("attachment", "audit_export.json"); return ResponseEntity.ok().headers(headers).body(jsonBytes); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Error serializing audit events to JSON", e); return ResponseEntity.internalServerError().build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index ecf7b990c..d41116a6b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -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.PathVariable; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.swagger.v3.oas.annotations.Operation; 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.service.UserLicenseSettingsService; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + @Slf4j @ProprietaryUiDataApi public class ProprietaryUIDataController { @@ -414,7 +414,7 @@ public class ProprietaryUIDataController { String settingsJson; try { settingsJson = objectMapper.writeValueAsString(user.get().getSettings()); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Error converting settings map", e); return ResponseEntity.status(500).build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 0baa1c6b1..3afdf0d7b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -8,9 +8,6 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; 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.security.config.EnterpriseEndpoint; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + /** REST API controller for usage analytics data used by React frontend. */ @Slf4j @ProprietaryUiDataApi @@ -135,7 +135,7 @@ public class UsageRestController { return normalizeEndpoint(requestUri.toString()); } - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.debug("Failed to parse audit data JSON: {}", dataJson, e); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 4762fa8a9..620f4a22c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -6,9 +6,9 @@ import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Qualifier; 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.DatabaseDriver; +import org.springframework.boot.persistence.autoconfigure.EntityScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -75,10 +75,18 @@ public class DatabaseConfig { } 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"); dataSourceBuilder - .url(DATASOURCE_DEFAULT_URL) + .url(url) .driverClassName(DatabaseDriver.H2.getDriverClassName()) .username(DEFAULT_USERNAME); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index f3a8f25b5..64c92488b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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.web.builders.HttpSecurity; 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.HeadersConfigurer.FrameOptionsConfig; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; 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.web.authentication.OpenSaml4AuthenticationRequestResolver; +import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; @@ -82,7 +84,7 @@ public class SecurityConfiguration { private final PersistentLoginRepository persistentLoginRepository; private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations; - private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver; + private final OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver; private final stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; private final ClientRegistrationRepository clientRegistrationRepository; @@ -105,7 +107,7 @@ public class SecurityConfiguration { @Autowired(required = false) RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations, @Autowired(required = false) - OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver, + OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver, @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository, stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService) { @@ -151,7 +153,8 @@ public class SecurityConfiguration { // Default to allowing all origins when nothing is configured cfg.setAllowedOriginPatterns(List.of("*")); 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) @@ -225,7 +228,7 @@ public class SecurityConfiguration { http.cors(cors -> cors.configurationSource(corsSource)); } else { // Explicitly disable CORS when no origins are configured - http.cors(cors -> cors.disable()); + http.cors(CorsConfigurer::disable); } http.csrf(CsrfConfigurer::disable); @@ -233,24 +236,24 @@ public class SecurityConfiguration { // Configure X-Frame-Options based on settings.yml configuration // When login is disabled, automatically disable X-Frame-Options to allow embedding if (!loginEnabledValue) { - http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable())); + http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable)); } else { String xFrameOption = securityProperties.getXFrameOptions(); if (xFrameOption != null) { http.headers( headers -> { if ("DISABLED".equalsIgnoreCase(xFrameOption)) { - headers.frameOptions(frameOptions -> frameOptions.disable()); + headers.frameOptions(FrameOptionsConfig::disable); } else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) { - headers.frameOptions(frameOptions -> frameOptions.sameOrigin()); + headers.frameOptions(FrameOptionsConfig::sameOrigin); } else { // Default to DENY - headers.frameOptions(frameOptions -> frameOptions.deny()); + headers.frameOptions(FrameOptionsConfig::deny); } }); } else { // 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 if (securityProperties.isSaml2Active() && runningProOrHigher) { - OpenSaml4AuthenticationProvider authenticationProvider = - new OpenSaml4AuthenticationProvider(); + OpenSaml5AuthenticationProvider authenticationProvider = + new OpenSaml5AuthenticationProvider(); authenticationProvider.setResponseAuthenticationConverter( new CustomSaml2ResponseAuthenticationConverter(userService)); http.authenticationProvider(authenticationProvider) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java index 3fe63b3f3..1680aa928 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java @@ -12,11 +12,6 @@ import org.bouncycastle.crypto.signers.Ed25519Signer; import org.bouncycastle.util.encoders.Hex; 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.extern.slf4j.Slf4j; @@ -24,6 +19,9 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.RegexPatternUtils; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + @Service @Slf4j @RequiredArgsConstructor @@ -47,7 +45,7 @@ public class KeygenLicenseVerifier { private static final String JWT_PREFIX = "key/"; - private static final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; private final ApplicationProperties applicationProperties; // Shared HTTP client for connection pooling @@ -135,11 +133,11 @@ public class KeygenLicenseVerifier { String algorithm = ""; try { - JSONObject attrs = new JSONObject(payload); - encryptedData = (String) attrs.get("enc"); - encodedSignature = (String) attrs.get("sig"); - algorithm = (String) attrs.get("alg"); - } catch (JSONException e) { + JsonNode attrs = objectMapper.readTree(payload); + encryptedData = attrs.path("enc").asText(""); + encodedSignature = attrs.path("sig").asText(""); + algorithm = attrs.path("alg").asText(""); + } catch (Exception e) { log.error("Failed to parse license file: {}", e.getMessage()); return false; } @@ -215,11 +213,17 @@ public class KeygenLicenseVerifier { private boolean processCertificateData(String certData, LicenseContext context) { try { - JSONObject licenseData = new JSONObject(certData); - JSONObject metaObj = licenseData.optJSONObject("meta"); - if (metaObj != null) { - String issuedStr = metaObj.optString("issued", null); - String expiryStr = metaObj.optString("expiry", null); + JsonNode licenseData = objectMapper.readTree(certData); + JsonNode metaObj = licenseData.path("meta"); + if (!metaObj.isMissingNode() && metaObj.isObject()) { + String issuedStr = + 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) { java.time.Instant issued = java.time.Instant.parse(issuedStr); @@ -244,31 +248,32 @@ public class KeygenLicenseVerifier { } // Get the main license data - JSONObject dataObj = licenseData.optJSONObject("data"); - if (dataObj == null) { + JsonNode dataObj = licenseData.path("data"); + if (dataObj.isMissingNode() || !dataObj.isObject()) { log.error("No data object found in certificate"); return false; } // Extract license or machine information - JSONObject attributesObj = dataObj.optJSONObject("attributes"); - if (attributesObj != null) { + JsonNode attributesObj = dataObj.path("attributes"); + if (!attributesObj.isMissingNode() && attributesObj.isObject()) { log.info("Found attributes in certificate data"); // Check for floating license - context.isFloatingLicense = attributesObj.optBoolean("floating", false); - context.maxMachines = attributesObj.optInt("maxMachines", 1); + context.isFloatingLicense = attributesObj.path("floating").asBoolean(false); + context.maxMachines = attributesObj.path("maxMachines").asInt(1); // Extract metadata - JSONObject metadataObj = attributesObj.optJSONObject("metadata"); - if (metadataObj != null) { + JsonNode metadataObj = attributesObj.path("metadata"); + if (!metadataObj.isMissingNode() && metadataObj.isObject()) { // 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 // Old licenses: Only had isEnterprise flag // 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 // ENTERPRISE license (isEnterprise=true, users>0) = limited seats @@ -282,7 +287,7 @@ public class KeygenLicenseVerifier { } // Check license status if available - String status = attributesObj.optString("status", null); + String status = attributesObj.path("status").asText(null); if (status != null && !"ACTIVE".equals(status) && !"EXPIRING".equals(status)) { // Accept "EXPIRING" status as valid @@ -372,11 +377,11 @@ public class KeygenLicenseVerifier { try { log.info("Processing license payload: {}", payload); - JSONObject licenseData = new JSONObject(payload); + JsonNode licenseData = objectMapper.readTree(payload); - JSONObject licenseObj = licenseData.optJSONObject("license"); - if (licenseObj == null) { - String id = licenseData.optString("id", null); + JsonNode licenseObj = licenseData.path("license"); + if (licenseObj.isMissingNode() || !licenseObj.isObject()) { + String id = licenseData.path("id").asText(null); if (id != null) { log.info("Found license ID: {}", id); 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); // Check for floating license in license object - context.isFloatingLicense = licenseObj.optBoolean("floating", false); - context.maxMachines = licenseObj.optInt("maxMachines", 1); + context.isFloatingLicense = licenseObj.path("floating").asBoolean(false); + context.maxMachines = licenseObj.path("maxMachines").asInt(1); if (context.isFloatingLicense) { log.info("Detected floating license with max machines: {}", context.maxMachines); } // Check expiry date - String expiryStr = licenseObj.optString("expiry", null); + String expiryStr = licenseObj.path("expiry").asText(null); if (expiryStr != null && !"null".equals(expiryStr)) { java.time.Instant expiry = java.time.Instant.parse(expiryStr); java.time.Instant now = java.time.Instant.now(); @@ -413,9 +418,9 @@ public class KeygenLicenseVerifier { } // Extract account, product, policy info - JSONObject accountObj = licenseData.optJSONObject("account"); - if (accountObj != null) { - String accountId = accountObj.optString("id", "unknown"); + JsonNode accountObj = licenseData.path("account"); + if (!accountObj.isMissingNode() && accountObj.isObject()) { + String accountId = accountObj.path("id").asText("unknown"); log.info("License belongs to account: {}", accountId); // Verify this matches your expected account ID @@ -426,14 +431,14 @@ public class KeygenLicenseVerifier { } // Extract policy information if available - JSONObject policyObj = licenseData.optJSONObject("policy"); - if (policyObj != null) { - String policyId = policyObj.optString("id", "unknown"); + JsonNode policyObj = licenseData.path("policy"); + if (!policyObj.isMissingNode() && policyObj.isObject()) { + String policyId = policyObj.path("id").asText("unknown"); log.info("License uses policy: {}", policyId); // Check for floating license in policy - boolean policyFloating = policyObj.optBoolean("floating", false); - int policyMaxMachines = policyObj.optInt("maxMachines", 1); + boolean policyFloating = policyObj.path("floating").asBoolean(false); + int policyMaxMachines = policyObj.path("maxMachines").asInt(1); // Policy settings take precedence if (policyFloating) { @@ -445,16 +450,21 @@ public class KeygenLicenseVerifier { } // Extract max users and isEnterprise from policy or metadata - context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false); - int users = policyObj.optInt("users", -1); + context.isEnterpriseLicense = policyObj.path("isEnterprise").asBoolean(false); + int users = policyObj.path("users").asInt(-1); if (users == -1) { // Try to get users from metadata if not at policy level - Object metadataObj = policyObj.opt("metadata"); - if (metadataObj instanceof JSONObject metadata) { + JsonNode metadataObj = policyObj.path("metadata"); + if (!metadataObj.isMissingNode() && metadataObj.isObject()) { context.isEnterpriseLicense = - metadata.optBoolean("isEnterprise", context.isEnterpriseLicense); - users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0); + metadataObj + .path("isEnterprise") + .asBoolean(context.isEnterpriseLicense); + users = + metadataObj + .path("users") + .asInt(context.isEnterpriseLicense ? 1 : 0); } else { // Default based on license type users = context.isEnterpriseLicense ? 1 : 0; @@ -493,9 +503,9 @@ public class KeygenLicenseVerifier { validateLicense(licenseKey, machineFingerprint, context); if (validationResponse != null) { 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) { - String code = validationResponse.path("meta").path("code").asText(); + String code = validationResponse.path("meta").path("code").asText(""); log.info(code); if ("NO_MACHINE".equals(code) || "NO_MACHINES".equals(code) @@ -579,8 +589,8 @@ public class KeygenLicenseVerifier { JsonNode metaNode = jsonResponse.path("meta"); boolean isValid = metaNode.path("valid").asBoolean(); - String detail = metaNode.path("detail").asText(); - String code = metaNode.path("code").asText(); + String detail = metaNode.path("detail").asText(""); + String code = metaNode.path("code").asText(""); log.info("License validity: {}", isValid); log.info("Validation detail: {}", detail); @@ -604,7 +614,7 @@ public class KeygenLicenseVerifier { if (includedNode.isArray()) { for (JsonNode node : includedNode) { - if ("policies".equals(node.path("type").asText())) { + if ("policies".equals(node.path("type").asText(""))) { policyNode = node; break; } @@ -690,9 +700,9 @@ public class KeygenLicenseVerifier { for (JsonNode machine : machines) { if (machineFingerprint.equals( - machine.path("attributes").path("fingerprint").asText())) { + machine.path("attributes").path("fingerprint").asText(""))) { isCurrentMachineActivated = true; - currentMachineId = machine.path("id").asText(); + currentMachineId = machine.path("id").asText(""); log.info( "Current machine is already activated with ID: {}", currentMachineId); @@ -726,7 +736,7 @@ public class KeygenLicenseVerifier { java.time.Instant.parse(createdStr); if (oldestTime == null || createdTime.isBefore(oldestTime)) { oldestTime = createdTime; - oldestMachineId = machine.path("id").asText(); + oldestMachineId = machine.path("id").asText(""); } } catch (Exception e) { log.warn( @@ -740,7 +750,7 @@ public class KeygenLicenseVerifier { if (oldestMachineId == null) { log.warn( "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); @@ -770,35 +780,28 @@ public class KeygenLicenseVerifier { hostname = "Unknown"; } - JSONObject body = - new JSONObject() - .put( - "data", - new JSONObject() - .put("type", "machines") - .put( - "attributes", - new JSONObject() - .put("fingerprint", machineFingerprint) - .put( - "platform", - System.getProperty("os.name")) - .put("name", hostname)) - .put( - "relationships", - new JSONObject() - .put( - "license", - new JSONObject() - .put( - "data", - new JSONObject() - .put( - "type", - "licenses") - .put( - "id", - licenseId))))); + tools.jackson.databind.node.ObjectNode attributes = objectMapper.createObjectNode(); + attributes.put("fingerprint", machineFingerprint); + attributes.put("platform", System.getProperty("os.name")); + attributes.put("name", hostname); + + tools.jackson.databind.node.ObjectNode licenseRef = objectMapper.createObjectNode(); + licenseRef.put("type", "licenses"); + licenseRef.put("id", licenseId); + + tools.jackson.databind.node.ObjectNode licenseRelation = objectMapper.createObjectNode(); + licenseRelation.set("data", licenseRef); + + tools.jackson.databind.node.ObjectNode relationships = objectMapper.createObjectNode(); + relationships.set("license", licenseRelation); + + tools.jackson.databind.node.ObjectNode data = objectMapper.createObjectNode(); + data.put("type", "machines"); + data.set("attributes", attributes); + data.set("relationships", relationships); + + tools.jackson.databind.node.ObjectNode body = objectMapper.createObjectNode(); + body.set("data", data); HttpRequest request = HttpRequest.newBuilder() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index f92434fd9..8ad14287d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -27,9 +27,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; 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.responses.ApiResponse; 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.UpdateSettingsRequest; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @AdminApi @RequiredArgsConstructor @PreAuthorize("hasRole('ROLE_ADMIN')") @@ -543,7 +543,8 @@ public class AdminSettingsController { pendingChanges.clear(); // Give the HTTP response time to complete, then exit - new Thread( + Thread.ofVirtual() + .start( () -> { try { Thread.sleep(1000); @@ -554,8 +555,7 @@ public class AdminSettingsController { log.error("Restart interrupted: {}", e.getMessage(), e); Thread.currentThread().interrupt(); } - }) - .start(); + }); return ResponseEntity.ok( Map.of( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java index 3af99ab60..ac2051196 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java @@ -20,9 +20,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; 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 lombok.Data; @@ -31,6 +28,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.RuntimePathConfig; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @Slf4j @RestController @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 final RuntimePathConfig runtimePathConfig; + private final ObjectMapper objectMapper; private static volatile List cachedRemoteTessdata = null; private static volatile long cachedRemoteTessdataExpiry = 0L; 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()) { - ObjectMapper mapper = new ObjectMapper(); List> items = - mapper.readValue(is, new TypeReference>>() {}); + objectMapper.readValue( + is, new TypeReference>>() {}); List languages = items.stream() .map(item -> (String) item.get("name")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java index 1db14aaaf..c969704ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java @@ -11,7 +11,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { private Object credentials; public ApiKeyAuthenticationToken(String apiKey) { - super(null); + super((Collection) null); this.principal = null; this.credentials = apiKey; setAuthenticated(false); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index e8326c1e3..f95e2cbc2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -14,7 +14,7 @@ import org.opensaml.saml.saml2.core.AuthnStatement; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.convert.converter.Converter; 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 lombok.RequiredArgsConstructor; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index 411ffe107..f42fc562d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -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.RelyingPartyRegistrationRepository; 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; @@ -151,10 +151,10 @@ public class Saml2Configuration { @Bean @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") - public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver( + public OpenSaml5AuthenticationRequestResolver authenticationRequestResolver( RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { - OpenSaml4AuthenticationRequestResolver resolver = - new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); + OpenSaml5AuthenticationRequestResolver resolver = + new OpenSaml5AuthenticationRequestResolver(relyingPartyRegistrationRepository); resolver.setRelayStateResolver( request -> { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index a551ab907..382d6273f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -20,9 +20,6 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; 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.ExpiredJwtException; 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.saml2.CustomSaml2AuthenticatedPrincipal; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + @Slf4j @Service public class JwtService implements JwtServiceInterface { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + private final ObjectMapper objectMapper; private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; private final ApplicationProperties.Security securityProperties; @Autowired public JwtService( + ObjectMapper objectMapper, @Qualifier("v2Enabled") boolean v2Enabled, KeyPersistenceServiceInterface keyPersistenceService, ApplicationProperties applicationProperties) { + this.objectMapper = objectMapper; this.v2Enabled = v2Enabled; this.keyPersistenceService = keyPersistenceService; this.securityProperties = applicationProperties.getSecurity(); @@ -374,14 +375,14 @@ public class JwtService implements JwtServiceInterface { byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]); Map header = - OBJECT_MAPPER.readValue( + objectMapper.readValue( headerBytes, new TypeReference>() {}); Object keyId = header.get("kid"); return keyId instanceof String ? (String) keyId : null; } catch (IllegalArgumentException e) { log.debug("Failed to decode Base64 JWT header: {}", e.getMessage()); return null; - } catch (java.io.IOException e) { + } catch (tools.jackson.core.JacksonException e) { log.debug("Failed to parse JWT header as JSON: {}", e.getMessage()); return null; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java index e4956b37f..fc8fe5937 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java @@ -15,8 +15,6 @@ import java.util.stream.Stream; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.extern.slf4j.Slf4j; 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.SavedSignatureResponse; +import tools.jackson.databind.ObjectMapper; + /** * Service for managing user signatures with authentication and storage limits. This proprietary * 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 final String SIGNATURE_BASE_PATH; private final String ALL_USERS_FOLDER = "ALL_USERS"; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; // Storage limits per user 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_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user - public SignatureService() { + public SignatureService(ObjectMapper objectMapper) { SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath(); + this.objectMapper = objectMapper; } /** diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java index a4704adbb..906802fe2 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -16,8 +16,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; 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.service.UserLicenseSettingsService; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + @ExtendWith(MockitoExtension.class) class ProprietaryUIDataControllerTest { @@ -63,7 +64,7 @@ class ProprietaryUIDataControllerTest { applicationProperties.getSecurity().getSaml2().setEnabled(false); auditConfig = new AuditConfigurationProperties(applicationProperties); - objectMapper = new ObjectMapper(); + objectMapper = JsonMapper.builder().build(); controller = new ProprietaryUIDataController( diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java index fd1a99e7b..6b89e91e7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -26,8 +26,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; 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.enumeration.Role; 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.UserService; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + @ExtendWith(MockitoExtension.class) class AuthControllerLoginTest { - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = JsonMapper.builder().build(); private MockMvc mockMvc; private ApplicationProperties.Security securityProperties; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java index d91931364..6a5dc6599 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java @@ -26,8 +26,6 @@ import org.springframework.security.core.Authentication; import org.springframework.test.web.servlet.MockMvc; 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.User; 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.UserService; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + @ExtendWith(MockitoExtension.class) class AuthControllerMfaTest { private static final String USERNAME = "user@example.com"; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = JsonMapper.builder().build(); private MockMvc mockMvc; private Authentication authentication; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UIDataTessdataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UIDataTessdataControllerTest.java index 6e57c6c39..84ecb5e70 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UIDataTessdataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UIDataTessdataControllerTest.java @@ -19,6 +19,8 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.configuration.RuntimePathConfig; +import tools.jackson.databind.json.JsonMapper; + class UIDataTessdataControllerTest { @Test @@ -27,7 +29,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn("ignored/path"); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -49,7 +51,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -77,7 +79,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -100,7 +102,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng", "fra"); @@ -139,7 +141,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -164,7 +166,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected boolean isWritableDirectory(Path dir) { return false; @@ -186,7 +188,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -217,7 +219,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -258,7 +260,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng", "fra"); @@ -282,7 +284,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -307,7 +309,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -330,7 +332,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(missingDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected List getRemoteTessdataLanguages() { return List.of("eng"); @@ -352,7 +354,7 @@ class UIDataTessdataControllerTest { Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString()); UIDataTessdataController controller = - new UIDataTessdataController(runtimePathConfig) { + new UIDataTessdataController(runtimePathConfig, JsonMapper.builder().build()) { @Override protected boolean isWritableDirectory(Path dir) { return false; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java index 6bfe44a3f..678bb650a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java @@ -21,8 +21,6 @@ import org.springframework.security.core.Authentication; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.model.Team; 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.service.UserLicenseSettingsService; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + @ExtendWith(MockitoExtension.class) class UserControllerTest { - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = JsonMapper.builder().build(); @Mock private UserService userService; @Mock private SessionPersistentRegistry sessionRegistry; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index 787aa57e3..c24680789 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -36,6 +36,9 @@ import stirling.software.proprietary.security.model.JwtVerificationKey; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + @ExtendWith(MockitoExtension.class) class JwtServiceTest { @@ -66,7 +69,8 @@ class JwtServiceTest { testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); ApplicationProperties applicationProperties = new ApplicationProperties(); - jwtService = new JwtService(true, keystoreService, applicationProperties); + ObjectMapper objectMapper = JsonMapper.builder().build(); + jwtService = new JwtService(objectMapper, true, keystoreService, applicationProperties); } @Test diff --git a/build.gradle b/build.gradle index aa5d71fa1..2a5b32fe4 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id "java" id "jacoco" 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 "io.swagger.swaggerhub" version "1.3.2" 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.JsonSlurper import groovy.xml.XmlSlurper +import org.gradle.api.JavaVersion import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion ext { - springBootVersion = "3.5.9" + springBootVersion = "4.0.3" pdfboxVersion = "3.0.6" imageioVersion = "3.13.0" lombokVersion = "1.18.42" bouncycastleVersion = "1.83" - springSecuritySamlVersion = "6.5.6" + springSecuritySamlVersion = "7.0.2" openSamlVersion = "4.3.2" - commonmarkVersion = "0.27.0" - googleJavaFormatVersion = "1.28.0" - logback = "1.5.25" + commonmarkVersion = "0.27.1" + googleJavaFormatVersion = "1.34.1" + logback = "1.5.28" 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 = { -> @@ -70,7 +81,6 @@ allprojects { version = '2.5.3' configurations.configureEach { - exclude group: 'commons-logging', module: 'commons-logging' exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" } } @@ -138,8 +148,11 @@ subprojects { apply plugin: 'jacoco' java { - // 17 is lowest but we support and recommend 21 - sourceCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } } if (project.name != "stirling-pdf") { @@ -167,12 +180,14 @@ subprojects { } configurations.configureEach { - exclude group: 'commons-logging', module: 'commons-logging' exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' // Exclude vulnerable BouncyCastle version used in tableau exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on' exclude group: 'org.bouncycastle', module: 'bcutil-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 { @@ -191,7 +206,11 @@ subprojects { compileOnly "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-webmvc-test' testRuntimeOnly 'org.mockito:mockito-inline:5.2.0' testRuntimeOnly "org.junit.platform:junit-platform-launcher" @@ -201,13 +220,15 @@ subprojects { tasks.withType(JavaCompile).configureEach { options.encoding = "UTF-8" + options.release = rootProject.ext.modernJavaVersion if (!project.hasProperty("noSpotless")) { dependsOn "spotlessApply" } } tasks.named("compileJava", JavaCompile).configure { - options.compilerArgs.add("-parameters") + // options.compilerArgs.add("-Xlint:deprecation") + // options.compilerArgs.add("-Xlint:unchecked") } def jacocoReport = tasks.named("jacocoTestReport") @@ -387,6 +408,17 @@ subprojects { finalizedBy("copySwaggerDoc") 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') classpath = files() - destinationDirectory = file("${buildDir}/restart-helper-classes") - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + destinationDirectory = layout.buildDirectory.dir("restart-helper-classes") + def restartMajorVersion = project.ext.modernJavaVersion + def restartCompatibility = JavaVersion.toVersion(restartMajorVersion.toString()) + sourceCompatibility = restartCompatibility + targetCompatibility = restartCompatibility + options.release.set(restartMajorVersion) } // Task to create restart-helper.jar @@ -544,9 +579,9 @@ tasks.register('buildRestartHelper', Jar) { description = 'Builds the restart-helper.jar' dependsOn 'compileRestartHelper' - from "${buildDir}/restart-helper-classes" + from layout.buildDirectory.dir("restart-helper-classes") archiveFileName = 'restart-helper.jar' - destinationDirectory = file("${buildDir}/libs") + destinationDirectory = layout.buildDirectory.dir("libs") manifest { attributes 'Main-Class': 'RestartHelper' diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified index 8807766e7..c2ca65c3f 100644 --- a/docker/Dockerfile.unified +++ b/docker/Dockerfile.unified @@ -24,6 +24,13 @@ COPY gradle gradle/ COPY app/core/build.gradle core/. COPY app/common/build.gradle common/. 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 WORKDIR /app @@ -54,7 +61,11 @@ LABEL org.opencontainers.image.keywords="PDF, manipulation, unified, API, Spring # Copy backend files COPY scripts /scripts 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 --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 # 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 && \ 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 && \ @@ -122,11 +134,14 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a py3-pillow@testing \ py3-pdf2image@testing && \ python3 -m venv /opt/venv && \ - /opt/venv/bin/pip install --upgrade pip setuptools && \ - /opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \ + /opt/venv/bin/pip install --no-cache-dir unoserver weasyprint && \ 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 /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 && \ 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 && \ @@ -135,8 +150,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a chmod +x /entrypoint.sh && \ # User permissions 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 stirlingpdfuser:stirlingpdfgroup /app.jar + 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 EXPOSE 8080/tcp diff --git a/docker/Dockerfile.unified-lite b/docker/Dockerfile.unified-lite index fc0dc7114..e767806bb 100644 --- a/docker/Dockerfile.unified-lite +++ b/docker/Dockerfile.unified-lite @@ -24,6 +24,13 @@ COPY gradle gradle/ COPY app/core/build.gradle core/. COPY app/common/build.gradle common/. 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 WORKDIR /app @@ -53,7 +60,11 @@ LABEL org.opencontainers.image.keywords="PDF, manipulation, unified, ultra-lite, # Copy backend files COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.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 --from=frontend-build /app/dist /usr/share/nginx/html @@ -80,6 +91,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \ ENDPOINTS_GROUPS_TO_REMOVE=CLI # 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 && \ 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 && \ @@ -100,8 +112,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a chmod +x /entrypoint.sh && \ # User permissions 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 stirlingpdfuser:stirlingpdfgroup /app.jar + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx EXPOSE 8080/tcp diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile deleted file mode 100644 index 567fb873b..000000000 --- a/docker/backend/Dockerfile +++ /dev/null @@ -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 [] diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat deleted file mode 100644 index a833efb26..000000000 --- a/docker/backend/Dockerfile.fat +++ /dev/null @@ -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 [] diff --git a/docker/backend/Dockerfile.ultra-lite b/docker/backend/Dockerfile.ultra-lite deleted file mode 100644 index 7840df339..000000000 --- a/docker/backend/Dockerfile.ultra-lite +++ /dev/null @@ -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 [] diff --git a/docker/compose/docker-compose.fat.yml b/docker/compose/docker-compose.fat.yml index 8382c2681..a6d657f15 100644 --- a/docker/compose/docker-compose.fat.yml +++ b/docker/compose/docker-compose.fat.yml @@ -1,9 +1,9 @@ services: - backend: + stirling-pdf: build: context: ../.. - dockerfile: docker/backend/Dockerfile.fat - container_name: stirling-pdf-backend-fat + dockerfile: docker/embedded/Dockerfile.fat + container_name: stirling-pdf-fat restart: unless-stopped deploy: resources: @@ -15,9 +15,7 @@ services: timeout: 10s retries: 16 ports: - - "8080:8080" # TODO: Remove in production - for debugging only - expose: - - "8080" + - "8080:8080" volumes: - ../../stirling/latest/data:/usr/share/tessdata:rw - ../../stirling/latest/config:/configs:rw @@ -37,24 +35,6 @@ services: networks: - 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: - #VITE_GOOGLE_DRIVE_API_KEY: - #VITE_GOOGLE_DRIVE_APP_ID: - depends_on: - - backend - networks: - - stirling-network - networks: stirling-network: driver: bridge diff --git a/docker/compose/docker-compose.ultra-lite.yml b/docker/compose/docker-compose.ultra-lite.yml index 39654583d..420a64137 100644 --- a/docker/compose/docker-compose.ultra-lite.yml +++ b/docker/compose/docker-compose.ultra-lite.yml @@ -1,9 +1,9 @@ services: - backend: + stirling-pdf: build: context: ../.. - dockerfile: docker/backend/Dockerfile.ultra-lite - container_name: stirling-pdf-backend-ultra-lite + dockerfile: docker/embedded/Dockerfile.ultra-lite + container_name: stirling-pdf-ultra-lite restart: unless-stopped deploy: resources: @@ -15,9 +15,7 @@ services: timeout: 10s retries: 16 ports: - - "8080:8080" # TODO: Remove in production - for debugging only - expose: - - "8080" + - "8080:8080" volumes: - ../../stirling/latest/config:/configs:rw - ../../stirling/latest/logs:/logs:rw @@ -34,24 +32,6 @@ services: networks: - 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: - #VITE_GOOGLE_DRIVE_API_KEY: - #VITE_GOOGLE_DRIVE_APP_ID: - depends_on: - - backend - networks: - - stirling-network - networks: stirling-network: driver: bridge diff --git a/docker/compose/docker-compose.yml b/docker/compose/docker-compose.yml index c272418ee..359bf2f46 100644 --- a/docker/compose/docker-compose.yml +++ b/docker/compose/docker-compose.yml @@ -1,9 +1,9 @@ services: - backend: + stirling-pdf: build: context: ../.. - dockerfile: docker/backend/Dockerfile - container_name: stirling-pdf-backend + dockerfile: docker/embedded/Dockerfile + container_name: stirling-pdf restart: unless-stopped healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"] @@ -11,9 +11,7 @@ services: timeout: 10s retries: 16 ports: - - "8080:8080" # TODO: Remove in production - for debugging only - expose: - - "8080" + - "8080:8080" volumes: - ../../stirling/latest/data:/usr/share/tessdata:rw - ../../stirling/latest/config:/configs:rw @@ -32,24 +30,6 @@ services: networks: - 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: - #VITE_GOOGLE_DRIVE_API_KEY: - #VITE_GOOGLE_DRIVE_APP_ID: - depends_on: - - backend - networks: - - stirling-network - networks: stirling-network: driver: bridge diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index b1ab2bc1f..84e66f44e 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -1,213 +1,592 @@ -# Stirling-PDF Dockerfile - Full version with embedded frontend -# Single JAR contains both frontend and backend +# Stirling-PDF - Full version (embedded frontend) -# Stage 1: Build application with embedded frontend -FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build +FROM ubuntu:noble AS calibre-build -# Install Node.js and npm for frontend build -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/* +ARG CALIBRE_VERSION=9.3.1 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; \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -t ${VERSION_CODENAME}-backports \ - libreoffice libreoffice-java-common; \ + apt-get install -y --no-install-recommends \ + 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/*; \ - 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 -# ============================================================================== -# Create non-root user (stirlingpdfuser) with configurable UID/GID -# ============================================================================== +# Build the Java application and frontend. +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 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; \ + groupadd -g "${PGID}" stirlingpdfgroup 2>/dev/null \ + || groupadd 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; \ + useradd -m -u "${PUID}" -g stirlingpdfgroup \ + -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser 2>/dev/null \ + || useradd -m -g stirlingpdfgroup \ + -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \ fi; \ - fi + ln -sf /usr/sbin/gosu /usr/local/bin/su-exec -# Compatibility alias for older entrypoint scripts expecting su-exec -RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec +# Application files. +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=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 --from=app-build --chown=1000:1000 \ + /app/build/libs/restart-helper.jar /restart-helper.jar +COPY --chown=1000:1000 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/ -# 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 - -# 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 \ - 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="" \ HOME=/home/stirlingpdfuser \ PUID=${PUID} \ PGID=${PGID} \ UMASK=022 \ + PATH="/opt/venv/bin:${PATH}" \ UNO_PATH=/usr/lib/libreoffice/program \ LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin \ STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ TMPDIR=/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 -# ============================================================================== -# 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__)" +# Metadata labels. +LABEL org.opencontainers.image.title="Stirling-PDF" \ + org.opencontainers.image.description="Full version with Calibre, LibreOffice, Tesseract, OCRmyPDF" \ + org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \ + org.opencontainers.image.licenses="MIT" \ + 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 - STOPSIGNAL SIGTERM -# Use tini as init (handles signals and zombies correctly) -ENTRYPOINT ["tini", "--", "/scripts/init.sh"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + 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 [] diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 529a694e7..2573a1fd5 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -1,158 +1,563 @@ -# Stirling-PDF Dockerfile - Fat version with embedded frontend -# Single JAR contains both frontend and backend with extra fonts for air-gapped environments +# Stirling-PDF - Fat version (embedded frontend) +# Extra fonts for air-gapped environments -# Stage 1: Build application with embedded frontend -FROM gradle:8.14-jdk21@sha256:051d9a116793bdc5175a3f97a545718b750489eee85a7da20913c8a53f722a72 AS build +FROM ubuntu:noble AS calibre-build -# Install Node.js and npm for frontend build -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/* +ARG CALIBRE_VERSION=9.3.1 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; \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -t ${VERSION_CODENAME}-backports \ - libreoffice libreoffice-java-common; \ + apt-get install -y --no-install-recommends \ + 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/*; \ - 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 -# ============================================================================== -# Create non-root user (stirlingpdfuser) with configurable UID/GID -# ============================================================================== +# Build the Java application and frontend. +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 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; \ + groupadd -g "${PGID}" stirlingpdfgroup 2>/dev/null \ + || groupadd 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; \ + useradd -m -u "${PUID}" -g stirlingpdfgroup \ + -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser 2>/dev/null \ + || useradd -m -g stirlingpdfgroup \ + -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \ fi; \ - fi + ln -sf /usr/sbin/gosu /usr/local/bin/su-exec -# Compatibility alias for older entrypoint scripts expecting su-exec -RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec +# Application files. +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 -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/ +# Fonts go to system dir — root ownership is correct (world-readable) +COPY --link 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 - -# 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 \ - 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="" \ HOME=/home/stirlingpdfuser \ PUID=${PUID} \ @@ -160,57 +565,34 @@ ENV VERSION_TAG=$VERSION_TAG \ UMASK=022 \ FAT_DOCKER=true \ INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \ + PATH="/opt/venv/bin:${PATH}" \ UNO_PATH=/usr/lib/libreoffice/program \ LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin \ STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ TMPDIR=/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 -# ============================================================================== -# 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__)" +# Metadata labels. +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" \ + org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \ + org.opencontainers.image.licenses="MIT" \ + 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 - STOPSIGNAL SIGTERM -# Use tini as init (handles signals and zombies correctly) -ENTRYPOINT ["tini", "--", "/scripts/init.sh"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + 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 [] diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 1c6f925d3..4a15c48cf 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -2,40 +2,50 @@ # Single JAR contains both frontend and backend with minimal dependencies # 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 -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && 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 \ && 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 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 . . # Build ultra-lite JAR with embedded frontend (minimal features) -RUN DISABLE_ADDITIONAL_FEATURES=true \ - ./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube +RUN --mount=type=cache,target=/home/gradle/.gradle/caches \ + DISABLE_ADDITIONAL_FEATURES=true \ + ./gradlew clean build \ + -PbuildWithFrontend=true \ + -x spotlessApply -x spotlessCheck -x test -x sonarqube \ + --no-daemon # Stage 2: Runtime image -FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 +FROM eclipse-temurin:25-jre-alpine ENV LANG=C.UTF-8 \ LC_ALL=C.UTF-8 @@ -43,30 +53,37 @@ ENV LANG=C.UTF-8 \ ARG VERSION_TAG # Labels -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" -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, ultra-lite, API, Spring Boot, React" +LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite" \ + org.opencontainers.image.description="Stirling-PDF with embedded frontend - Ultra-lite version with minimal dependencies" \ + org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \ + org.opencontainers.image.licenses="MIT" \ + 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, ultra-lite, API, Spring Boot, React" # Copy scripts COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY scripts/installFonts.sh /scripts/installFonts.sh COPY scripts/stirling-diagnostics.sh /scripts/stirling-diagnostics.sh -# Copy built JAR from build stage -COPY --from=build /app/app/core/build/libs/*.jar /app.jar -COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar +# Copy built JARs from build stage +# Use numeric UID:GID (1000:1000) since the named user doesn't exist yet at COPY time +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 +# NOTE: Memory flags (InitialRAMPercentage, MaxRAMPercentage, MaxMetaspaceSize) +# are computed dynamically by init-without-ocr.sh based on container memory limits. 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="" \ HOME=/home/stirlingpdfuser \ PUID=1000 \ @@ -79,6 +96,7 @@ ENV VERSION_TAG=$VERSION_TAG \ ENDPOINTS_GROUPS_TO_REMOVE=CLI # 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 && \ 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 && \ @@ -90,18 +108,16 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a bash \ curl \ shadow \ - su-exec \ - openjdk21-jre && \ - mkdir -p $HOME /configs /configs/heap_dumps /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \ + su-exec && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \ mkdir -p /usr/share/fonts/opentype/noto && \ chmod +x /scripts/*.sh && \ # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf EXPOSE 8080/tcp # Set user and run command ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"] -CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"] +CMD [] diff --git a/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml b/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml index e43710c5b..51d859c80 100644 --- a/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml +++ b/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml @@ -22,6 +22,8 @@ services: environment: DISABLE_ADDITIONAL_FEATURES: "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 PGID: 1002 UMASK: "022" diff --git a/docker/embedded/compose/docker-compose-latest-fat-security.yml b/docker/embedded/compose/docker-compose-latest-fat-security.yml index 7c46cbeb8..187e7bba6 100644 --- a/docker/embedded/compose/docker-compose-latest-fat-security.yml +++ b/docker/embedded/compose/docker-compose-latest-fat-security.yml @@ -20,6 +20,8 @@ services: environment: DISABLE_ADDITIONAL_FEATURES: "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 PGID: 1002 UMASK: "022" diff --git a/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml b/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml index 180e5f371..d1b804ad4 100644 --- a/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml +++ b/docker/embedded/compose/docker-compose-latest-security-remote-uno.yml @@ -33,6 +33,8 @@ services: PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_PROTOCOL: "http" # Session limit should match endpoint count for optimal concurrency 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 PGID: 1002 UMASK: "022" diff --git a/docker/embedded/compose/docker-compose-latest-security.yml b/docker/embedded/compose/docker-compose-latest-security.yml index e0965c686..af8979baf 100644 --- a/docker/embedded/compose/docker-compose-latest-security.yml +++ b/docker/embedded/compose/docker-compose-latest-security.yml @@ -21,6 +21,8 @@ services: SECURITY_ENABLELOGIN: "false" PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true" 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 PGID: 1002 UMASK: "022" diff --git a/docker/embedded/compose/docker-compose-latest-ultra-lite.yml b/docker/embedded/compose/docker-compose-latest-ultra-lite.yml index 010b91508..fbc8e4cdc 100644 --- a/docker/embedded/compose/docker-compose-latest-ultra-lite.yml +++ b/docker/embedded/compose/docker-compose-latest-ultra-lite.yml @@ -18,6 +18,8 @@ services: - ../../../stirling/latest/logs:/logs:rw environment: SECURITY_ENABLELOGIN: "false" + # STIRLING_JVM_PROFILE: "balanced" # Options: balanced (G1GC, default), performance (Shenandoah) + # JAVA_CUSTOM_OPTS: "" # Additional JVM flags appended to the active profile SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: Stirling-PDF-Ultra-lite UI_HOMEDESCRIPTION: Demo site for Stirling-PDF-Ultra-lite Latest diff --git a/gradle.properties b/gradle.properties index 8a390f592..e99fbdc91 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,4 +5,8 @@ org.gradle.parallel=true org.gradle.caching=true org.gradle.build-scan=true +# allow Gradle to download the toolchain version requested by the build +org.gradle.java.installations.auto-download=true + +org.gradle.daemon=true # org.gradle.configuration-cache=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6514f919f..5f38436fc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh old mode 100644 new mode 100755 index 1afec3b9b..079688634 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -2,7 +2,13 @@ # This script initializes Stirling PDF without OCR features. set -euo pipefail -log() { printf '%s\n' "$*" >&2; } +log() { + if [ $# -eq 0 ]; then + cat >&2 + else + printf '%s\n' "$*" >&2 + fi +} command_exists() { command -v "$1" >/dev/null 2>&1; } if [ -d /scripts ] && [[ ":${PATH}:" != *":/scripts:"* ]]; then @@ -18,6 +24,42 @@ if [ -x /scripts/stirling-diagnostics.sh ]; then ln -sf /scripts/stirling-diagnostics.sh /usr/local/bin/diagnostic fi +print_versions() { + set +o pipefail + log "--- Binary Versions ---" + command_exists java && java -version 2>&1 | head -n 1 | log + command_exists qpdf && qpdf --version | head -n 1 | log + command_exists magick && magick --version | head -n 1 | log + # Use python to get versions of pip-installed tools to be sure + command_exists ocrmypdf && ocrmypdf --version 2>&1 | head -n 1 | printf "ocrmypdf %s\n" "$(cat)" | log + command_exists soffice && soffice --version | head -n 1 | log + command_exists unoserver && unoserver --version 2>&1 | head -n 1 | log + command_exists tesseract && tesseract --version | head -n 1 | log + command_exists gs && gs --version | printf "Ghostscript %s\n" "$(cat)" | log + command_exists ffmpeg && ffmpeg -version | head -n 1 | log + command_exists pdfinfo && pdfinfo -v 2>&1 | head -n 1 | log + command_exists fontforge && fontforge --version 2>&1 | head -n 1 | log + command_exists unpaper && unpaper --version 2>&1 | head -n 1 | log + command_exists ebook-convert && ebook-convert --version 2>&1 | head -n 1 | log + log "-----------------------" + set -o pipefail +} + +cleanup() { + log "Shutdown signal received. Cleaning up..." + # Kill background AOT generation if still running + [ -n "${AOT_GEN_PID:-}" ] && kill -TERM "$AOT_GEN_PID" 2>/dev/null || true + # Kill background processes (unoservers, watchdog, Xvfb) + pkill -P $$ || true + # Kill Java if it was backgrounded (though it handles its own shutdown) + [ -n "${JAVA_PID:-}" ] && kill -TERM "$JAVA_PID" 2>/dev/null || true + log "Cleanup complete." +} + +trap cleanup SIGTERM EXIT + +print_versions + run_with_timeout() { local secs=$1; shift if command_exists timeout; then @@ -46,10 +88,57 @@ tcp_port_check() { return $result fi - # No TCP check method available + # No TCP check method available; caller uses ==2 to fall back to PID-only logic return 2 } +check_unoserver_port_ready() { + local port=$1 + local silent=${2:-} + + # Try unoping first (best - checks actual server health) + if [ -n "${UNOPING_BIN:-}" ]; then + if run_as_runtime_user_with_timeout 5 "$UNOPING_BIN" --host 127.0.0.1 --port "$port" >/dev/null 2>&1; then + return 0 + fi + if [ "$silent" != "silent" ]; then + log "unoserver health check failed (unoping) for port ${port}, trying TCP fallback" + fi + fi + + # Fallback to TCP port check (verifies service is listening) + tcp_port_check "127.0.0.1" "$port" 5 + local tcp_rc=$? + if [ $tcp_rc -eq 0 ]; then + return 0 + elif [ $tcp_rc -eq 2 ]; then + if [ "$silent" != "silent" ]; then + log "No TCP check available; falling back to PID-only for port ${port}" + fi + return 0 + else + if [ "$silent" != "silent" ]; then + log "unoserver TCP check failed for port ${port}" + fi + fi + + return 1 +} + +check_unoserver_ready() { + local silent=${1:-} + if [ "${#UNOSERVER_PORTS[@]}" -eq 0 ]; then + log "Skipping unoserver readiness check (no local ports started)" + return 0 + fi + for port in "${UNOSERVER_PORTS[@]}"; do + if ! check_unoserver_port_ready "$port" "$silent"; then + return 1 + fi + done + return 0 +} + UNOSERVER_PIDS=() UNOSERVER_PORTS=() UNOSERVER_UNO_PORTS=() @@ -138,18 +227,20 @@ get_unoserver_count() { start_unoserver_instance() { local port=$1 local uno_port=$2 + # Suppress repetitive POST /RPC2 access logs from health checks run_as_runtime_user "$UNOSERVER_BIN" \ --interface 127.0.0.1 \ --port "$port" \ --uno-port "$uno_port" \ + 2> >(grep --line-buffered -v "POST /RPC2" >&2) \ & LAST_UNOSERVER_PID=$! } start_unoserver_watchdog() { - local interval=${UNO_SERVER_HEALTH_INTERVAL:-30} + local interval=${UNO_SERVER_HEALTH_INTERVAL:-120} case "$interval" in - ''|*[!0-9]*) interval=30 ;; + ''|*[!0-9]*) interval=120 ;; esac ( while true; do @@ -160,37 +251,12 @@ start_unoserver_watchdog() { local uno_port=${UNOSERVER_UNO_PORTS[$i]} local needs_restart=false - # Check 1: PID exists + # Check PID and Health if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then log "unoserver PID ${pid} not found for port ${port}" needs_restart=true - else - # PID exists, now check if server is actually healthy - local health_ok=false - - # Check 2A: Health check with unoping (best - checks actual server health) - if [ -n "$UNOPING_BIN" ]; then - if run_as_runtime_user_with_timeout 5 "$UNOPING_BIN" --host 127.0.0.1 --port "$port" >/dev/null 2>&1; then - health_ok=true - else - log "unoserver health check failed (unoping) for port ${port}, trying TCP fallback" - fi - fi - - # Check 2B: Fallback to TCP port check (verifies service is listening) - if [ "$health_ok" = false ]; then - tcp_port_check "127.0.0.1" "$port" 5 - local tcp_rc=$? - if [ $tcp_rc -eq 0 ]; then - health_ok=true - elif [ $tcp_rc -eq 2 ]; then - log "No TCP check available; falling back to PID-only for port ${port}" - health_ok=true - else - log "unoserver TCP check failed for port ${port}" - needs_restart=true - fi - fi + elif ! check_unoserver_port_ready "$port"; then + needs_restart=true fi if [ "$needs_restart" = true ]; then @@ -244,7 +310,8 @@ start_unoserver_pool() { i=$((i + 1)) done - start_unoserver_watchdog + # Small delay to let servers bind + sleep 2 } # ---------- VERSION_TAG ---------- @@ -254,10 +321,313 @@ if [ -z "${VERSION_TAG:-}" ] && [ -f /etc/stirling_version ]; then export VERSION_TAG fi +# ---------- Dynamic Memory Detection ---------- +# Detects the container memory limit (in MB) from cgroups v2/v1 or /proc/meminfo. +detect_container_memory_mb() { + local mem_bytes="" + # cgroups v2 + if [ -f /sys/fs/cgroup/memory.max ]; then + mem_bytes=$(cat /sys/fs/cgroup/memory.max 2>/dev/null) + if [ "$mem_bytes" = "max" ]; then + mem_bytes="" + fi + fi + # cgroups v1 fallback + if [ -z "$mem_bytes" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + mem_bytes=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null) + # Values near max uint64 mean "unlimited" + # Use string-length heuristic (>=19 digits) to avoid shell integer overflow on Alpine/busybox + if [ "${#mem_bytes}" -ge 19 ]; then + mem_bytes="" + fi + fi + # Fallback to system total memory + if [ -z "$mem_bytes" ]; then + mem_bytes=$(awk '/MemTotal/ {print $2 * 1024}' /proc/meminfo 2>/dev/null) + fi + if [ -n "$mem_bytes" ] && [ "$mem_bytes" -gt 0 ] 2>/dev/null; then + echo $(( mem_bytes / 1048576 )) + else + echo "0" + fi +} + +# Computes dynamic JVM memory flags based on detected container memory and profile. +# Sets: DYNAMIC_INITIAL_RAM_PCT, DYNAMIC_MAX_RAM_PCT, DYNAMIC_MAX_METASPACE +compute_dynamic_memory() { + local mem_mb=$1 + local profile=${2:-balanced} + + if [ "$mem_mb" -le 0 ] 2>/dev/null; then + # Cannot detect memory; use safe defaults + DYNAMIC_INITIAL_RAM_PCT=10 + DYNAMIC_MAX_RAM_PCT=75 + DYNAMIC_MAX_METASPACE=256 + return + fi + + log "Detected container memory: ${mem_mb}MB" + + # NOTE: MaxRAMPercentage governs HEAP only. Total JVM footprint also includes: + # - Metaspace (MaxMetaspaceSize) + # - Code cache (~100-200MB) + # - Thread stacks (~1MB each × virtual threads) + # - Direct byte buffers, native memory + # Rule of thumb: heap% + (metaspace + ~200MB overhead) should fit in container. + if [ "$mem_mb" -le 512 ]; then + DYNAMIC_INITIAL_RAM_PCT=30 + DYNAMIC_MAX_RAM_PCT=55 + DYNAMIC_MAX_METASPACE=96 + elif [ "$mem_mb" -le 1024 ]; then + DYNAMIC_INITIAL_RAM_PCT=25 + DYNAMIC_MAX_RAM_PCT=60 + DYNAMIC_MAX_METASPACE=128 + elif [ "$mem_mb" -le 2048 ]; then + DYNAMIC_INITIAL_RAM_PCT=20 + DYNAMIC_MAX_RAM_PCT=65 + DYNAMIC_MAX_METASPACE=192 + elif [ "$mem_mb" -le 4096 ]; then + DYNAMIC_INITIAL_RAM_PCT=15 + DYNAMIC_MAX_RAM_PCT=70 + DYNAMIC_MAX_METASPACE=256 + else + # Large memory: be conservative to leave room for off-heap (LibreOffice, Calibre, etc.) + if [ "$profile" = "performance" ]; then + DYNAMIC_INITIAL_RAM_PCT=20 + DYNAMIC_MAX_RAM_PCT=70 + DYNAMIC_MAX_METASPACE=512 + else + DYNAMIC_INITIAL_RAM_PCT=10 + DYNAMIC_MAX_RAM_PCT=50 + DYNAMIC_MAX_METASPACE=256 + fi + fi + + log "Dynamic memory: InitialRAM=${DYNAMIC_INITIAL_RAM_PCT}%, MaxRAM=${DYNAMIC_MAX_RAM_PCT}%, MaxMeta=${DYNAMIC_MAX_METASPACE}m" +} + +# ---------- Project Leyden AOT Cache (JEP 483 + 514 + 515) ---------- +# Replaces legacy AppCDS with JDK 25's AOT cache. Uses the three-step workflow: +# 1. RECORD — runs Spring context init, captures class loading + method profiles +# 2. CREATE — builds the AOT cache file (does NOT start the app) +# 3. RUNTIME — java -XX:AOTCache=... starts with pre-linked classes + compiled methods +# Constraints: +# - Cache must be generated on the same JDK build + OS + arch as production (satisfied +# because we generate inside the same container image at runtime) +# - ZGC not supported until JDK 26 (G1GC and Shenandoah are fully supported) +# - Signed JARs (BouncyCastle) are silently skipped, no warnings, no functionality loss +generate_aot_cache() { + local aot_path="$1" + shift + # Remaining args ($@) are the classpath/main-class arguments for the training run + + local aot_dir + aot_dir=$(dirname "$aot_path") + mkdir -p "$aot_dir" 2>/dev/null || true + + local aot_conf="/tmp/stirling.aotconf" + + log "AOT: Phase 1/2 — Recording class loading + method profiles..." + + # RECORD — starts Spring context, observes class loading + collects method profiles (JEP 515). + # -Dspring.context.exit=onRefresh stops after Spring context loads (good training coverage). + # Uses -Xmx512m: enough for Spring context init without starving the running application. + # -Xlog:aot=error suppresses harmless "Skipping"/"Preload Warning" messages for proxies, + # signed JARs (BouncyCastle), JFR events, CGLIB classes, etc. The JVM handles all of + # these internally they are informational, not errors. + # Non-zero exit is expected — onRefresh triggers controlled shutdown. + # Uses in-memory H2 database to avoid file-lock conflicts with the running application. + # Note: DatabaseConfig reads System.getProperty("stirling.datasource.url") to override + # the default file-based H2 URL. We use MODE=PostgreSQL to match the production config. + # Redirect both stdout and stderr to suppress duplicate startup logs (banner + Spring init). + # IMPORTANT: COMPRESSED_OOPS_FLAG must match the runtime setting to avoid AOT cache + # invalidation on restart ("saved state of UseCompressedOops ... is different" error). + java -Xmx512m -XX:+UseCompactObjectHeaders ${COMPRESSED_OOPS_FLAG} \ + -Xlog:aot=error \ + -XX:AOTMode=record \ + -XX:AOTConfiguration="$aot_conf" \ + -Dspring.main.banner-mode=off \ + -Dspring.context.exit=onRefresh \ + -Dstirling.datasource.url="jdbc:h2:mem:aottraining;DB_CLOSE_DELAY=-1;MODE=PostgreSQL" \ + "$@" >/tmp/aot-record.log 2>&1 || true + + if [ ! -f "$aot_conf" ]; then + log "AOT: Training produced no configuration file." + tail -5 /tmp/aot-record.log 2>/dev/null | while IFS= read -r line; do log " $line"; done + rm -f /tmp/aot-record.log + return 1 + fi + + log "AOT: Phase 2/2 — Creating AOT cache from recorded profile..." + + # CREATE — does NOT start the application. Processes the recorded configuration + # to build the AOT cache with pre-linked classes and optimized native code. + # Uses less memory than the training run. + # -Xlog:aot=error: same as record phase — suppress harmless skip/preload warnings. + # Redirect both stdout and stderr to avoid polluting container logs. + # IMPORTANT: COMPRESSED_OOPS_FLAG must match both RECORD and RUNTIME. + if java -Xmx256m -XX:+UseCompactObjectHeaders ${COMPRESSED_OOPS_FLAG} \ + -Xlog:aot=error \ + -XX:AOTMode=create \ + -XX:AOTConfiguration="$aot_conf" \ + -XX:AOTCache="$aot_path" \ + "$@" >/tmp/aot-create.log 2>&1; then + + local cache_size + cache_size=$(du -h "$aot_path" 2>/dev/null | cut -f1) + log "AOT: Cache created successfully: $aot_path ($cache_size)" + rm -f "$aot_conf" /tmp/aot-record.log /tmp/aot-create.log + return 0 + else + log "AOT: Cache creation failed." + tail -5 /tmp/aot-create.log 2>/dev/null | while IFS= read -r line; do log " $line"; done + rm -f "$aot_conf" "$aot_path" /tmp/aot-record.log /tmp/aot-create.log + return 1 + fi +} + +# ---------- Memory Detection ---------- +CONTAINER_MEM_MB=$(detect_container_memory_mb) +JVM_PROFILE="${STIRLING_JVM_PROFILE:-balanced}" +compute_dynamic_memory "$CONTAINER_MEM_MB" "$JVM_PROFILE" +MEMORY_FLAGS="-XX:InitialRAMPercentage=${DYNAMIC_INITIAL_RAM_PCT} -XX:MaxRAMPercentage=${DYNAMIC_MAX_RAM_PCT} -XX:MaxMetaspaceSize=${DYNAMIC_MAX_METASPACE}m" + +# ---------- Compressed Oops Detection ---------- +# AOT/CDS cache is sensitive to UseCompressedOops. The setting must be identical +# between the training run (generate_aot_cache) and all subsequent runtime boots. +# With small -Xmx during training the JVM defaults to +UseCompressedOops, but at +# runtime a large MaxRAMPercentage (e.g. 50% of 64GB ≈ 32GB) may cause the JVM to +# disable it, invalidating the cache. We compute the expected max heap and lock the +# flag so every invocation agrees. +if [ "$CONTAINER_MEM_MB" -gt 0 ] 2>/dev/null; then + MAX_HEAP_MB=$((CONTAINER_MEM_MB * DYNAMIC_MAX_RAM_PCT / 100)) + # JVM disables compressed oops when max heap >= ~32 GB (exact threshold varies + # by alignment / JVM build). Use a conservative 31744 MB (~31 GB) cutoff. + if [ "$MAX_HEAP_MB" -ge 31744 ]; then + COMPRESSED_OOPS_FLAG="-XX:-UseCompressedOops" + else + COMPRESSED_OOPS_FLAG="-XX:+UseCompressedOops" + fi +else + # Cannot detect memory — default matches small-heap behaviour + COMPRESSED_OOPS_FLAG="-XX:+UseCompressedOops" +fi + +# ---------- JVM Profile Selection ---------- +# Resolve JAVA_BASE_OPTS from profile system or user override. +# Priority: JAVA_BASE_OPTS (explicit override) > STIRLING_JVM_PROFILE > fallback defaults +if [ -z "${JAVA_BASE_OPTS:-}" ]; then + case "$JVM_PROFILE" in + performance) + if [ -n "${_JVM_OPTS_PERFORMANCE:-}" ]; then + JAVA_BASE_OPTS="${_JVM_OPTS_PERFORMANCE}" + log "JVM profile: performance (Shenandoah generational)" + else + JAVA_BASE_OPTS="${_JVM_OPTS_BALANCED:-}" + log "Performance profile not available in this image; falling back to balanced" + fi + ;; + *) + if [ -n "${_JVM_OPTS_BALANCED:-}" ]; then + JAVA_BASE_OPTS="${_JVM_OPTS_BALANCED}" + log "JVM profile: balanced (G1GC)" + else + log "JAVA_BASE_OPTS and profiles unset; applying fallback defaults." + JAVA_BASE_OPTS="-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" + fi + ;; + esac + + # Strip any hardcoded memory/CDS/AOT flags from the profile (managed dynamically) + JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E \ + 's/-XX:InitialRAMPercentage=[^ ]*//g; + s/-XX:MinRAMPercentage=[^ ]*//g; + s/-XX:MaxRAMPercentage=[^ ]*//g; + s/-XX:MaxMetaspaceSize=[^ ]*//g; + s/-XX:SharedArchiveFile=[^ ]*//g; + s/-Xshare:(auto|on|off)//g; + s/-XX:AOTCache=[^ ]*//g; + s/-XX:AOTMode=[^ ]*//g; + s/-XX:AOTConfiguration=[^ ]*//g') + + # Append computed dynamic memory flags + JAVA_BASE_OPTS="${JAVA_BASE_OPTS} ${MEMORY_FLAGS}" +else + # JAVA_BASE_OPTS explicitly set by user or Dockerfile + # Only add dynamic memory if not already present + if ! echo "$JAVA_BASE_OPTS" | grep -q 'MaxRAMPercentage'; then + JAVA_BASE_OPTS="${JAVA_BASE_OPTS} ${MEMORY_FLAGS}" + log "Appended dynamic memory flags to JAVA_BASE_OPTS" + else + log "JAVA_BASE_OPTS already contains memory flags; keeping user values" + fi +fi + +# Check if Project Lilliput is supported (standard in Java 25+) +if java -XX:+UseCompactObjectHeaders -version >/dev/null 2>&1; then + # Only append if not already present in JAVA_BASE_OPTS + case "${JAVA_BASE_OPTS}" in + *UseCompactObjectHeaders*) ;; + *) + log "JVM supports Compact Object Headers. Enabling Project Lilliput..." + JAVA_BASE_OPTS="${JAVA_BASE_OPTS} -XX:+UseCompactObjectHeaders" + ;; + esac +else + log "JVM does not support Compact Object Headers. Skipping Project Lilliput flags." +fi + +# ---------- Clean deprecated/invalid JVM flags ---------- +# Remove UseCompressedClassPointers (deprecated in Java 25+ with Lilliput) +JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E 's/-XX:[+-]UseCompressedClassPointers//g') +# Remove any existing UseCompressedOops (we manage it explicitly for AOT consistency) +JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E 's/-XX:[+-]UseCompressedOops//g') +# Append the computed compressed oops flag (must match AOT training) +JAVA_BASE_OPTS="${JAVA_BASE_OPTS} ${COMPRESSED_OOPS_FLAG}" + +# ---------- AOT Cache Management (Project Leyden) ---------- +# Strip any legacy CDS/AOT references from base opts (we manage AOT dynamically below) +JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E \ + 's/-XX:SharedArchiveFile=[^ ]*//g; + s/-Xshare:(auto|on|off)//g; + s/-XX:AOTCache=[^ ]*//g') + +AOT_CACHE="/app/stirling.aot" +AOT_GENERATE_BACKGROUND=false + +# Support both new (STIRLING_AOT_DISABLE) and legacy (STIRLING_CDS_DISABLE) env vars +AOT_DISABLED="${STIRLING_AOT_DISABLE:-${STIRLING_CDS_DISABLE:-false}}" + +if [ -f "$AOT_CACHE" ]; then + # Cache exists from a previous boot — use it. + # If the file is corrupt or from a different JDK build, the JVM issues a warning + # and continues without the cache (graceful degradation, no crash). + log "AOT cache found: $AOT_CACHE" + JAVA_BASE_OPTS="${JAVA_BASE_OPTS} -XX:AOTCache=${AOT_CACHE}" + + # Clean up legacy .jsa if still present + rm -f /app/stirling.jsa 2>/dev/null || true +elif [ "$AOT_DISABLED" = "true" ]; then + log "AOT cache disabled via STIRLING_AOT_DISABLE=true" +else + # No cache exists — schedule background generation after app starts. + # The app starts immediately (no training delay). The AOT cache will be + # ready for the NEXT boot, giving 15-25% faster startup from then on. + log "No AOT cache found. Will generate in background after app starts." + AOT_GENERATE_BACKGROUND=true +fi + +# Collapse duplicate whitespace +JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | tr -s ' ') + # ---------- JAVA_OPTS ---------- # Configure Java runtime options. export JAVA_TOOL_OPTIONS="${JAVA_BASE_OPTS:-} ${JAVA_CUSTOM_OPTS:-}" -export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true ${JAVA_TOOL_OPTIONS}" +# Prepend headless flag only if not already present +case "${JAVA_TOOL_OPTIONS}" in + *java.awt.headless*) ;; + *) export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true ${JAVA_TOOL_OPTIONS}" ;; +esac log "running with JAVA_TOOL_OPTIONS=${JAVA_TOOL_OPTIONS}" log "Running Stirling PDF with DISABLE_ADDITIONAL_FEATURES=${DISABLE_ADDITIONAL_FEATURES:-} and VERSION_TAG=${VERSION_TAG:-}" @@ -318,7 +688,7 @@ fi # ---------- Permissions ---------- # Ensure required directories exist and set correct permissions. log "Setting permissions..." -mkdir -p /tmp/stirling-pdf /logs /configs /configs/heap_dumps /customFiles /pipeline || true +mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /customFiles /pipeline || true CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/tmp/stirling-pdf" "/app.jar") [ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype") CHOWN_OK=true @@ -352,51 +722,20 @@ if [ -n "$UNOSERVER_BIN" ] && [ -n "$UNOCONVERT_BIN" ]; then start_unoserver_pool log "unoserver pool started (Profile: $LIBREOFFICE_PROFILE)" - check_unoserver_port_ready() { - local port=$1 - - # Try unoping first (best - checks actual server health) - if [ -n "$UNOPING_BIN" ]; then - if run_as_runtime_user_with_timeout 5 "$UNOPING_BIN" --host 127.0.0.1 --port "$port" >/dev/null 2>&1; then - return 0 - fi - fi - - # Fallback to TCP port check (verifies service is listening) - tcp_port_check "127.0.0.1" "$port" 5 - local tcp_rc=$? - if [ $tcp_rc -eq 0 ] || [ $tcp_rc -eq 2 ]; then - # Success or unsupported (assume ready if can't check) - return 0 - fi - - return 1 - } - - check_unoserver_ready() { - if [ "${#UNOSERVER_PORTS[@]}" -eq 0 ]; then - log "Skipping unoserver readiness check (no local ports started)" - return 0 - fi - for port in "${UNOSERVER_PORTS[@]}"; do - if ! check_unoserver_port_ready "$port"; then - return 1 - fi - done - return 0 - } # Wait until UNO server is ready. log "Waiting for unoserver..." for _ in {1..20}; do - if check_unoserver_ready; then + # Pass 'silent' to check_unoserver_ready to suppress unoping failure logs during wait + if check_unoserver_ready "silent"; then log "unoserver is ready!" break fi - log "unoserver not ready yet; retrying..." sleep 1 done + start_unoserver_watchdog + if ! check_unoserver_ready; then log "ERROR: unoserver failed!" for pid in "${UNOSERVER_PIDS[@]}"; do @@ -416,14 +755,66 @@ JAVA_CMD=( java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf - -jar /app.jar ) +if [ -f "/app.jar" ]; then + JAVA_CMD+=("-jar" "/app.jar") +elif [ -f "/app/app.jar" ]; then + # Spring Boot 4 layered JAR structure (exploded via extract --layers). + # Use -cp (not -jar) so the classpath matches the AOT cache exactly. + JAVA_CMD+=("-cp" "/app/app.jar:/app/lib/*" "stirling.software.SPDF.SPDFApplication") +else + # Legacy fallback for Spring Boot 3 layered layout + export JAVA_MAIN_CLASS=org.springframework.boot.loader.launch.JarLauncher + JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher") +fi + if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then - exec "${JAVA_CMD[@]}" + "${JAVA_CMD[@]}" & elif [ "$CURRENT_UID" -eq 0 ] && [ -n "$SU_EXEC_BIN" ]; then - exec "$SU_EXEC_BIN" "$RUNTIME_USER" "${JAVA_CMD[@]}" + "$SU_EXEC_BIN" "$RUNTIME_USER" "${JAVA_CMD[@]}" & else warn_switch_user_once - exec "${JAVA_CMD[@]}" + "${JAVA_CMD[@]}" & fi + +JAVA_PID=$! + +# ---------- Background AOT Cache Generation ---------- +# On first boot (no existing cache), generate the AOT cache in the background +# so the app starts immediately. The cache is picked up on the next boot. +# Only runs on containers with >768MB memory to avoid starving the main process. +AOT_GEN_PID="" +if [ "$AOT_GENERATE_BACKGROUND" = true ]; then + if [ "$CONTAINER_MEM_MB" -gt 768 ] || [ "$CONTAINER_MEM_MB" -eq 0 ]; then + ( + # Wait for the app to finish starting before competing for resources. + # This avoids CPU/memory contention during Spring Boot initialization. + sleep 45 + + # Verify the main app is still running before investing in cache generation + if ! kill -0 "$JAVA_PID" 2>/dev/null; then + log "AOT: Main process exited; skipping cache generation." + exit 0 + fi + + log "AOT: Starting background cache generation for next boot..." + if [ -f /app/app.jar ] && [ -d /app/lib ]; then + generate_aot_cache "$AOT_CACHE" -cp "/app/app.jar:/app/lib/*" stirling.software.SPDF.SPDFApplication + elif [ -f /app.jar ]; then + generate_aot_cache "$AOT_CACHE" -jar /app.jar + else + log "AOT: Cannot determine JAR layout; skipping cache generation." + fi + ) & + AOT_GEN_PID=$! + log "AOT: Background cache generation scheduled (PID $AOT_GEN_PID)" + else + log "AOT: Container memory (${CONTAINER_MEM_MB}MB) too low for background generation (need >768MB). Cache will not be created." + fi +fi + +wait "$JAVA_PID" +exit_code=$? +# Propagate Java's actual exit code so container orchestrators can detect crashes +exit "${exit_code}" diff --git a/testing/compose/docker-compose-security-with-login.yml b/testing/compose/docker-compose-security-with-login.yml index 6e72d4f1a..ca35fd1c9 100644 --- a/testing/compose/docker-compose-security-with-login.yml +++ b/testing/compose/docker-compose-security-with-login.yml @@ -1,8 +1,8 @@ services: - backend: + stirling-pdf: build: context: ../.. - dockerfile: docker/backend/Dockerfile + dockerfile: docker/embedded/Dockerfile container_name: Stirling-PDF-Security-with-login restart: unless-stopped deploy: @@ -38,21 +38,6 @@ services: networks: - stirling-network - frontend: - build: - context: ../.. - dockerfile: docker/frontend/Dockerfile - container_name: stirling-pdf-frontend-security-login - restart: unless-stopped - ports: - - "3000:80" - environment: - BACKEND_URL: http://backend:8080 - depends_on: - - backend - networks: - - stirling-network - networks: stirling-network: driver: bridge diff --git a/testing/compose/docker-compose-security.yml b/testing/compose/docker-compose-security.yml index 5c4de2cbd..deedc001f 100644 --- a/testing/compose/docker-compose-security.yml +++ b/testing/compose/docker-compose-security.yml @@ -1,8 +1,8 @@ services: - backend: + stirling-pdf: build: context: ../.. - dockerfile: docker/backend/Dockerfile + dockerfile: docker/embedded/Dockerfile container_name: Stirling-PDF-Security restart: unless-stopped deploy: @@ -34,21 +34,6 @@ services: networks: - stirling-network - frontend: - build: - context: ../.. - dockerfile: docker/frontend/Dockerfile - container_name: stirling-pdf-frontend-security - restart: unless-stopped - ports: - - "3000:80" - environment: - BACKEND_URL: http://backend:8080 - depends_on: - - backend - networks: - - stirling-network - networks: stirling-network: driver: bridge diff --git a/testing/compose/docker-compose-ultra-lite.yml b/testing/compose/docker-compose-ultra-lite.yml index 41c08e19a..fcb955479 100644 --- a/testing/compose/docker-compose-ultra-lite.yml +++ b/testing/compose/docker-compose-ultra-lite.yml @@ -1,8 +1,8 @@ services: - backend: + stirling-pdf: build: context: ../.. - dockerfile: docker/backend/Dockerfile.ultra-lite + dockerfile: docker/embedded/Dockerfile.ultra-lite container_name: Stirling-PDF-Ultra-Lite restart: unless-stopped deploy: @@ -34,21 +34,6 @@ services: networks: - 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 - depends_on: - - backend - networks: - - stirling-network - networks: stirling-network: driver: bridge diff --git a/testing/cucumber/features/external.feature b/testing/cucumber/features/external.feature index 6bd563943..ba3834ede 100644 --- a/testing/cucumber/features/external.feature +++ b/testing/cucumber/features/external.feature @@ -58,7 +58,7 @@ Feature: API Validation | deskew | true | | clean | true | | cleanFinal | true | - | ocrType | Force | + | ocrType | force-ocr | | ocrRenderType | hocr | | removeImagesAfter | false | When I send the API request to the endpoint "/api/v1/misc/ocr-pdf" diff --git a/testing/cucumber/features/filter.feature b/testing/cucumber/features/filter.feature index 75e02e419..d0f34c873 100644 --- a/testing/cucumber/features/filter.feature +++ b/testing/cucumber/features/filter.feature @@ -136,8 +136,10 @@ Feature: Filter API Endpoints # --------------------------------------------------------------------------- # filter-page-size - # Blank pages are 72x72 points (smaller than any standard page size). - # Pages with random text use LETTER (612x792 points). + # Blank pages use LETTER (612x792 points = 484704 sq pts), same as pages + # with random text. Standard page areas for reference: + # A0 ~8031893 sq pts, A4 ~501168 sq pts, LEGAL 616896 sq pts, + # LETTER 484704 sq pts, A6 ~124870 sq pts # --------------------------------------------------------------------------- @filter-page-size @positive @@ -157,10 +159,21 @@ Feature: Filter API Endpoints | standardPageSize | | A0 | | A4 | - | A6 | - | LETTER | | LEGAL | + @filter-page-size @positive + Scenario: filter-page-size returns 200 when blank PDF equals LETTER size + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | standardPageSize | LETTER | + | comparator | Equal | + When I send the API request to the endpoint "/api/v1/filter/filter-page-size" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + @filter-page-size @positive Scenario: filter-page-size returns 200 when text PDF equals LETTER size Given I generate a PDF file as "fileInput" @@ -174,6 +187,19 @@ Feature: Filter API Endpoints And the response content type should be "application/pdf" And the response file should have size greater than 0 + @filter-page-size @positive + Scenario: filter-page-size returns 200 when blank PDF is Greater than A6 + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | standardPageSize | A6 | + | comparator | Greater | + When I send the API request to the endpoint "/api/v1/filter/filter-page-size" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + @filter-page-size @negative Scenario Outline: filter-page-size returns 204 when blank PDF does not match standard size as Equal Given I generate a PDF file as "fileInput" @@ -188,16 +214,15 @@ Feature: Filter API Endpoints Examples: | standardPageSize | | A4 | - | LETTER | | LEGAL | @filter-page-size @negative - Scenario: filter-page-size returns 204 when blank PDF is not Greater than any standard size + Scenario: filter-page-size returns 204 when blank PDF is not Greater than A4 Given I generate a PDF file as "fileInput" And the pdf contains 2 pages And the request data includes - | parameter | value | - | standardPageSize | A6 | + | parameter | value | + | standardPageSize | A4 | | comparator | Greater | When I send the API request to the endpoint "/api/v1/filter/filter-page-size" Then the response status code should be 204 diff --git a/testing/cucumber/features/steps/step_definitions.py b/testing/cucumber/features/steps/step_definitions.py index 8bdaa473e..65c471e71 100644 --- a/testing/cucumber/features/steps/step_definitions.py +++ b/testing/cucumber/features/steps/step_definitions.py @@ -26,10 +26,18 @@ API_HEADERS = {"X-API-KEY": "123456789"} def step_generate_pdf(context, fileInput): context.param_name = fileInput context.file_name = "genericNonCustomisableName.pdf" - writer = PdfWriter() - writer.add_blank_page(width=72, height=72) # Single blank page + # Generate a PDF with proper size and content (Letter size: 612x792 points) + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + width, height = letter + # Add some text content so OCR and other tools can process it + c.drawString(100, height - 100, "This is a test PDF document") + c.showPage() + c.save() + with open(context.file_name, "wb") as f: - writer.write(f) + f.write(buffer.getvalue()) + if not hasattr(context, "files"): context.files = {} context.files[context.param_name] = open(context.file_name, "rb") @@ -52,11 +60,16 @@ def step_use_example_file(context, filePath, fileInput): @given("the pdf contains {page_count:d} pages") def step_pdf_contains_pages(context, page_count): - writer = PdfWriter() + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + width, height = letter for i in range(page_count): - writer.add_blank_page(width=72, height=72) + c.drawString(100, height - 100, f"Page {i + 1} of {page_count}") + c.showPage() + c.save() + with open(context.file_name, "wb") as f: - writer.write(f) + f.write(buffer.getvalue()) context.files[context.param_name].close() context.files[context.param_name] = open(context.file_name, "rb") @@ -64,11 +77,15 @@ def step_pdf_contains_pages(context, page_count): # Duplicate for now... @given("the pdf contains {page_count:d} blank pages") def step_pdf_contains_blank_pages(context, page_count): - writer = PdfWriter() + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + width, height = letter for i in range(page_count): - writer.add_blank_page(width=72, height=72) + c.showPage() + c.save() + with open(context.file_name, "wb") as f: - writer.write(f) + f.write(buffer.getvalue()) context.files[context.param_name].close() context.files[context.param_name] = open(context.file_name, "rb") diff --git a/testing/test.sh b/testing/test.sh index ea787eae4..877a2a06c 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -105,10 +105,13 @@ capture_file_list() { -not -path '*/tmp/stirling-pdf/PDFBox*' \ -not -path '*/tmp/stirling-pdf/hsperfdata_stirlingpdfuser/*' \ -not -path '*/tmp/hsperfdata_stirlingpdfuser/*' \ + -not -path '*/tmp/hsperfdata_root/*' \ -not -path '*/tmp/stirling-pdf/jetty-*/*' \ -not -path '*/tmp/stirling-pdf/lu*' \ -not -path '*/tmp/stirling-pdf/tmp*' \ - -not -path '*/tmp/stirling-pdf/stirling-pdf-*.pdf' \ + -not -path '/app/stirling.aot' \ + -not -path '*/tmp/stirling.aotconf' \ + -not -path '*/tmp/aot-*.log' \ 2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file" # Check if the output file has content @@ -128,11 +131,14 @@ capture_file_list() { -not -path '*/home/stirlingpdfuser/.pdfbox.cache' \ -not -path '*/tmp/PDFBox*' \ -not -path '*/tmp/hsperfdata_stirlingpdfuser/*' \ + -not -path '*/tmp/hsperfdata_root/*' \ -not -path '*/tmp/stirling-pdf/hsperfdata_stirlingpdfuser/*' \ -not -path '*/tmp/stirling-pdf/jetty-*/*' \ -not -path '*/tmp/lu*' \ -not -path '*/tmp/tmp*' \ - -not -path '*/tmp/stirling-pdf/stirling-pdf-*.pdf' \ + -not -path '/app/stirling.aot' \ + -not -path '*/tmp/stirling.aotconf' \ + -not -path '*/tmp/aot-*.log' \ 2>/dev/null | sort" > "$output_file" if [ ! -s "$output_file" ]; then diff --git a/testing/test2.sh b/testing/test2.sh index c8376d288..2b3396ed3 100644 --- a/testing/test2.sh +++ b/testing/test2.sh @@ -57,17 +57,17 @@ build_and_test() { case "$build_type" in full) - dockerfile_name="./docker/backend/Dockerfile" + dockerfile_name="./docker/embedded/Dockerfile.fat" if [ "$enable_security" == "true" ]; then compose_file="${docker_compose_base}-fat-security${compose_suffix}" service_name="Stirling-PDF-Security-Fat" else compose_file="${docker_compose_base}-fat${compose_suffix}" - service_name="stirling-pdf-backend-fat" + service_name="stirling-pdf-fat" fi ;; ultra-lite) - dockerfile_name="./docker/backend/Dockerfile.ultra-lite" + dockerfile_name="./docker/embedded/Dockerfile.ultra-lite" if [ "$enable_security" == "true" ]; then compose_file="${docker_compose_base}-ultra-lite-security${compose_suffix}" service_name="stirling-pdf-backend-ultra-lite-security"