Merge branch 'main' into issue-397

This commit is contained in:
OUNZAR Aymane 2025-11-12 13:02:06 +01:00 committed by GitHub
commit be39e097fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 2106 additions and 220 deletions

View File

@ -39,7 +39,7 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -127,7 +127,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -361,7 +361,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -21,7 +21,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -16,7 +16,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -56,7 +56,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -143,7 +143,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -176,7 +176,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -225,7 +225,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -266,7 +266,7 @@ jobs:
test-build-docker-images: test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence] needs: [files-changed, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@ -274,7 +274,7 @@ jobs:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"] docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -299,7 +299,7 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx

View File

@ -32,7 +32,7 @@ jobs:
pull-requests: write # Allow writing to pull requests pull-requests: write # Allow writing to pull requests
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -31,7 +31,7 @@ jobs:
repository-projects: write # Required for enabling automerge repository-projects: write # Required for enabling automerge
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -15,7 +15,7 @@ jobs:
issues: write issues: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -21,7 +21,7 @@ jobs:
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }} versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -60,7 +60,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -110,7 +110,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -148,7 +148,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -238,7 +238,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -301,7 +301,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -21,7 +21,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -30,7 +30,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -80,7 +80,7 @@ jobs:
password: ${{ github.token }} password: ${{ github.token }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Convert repository owner to lowercase - name: Convert repository owner to lowercase
id: repoowner id: repoowner
@ -88,7 +88,7 @@ jobs:
- name: Generate tags - name: Generate tags
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
if: github.ref != 'refs/heads/main' if: github.ref != 'refs/heads/main'
with: with:
images: | images: |
@ -134,7 +134,7 @@ jobs:
- name: Generate tags ultra-lite - name: Generate tags ultra-lite
id: meta2 id: meta2
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
if: github.ref != 'refs/heads/main' if: github.ref != 'refs/heads/main'
with: with:
images: | images: |
@ -165,7 +165,7 @@ jobs:
- name: Generate tags fat - name: Generate tags fat
id: meta3 id: meta3
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with: with:
images: | images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf

View File

@ -23,7 +23,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }} version: ${{ steps.versionNumber.outputs.versionNumber }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -83,7 +83,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -161,7 +161,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -16,7 +16,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -36,7 +36,7 @@ jobs:
PIP_DISABLE_PIP_VERSION_CHECK: "1" PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -139,7 +139,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit
@ -175,7 +175,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with: with:
egress-policy: audit egress-policy: audit

View File

@ -38,11 +38,14 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
TEMP=/tmp/stirling-pdf \ TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf TMP=/tmp/stirling-pdf
# JDK for app # JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ RUN apk add --no-cache bash \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ && ln -sf /bin/bash /bin/sh \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ && 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 upgrade --no-cache -a && \
apk add --no-cache \ apk add --no-cache \
ca-certificates \ ca-certificates \
@ -65,19 +68,23 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# OCR MY PDF (unpaper for descew and other advanced features) # OCR MY PDF (unpaper for descew and other advanced features)
tesseract-ocr-data-eng \ tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \ tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \ tesseract-ocr-data-deu \
tesseract-ocr-data-fra \ tesseract-ocr-data-fra \
tesseract-ocr-data-por \ tesseract-ocr-data-por \
unpaper \ unpaper \
# CV # CV / Python
py3-opencv \ py3-opencv \
python3 \ python3 \
ocrmypdf \ ocrmypdf \
py3-pip \ py3-pip \
py3-pillow@testing \ py3-pillow \
py3-pdf2image@testing \ py3-pdf2image \
# Calibre
calibre \
# URW Base 35 fonts for better PDF rendering # URW Base 35 fonts for better PDF rendering
font-urw-base35 && \ font-urw-base35 && \
# Calibre fixes
apk fix --no-cache calibre && \
python3 -m venv /opt/venv && \ python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \ /opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \ /opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
@ -93,7 +100,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# User permissions # User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
ln -sf /bin/busybox /bin/sh
EXPOSE 8080/tcp EXPOSE 8080/tcp

View File

@ -25,6 +25,12 @@ RUN apt-get update && apt-get install -y \
python3-venv \ python3-venv \
# ss -tln # ss -tln
iproute2 \ iproute2 \
# calibre requires these dependencies
wget \
xz-utils \
libopengl0 \
libxcb-cursor0 \
&& wget -nv -O- https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/*
# Setze die Environment Variable für setuptools # Setze die Environment Variable für setuptools
@ -38,7 +44,7 @@ ENV SETUPTOOLS_USE_DISTUTILS=local \
COPY .github/scripts/requirements_dev.txt /tmp/requirements_dev.txt COPY .github/scripts/requirements_dev.txt /tmp/requirements_dev.txt
RUN python3 -m venv --system-site-packages /opt/venv \ RUN python3 -m venv --system-site-packages /opt/venv \
&& . /opt/venv/bin/activate \ && . /opt/venv/bin/activate \
&& pip install --no-cache-dir --require-hashes -r /tmp/requirements_dev.txt && pip install --no-cache-dir --only-binary=:all: --require-hashes -r /tmp/requirements_dev.txt
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind # Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
ENV PATH="/opt/venv/bin:$PATH" ENV PATH="/opt/venv/bin:$PATH"

View File

@ -17,9 +17,9 @@ WORKDIR /app
COPY . . COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=false # Build the application with DISABLE_ADDITIONAL_FEATURES=false
RUN DISABLE_ADDITIONAL_FEATURES=false \ ENV DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \ STIRLING_PDF_DESKTOP_UI=false
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube RUN ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage # Main stage
FROM alpine:3.22.2@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 FROM alpine:3.22.2@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412
@ -52,11 +52,14 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
TEMP=/tmp/stirling-pdf \ TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf TMP=/tmp/stirling-pdf
# JDK for app # JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ RUN apk add --no-cache bash \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ && ln -sf /bin/bash /bin/sh \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ && 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 upgrade --no-cache -a && \
apk add --no-cache \ apk add --no-cache \
ca-certificates \ ca-certificates \
@ -79,18 +82,22 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# OCR MY PDF (unpaper for descew and other advanced featues) # OCR MY PDF (unpaper for descew and other advanced featues)
tesseract-ocr-data-eng \ tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \ tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \ tesseract-ocr-data-deu \
tesseract-ocr-data-fra \ tesseract-ocr-data-fra \
tesseract-ocr-data-por \ tesseract-ocr-data-por \
unpaper \ unpaper \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \ font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \
# CV # CV / Python
py3-opencv \ py3-opencv \
python3 \ python3 \
ocrmypdf \ ocrmypdf \
py3-pip \ py3-pip \
py3-pillow@testing \ py3-pillow \
py3-pdf2image@testing && \ py3-pdf2image \
# Calibre (musl-native) + QtWebEngine Runtime
calibre && \
# Calibre fixes
apk fix --no-cache calibre && \
python3 -m venv /opt/venv && \ python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \ /opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \ /opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
@ -106,7 +113,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# User permissions # User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
ln -sf /bin/busybox /bin/sh
EXPOSE 8080/tcp EXPOSE 8080/tcp
# Set user and run command # Set user and run command

View File

@ -24,9 +24,13 @@ COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY app/core/build/libs/*.jar app.jar COPY app/core/build/libs/*.jar app.jar
# Set up necessary directories and permissions # Set up necessary directories and permissions
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ RUN apk add --no-cache bash \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ && ln -sf /bin/bash /bin/sh \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ && 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 upgrade --no-cache -a && \
apk add --no-cache \ apk add --no-cache \
ca-certificates \ ca-certificates \
@ -42,7 +46,8 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
chmod +x /scripts/*.sh && \ chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
ln -sf /bin/busybox /bin/sh
# Set environment variables # Set environment variables
ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI

View File

@ -52,6 +52,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
- **CBZ to PDF**: Convert comic book archives - **CBZ to PDF**: Convert comic book archives
- **CBR to PDF**: Convert comic book rar archives - **CBR to PDF**: Convert comic book rar archives
- **Email to PDF**: Convert email files to PDF - **Email to PDF**: Convert email files to PDF
- **eBook to PDF**: Convert eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF (using Calibre)
- **Vector Image to PDF**: Convert vector images (PS, EPS, EPSF) to PDF format - **Vector Image to PDF**: Convert vector images (PS, EPS, EPSF) to PDF format
#### Convert from PDF #### Convert from PDF

View File

@ -70,7 +70,7 @@ public class AppConfig {
@Bean(name = "loginEnabled") @Bean(name = "loginEnabled")
public boolean loginEnabled() { public boolean loginEnabled() {
return applicationProperties.getSecurity().getEnableLogin(); return applicationProperties.getSecurity().isEnableLogin();
} }
@Bean(name = "appName") @Bean(name = "appName")
@ -120,9 +120,7 @@ public class AppConfig {
@Bean(name = "enableAlphaFunctionality") @Bean(name = "enableAlphaFunctionality")
public boolean enableAlphaFunctionality() { public boolean enableAlphaFunctionality() {
return applicationProperties.getSystem().getEnableAlphaFunctionality() != null return applicationProperties.getSystem().isEnableAlphaFunctionality();
? applicationProperties.getSystem().getEnableAlphaFunctionality()
: false;
} }
@Bean(name = "rateLimit") @Bean(name = "rateLimit")

View File

@ -21,6 +21,7 @@ public class RuntimePathConfig {
private final String basePath; private final String basePath;
private final String weasyPrintPath; private final String weasyPrintPath;
private final String unoConvertPath; private final String unoConvertPath;
private final String calibrePath;
// Pipeline paths // Pipeline paths
private final String pipelineWatchedFoldersPath; private final String pipelineWatchedFoldersPath;
@ -57,6 +58,7 @@ public class RuntimePathConfig {
// Initialize Operation paths // Initialize Operation paths
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint"; String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert"; String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
String defaultCalibrePath = isDocker ? "/usr/bin/ebook-convert" : "ebook-convert";
Operations operations = properties.getSystem().getCustomPaths().getOperations(); Operations operations = properties.getSystem().getCustomPaths().getOperations();
this.weasyPrintPath = this.weasyPrintPath =
@ -67,6 +69,9 @@ public class RuntimePathConfig {
resolvePath( resolvePath(
defaultUnoConvertPath, defaultUnoConvertPath,
operations != null ? operations.getUnoconvert() : null); operations != null ? operations.getUnoconvert() : null);
this.calibrePath =
resolvePath(
defaultCalibrePath, operations != null ? operations.getCalibre() : null);
} }
private String resolvePath(String defaultPath, String customPath) { private String resolvePath(String defaultPath, String customPath) {

View File

@ -112,8 +112,8 @@ public class ApplicationProperties {
@Data @Data
public static class Security { public static class Security {
private Boolean enableLogin; private boolean enableLogin;
private Boolean csrfDisabled; private boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin(); private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2(); private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2(); private SAML2 saml2 = new SAML2();
@ -295,8 +295,8 @@ public class ApplicationProperties {
throw new UnsupportedProviderException( throw new UnsupportedProviderException(
"Logout from the provider " "Logout from the provider "
+ registrationId + registrationId
+ " is not supported. " + " is not supported. Report it at"
+ "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues"); + " https://github.com/Stirling-Tools/Stirling-PDF/issues");
}; };
} }
} }
@ -314,19 +314,19 @@ public class ApplicationProperties {
@Data @Data
public static class System { public static class System {
private String defaultLocale; private String defaultLocale;
private Boolean googlevisibility; private boolean googlevisibility;
private boolean showUpdate; private boolean showUpdate;
private Boolean showUpdateOnlyAdmin; private boolean showUpdateOnlyAdmin;
private boolean customHTMLFiles; private boolean customHTMLFiles;
private String tessdataDir; private String tessdataDir;
private Boolean enableAlphaFunctionality; private boolean enableAlphaFunctionality;
private Boolean enableAnalytics; private Boolean enableAnalytics;
private Boolean enablePosthog; private Boolean enablePosthog;
private Boolean enableScarf; private Boolean enableScarf;
private Datasource datasource; private Datasource datasource;
private Boolean disableSanitize; private boolean disableSanitize;
private int maxDPI; private int maxDPI;
private Boolean enableUrlToPDF; private boolean enableUrlToPDF;
private Html html = new Html(); private Html html = new Html();
private CustomPaths customPaths = new CustomPaths(); private CustomPaths customPaths = new CustomPaths();
private String fileUploadLimit; private String fileUploadLimit;
@ -371,6 +371,7 @@ public class ApplicationProperties {
public static class Operations { public static class Operations {
private String weasyprint; private String weasyprint;
private String unoconvert; private String unoconvert;
private String calibre;
} }
} }
@ -453,10 +454,10 @@ public class ApplicationProperties {
@Override @Override
public String toString() { public String toString() {
return """ return """
Driver { Driver {
driverName='%s' driverName='%s'
} }
""" """
.formatted(driverName); .formatted(driverName);
} }
} }
@ -491,7 +492,7 @@ public class ApplicationProperties {
@Data @Data
public static class Metrics { public static class Metrics {
private Boolean enabled; private boolean enabled;
} }
@Data @Data

View File

@ -253,11 +253,11 @@ public class PostHogService {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"security_enableLogin", "security_enableLogin",
applicationProperties.getSecurity().getEnableLogin()); applicationProperties.getSecurity().isEnableLogin());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"security_csrfDisabled", "security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled()); applicationProperties.getSecurity().isCsrfDisabled());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"security_loginAttemptCount", "security_loginAttemptCount",
@ -302,13 +302,13 @@ public class PostHogService {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_googlevisibility", "system_googlevisibility",
applicationProperties.getSystem().getGooglevisibility()); applicationProperties.getSystem().isGooglevisibility());
addIfNotEmpty( addIfNotEmpty(
properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate()); properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_showUpdateOnlyAdmin", "system_showUpdateOnlyAdmin",
applicationProperties.getSystem().getShowUpdateOnlyAdmin()); applicationProperties.getSystem().isShowUpdateOnlyAdmin());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_customHTMLFiles", "system_customHTMLFiles",
@ -320,7 +320,7 @@ public class PostHogService {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_enableAlphaFunctionality", "system_enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality()); applicationProperties.getSystem().isEnableAlphaFunctionality());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_enableAnalytics", "system_enableAnalytics",
@ -337,7 +337,7 @@ public class PostHogService {
// Capture Metrics properties // Capture Metrics properties
addIfNotEmpty( addIfNotEmpty(
properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled()); properties, "metrics_enabled", applicationProperties.getMetrics().isEnabled());
// Capture EnterpriseEdition properties // Capture EnterpriseEdition properties
addIfNotEmpty( addIfNotEmpty(

View File

@ -62,8 +62,7 @@ public class CustomHtmlSanitizer {
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory()); .and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
public String sanitize(String html) { public String sanitize(String html) {
boolean disableSanitize = boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize();
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
return disableSanitize ? html : POLICY.sanitize(html); return disableSanitize ? html : POLICY.sanitize(html);
} }
} }

View File

@ -13,6 +13,8 @@ import org.junit.jupiter.params.provider.CsvSource;
public class FileInfoTest { public class FileInfoTest {
private static final LocalDateTime FIXED_NOW = LocalDateTime.of(2025, 11, 1, 12, 0, 0);
@ParameterizedTest(name = "{index}: fileSize={0}") @ParameterizedTest(name = "{index}: fileSize={0}")
@CsvSource({ @CsvSource({
"0, '0 Bytes'", "0, '0 Bytes'",
@ -28,9 +30,9 @@ public class FileInfoTest {
new FileInfo( new FileInfo(
"example.txt", "example.txt",
"/path/to/example.txt", "/path/to/example.txt",
LocalDateTime.now(), FIXED_NOW,
fileSize, fileSize,
LocalDateTime.now().minusDays(1)); FIXED_NOW.minusDays(1));
assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize()); assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize());
} }
@ -45,9 +47,9 @@ public class FileInfoTest {
new FileInfo( new FileInfo(
"example.txt", "example.txt",
"/path/to/example.txt", "/path/to/example.txt",
LocalDateTime.now(), FIXED_NOW,
123, 123,
LocalDateTime.now().minusDays(1)); FIXED_NOW.minusDays(1));
Path path = fi.getFilePathAsPath(); Path path = fi.getFilePathAsPath();
@ -103,7 +105,7 @@ public class FileInfoTest {
"/path/to/example.txt", "/path/to/example.txt",
null, // modificationDate null null, // modificationDate null
1, 1,
LocalDateTime.now()); FIXED_NOW);
assertThrows( assertThrows(
NullPointerException.class, NullPointerException.class,
@ -120,7 +122,7 @@ public class FileInfoTest {
new FileInfo( new FileInfo(
"example.txt", "example.txt",
"/path/to/example.txt", "/path/to/example.txt",
LocalDateTime.now(), FIXED_NOW,
1, 1,
null); // creationDate null null); // creationDate null
@ -142,9 +144,9 @@ public class FileInfoTest {
new FileInfo( new FileInfo(
"example.txt", "example.txt",
"/path/to/example.txt", "/path/to/example.txt",
LocalDateTime.now(), FIXED_NOW,
1536, // 1.5 KB 1536, // 1.5 KB
LocalDateTime.now().minusDays(1)); FIXED_NOW.minusDays(1));
assertEquals("1.50 KB", fi.getFormattedFileSize()); assertEquals("1.50 KB", fi.getFormattedFileSize());
} }
@ -158,9 +160,9 @@ public class FileInfoTest {
new FileInfo( new FileInfo(
"example.txt", "example.txt",
"/path/to/example.txt", "/path/to/example.txt",
LocalDateTime.now(), FIXED_NOW,
twoTB, twoTB,
LocalDateTime.now().minusDays(1)); FIXED_NOW.minusDays(1));
// 2 TB equals 2048.00 GB with current implementation // 2 TB equals 2048.00 GB with current implementation
assertEquals( assertEquals(

View File

@ -6,7 +6,6 @@ import static org.mockito.Mockito.*;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -83,7 +82,7 @@ class FileStorageTest {
void testRetrieveFile() throws IOException { void testRetrieveFile() throws IOException {
// Arrange // Arrange
byte[] fileContent = "Test PDF content".getBytes(); byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString(); String fileId = "test-file-1";
Path filePath = tempDir.resolve(fileId); Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent); Files.write(filePath, fileContent);
@ -103,7 +102,7 @@ class FileStorageTest {
void testRetrieveBytes() throws IOException { void testRetrieveBytes() throws IOException {
// Arrange // Arrange
byte[] fileContent = "Test PDF content".getBytes(); byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString(); String fileId = "test-file-2";
Path filePath = tempDir.resolve(fileId); Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent); Files.write(filePath, fileContent);
@ -136,7 +135,7 @@ class FileStorageTest {
void testDeleteFile() throws IOException { void testDeleteFile() throws IOException {
// Arrange // Arrange
byte[] fileContent = "Test PDF content".getBytes(); byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString(); String fileId = "test-file-3";
Path filePath = tempDir.resolve(fileId); Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent); Files.write(filePath, fileContent);
@ -164,7 +163,7 @@ class FileStorageTest {
void testFileExists() throws IOException { void testFileExists() throws IOException {
// Arrange // Arrange
byte[] fileContent = "Test PDF content".getBytes(); byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString(); String fileId = "test-file-4";
Path filePath = tempDir.resolve(fileId); Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent); Files.write(filePath, fileContent);

View File

@ -166,13 +166,13 @@ class JobExecutorServiceTest {
// Given // Given
Supplier<Object> work = Supplier<Object> work =
() -> { () -> {
try { // Simulate long-running job without actual sleep
Thread.sleep(100); // Simulate long-running job // Use a loop to consume time instead of Thread.sleep
return "test-result"; long startTime = System.nanoTime();
} catch (InterruptedException e) { while (System.nanoTime() - startTime < 100_000_000) { // 100ms in nanoseconds
Thread.currentThread().interrupt(); // Busy wait to simulate work without Thread.sleep
throw new RuntimeException(e);
} }
return "test-result";
}; };
// Use reflection to access the private executeWithTimeout method // Use reflection to access the private executeWithTimeout method

View File

@ -126,12 +126,15 @@ class ResourceMonitorTest {
@Test @Test
void resourceMetricsShouldDetectStaleState() { void resourceMetricsShouldDetectStaleState() {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Given // Given
Instant now = Instant.now(); Instant pastInstant =
Instant pastInstant = now.minusMillis(6000); testTime.minusMillis(6000); // 6 seconds ago (relative to test start time)
ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant); ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant);
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now); ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, testTime);
// When/Then // When/Then
assertTrue( assertTrue(

View File

@ -5,7 +5,6 @@ import static org.mockito.Mockito.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -42,7 +41,7 @@ class TaskManagerTest {
@Test @Test
void testCreateTask() { void testCreateTask() {
// Act // Act
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-1";
taskManager.createTask(jobId); taskManager.createTask(jobId);
// Assert // Assert
@ -56,7 +55,7 @@ class TaskManagerTest {
@Test @Test
void testSetResult() { void testSetResult() {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-2";
taskManager.createTask(jobId); taskManager.createTask(jobId);
Object resultObject = "Test result"; Object resultObject = "Test result";
@ -74,7 +73,7 @@ class TaskManagerTest {
@Test @Test
void testSetFileResult() throws Exception { void testSetFileResult() throws Exception {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-3";
taskManager.createTask(jobId); taskManager.createTask(jobId);
String fileId = "file-id"; String fileId = "file-id";
String originalFileName = "test.pdf"; String originalFileName = "test.pdf";
@ -108,7 +107,7 @@ class TaskManagerTest {
@Test @Test
void testSetError() { void testSetError() {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-4";
taskManager.createTask(jobId); taskManager.createTask(jobId);
String errorMessage = "Test error"; String errorMessage = "Test error";
@ -126,7 +125,7 @@ class TaskManagerTest {
@Test @Test
void testSetComplete_WithExistingResult() { void testSetComplete_WithExistingResult() {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-5";
taskManager.createTask(jobId); taskManager.createTask(jobId);
Object resultObject = "Test result"; Object resultObject = "Test result";
taskManager.setResult(jobId, resultObject); taskManager.setResult(jobId, resultObject);
@ -144,7 +143,7 @@ class TaskManagerTest {
@Test @Test
void testSetComplete_WithoutExistingResult() { void testSetComplete_WithoutExistingResult() {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-6";
taskManager.createTask(jobId); taskManager.createTask(jobId);
// Act // Act
@ -160,7 +159,7 @@ class TaskManagerTest {
@Test @Test
void testIsComplete() { void testIsComplete() {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-7";
taskManager.createTask(jobId); taskManager.createTask(jobId);
// Assert - not complete initially // Assert - not complete initially
@ -216,6 +215,8 @@ class TaskManagerTest {
@Test @Test
void testCleanupOldJobs() { void testCleanupOldJobs() {
// Capture test time at the beginning for deterministic calculations
final LocalDateTime testTime = LocalDateTime.now();
// Arrange // Arrange
// 1. Create a recent completed job // 1. Create a recent completed job
String recentJobId = "recent-job"; String recentJobId = "recent-job";
@ -227,8 +228,9 @@ class TaskManagerTest {
taskManager.createTask(oldJobId); taskManager.createTask(oldJobId);
JobResult oldJob = taskManager.getJobResult(oldJobId); JobResult oldJob = taskManager.getJobResult(oldJobId);
// Manually set the completion time to be older than the expiry // Manually set the completion time to be older than the expiry (relative to test start
LocalDateTime oldTime = LocalDateTime.now().minusHours(1); // time)
LocalDateTime oldTime = testTime.minusHours(1);
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime); ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
ReflectionTestUtils.setField(oldJob, "complete", true); ReflectionTestUtils.setField(oldJob, "complete", true);
@ -280,7 +282,7 @@ class TaskManagerTest {
@Test @Test
void testAddNote() { void testAddNote() {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = "test-job-8";
taskManager.createTask(jobId); taskManager.createTask(jobId);
String note = "Test note"; String note = "Test note";

View File

@ -131,6 +131,9 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations // Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) { try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for each directory we'll process // Mock Files.list for each directory we'll process
mockedFiles mockedFiles
.when(() -> Files.list(eq(systemTempDir))) .when(() -> Files.list(eq(systemTempDir)))
@ -175,18 +178,17 @@ public class TempFileCleanupServiceTest {
// maxAgeMillis // maxAgeMillis
if (fileName.contains("old")) { if (fileName.contains("old")) {
return FileTime.fromMillis( return FileTime.fromMillis(
System.currentTimeMillis() - 5000000); testTime - 5000000); // ~1.4 hours ago
} }
// For empty.tmp file, return a timestamp older than 5 minutes (for // For empty.tmp file, return a timestamp older than 5 minutes (for
// empty file test) // empty file test)
else if (fileName.equals("empty.tmp")) { else if ("empty.tmp".equals(fileName)) {
return FileTime.fromMillis( return FileTime.fromMillis(
System.currentTimeMillis() - 6 * 60 * 1000); testTime - 6 * 60 * 1000); // 6 minutes ago
} }
// For all other files, return a recent timestamp // For all other files, return a recent timestamp
else { else {
return FileTime.fromMillis( return FileTime.fromMillis(testTime - 60000); // 1 minute ago
System.currentTimeMillis() - 60000); // 1 minute ago
} }
}); });
@ -199,7 +201,7 @@ public class TempFileCleanupServiceTest {
String fileName = path.getFileName().toString(); String fileName = path.getFileName().toString();
// Return 0 bytes for the empty file // Return 0 bytes for the empty file
if (fileName.equals("empty.tmp")) { if ("empty.tmp".equals(fileName)) {
return 0L; return 0L;
} }
// Return normal size for all other files // Return normal size for all other files
@ -274,6 +276,9 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations // Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) { try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for systemTempDir // Mock Files.list for systemTempDir
mockedFiles mockedFiles
.when(() -> Files.list(eq(systemTempDir))) .when(() -> Files.list(eq(systemTempDir)))
@ -288,9 +293,7 @@ public class TempFileCleanupServiceTest {
// Configure Files.getLastModifiedTime to return recent timestamps // Configure Files.getLastModifiedTime to return recent timestamps
mockedFiles mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class))) .when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenReturn( .thenReturn(FileTime.fromMillis(testTime - 60000)); // 1 minute ago
FileTime.fromMillis(
System.currentTimeMillis() - 60000)); // 1 minute ago
// Configure Files.size to return normal size // Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB
@ -335,6 +338,9 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations // Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) { try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for systemTempDir // Mock Files.list for systemTempDir
mockedFiles mockedFiles
.when(() -> Files.list(eq(systemTempDir))) .when(() -> Files.list(eq(systemTempDir)))
@ -354,14 +360,14 @@ public class TempFileCleanupServiceTest {
Path path = invocation.getArgument(0); Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString(); String fileName = path.getFileName().toString();
if (fileName.equals("empty.tmp")) { if ("empty.tmp".equals(fileName)) {
// More than 5 minutes old // More than 5 minutes old
return FileTime.fromMillis( return FileTime.fromMillis(
System.currentTimeMillis() - 6 * 60 * 1000); testTime - 6 * 60 * 1000); // 6 minutes ago
} else { } else {
// Less than 5 minutes old // Less than 5 minutes old
return FileTime.fromMillis( return FileTime.fromMillis(
System.currentTimeMillis() - 2 * 60 * 1000); testTime - 2 * 60 * 1000); // 2 minutes ago
} }
}); });
@ -410,14 +416,25 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations // Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) { try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for each directory // Mock Files.list for each directory
mockedFiles.when(() -> Files.list(eq(systemTempDir))).thenReturn(Stream.of(dir1)); mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
.thenAnswer(invocation -> Stream.of(dir1));
mockedFiles.when(() -> Files.list(eq(dir1))).thenReturn(Stream.of(tempFile1, dir2)); mockedFiles
.when(() -> Files.list(eq(dir1)))
.thenAnswer(invocation -> Stream.of(tempFile1, dir2));
mockedFiles.when(() -> Files.list(eq(dir2))).thenReturn(Stream.of(tempFile2, dir3)); mockedFiles
.when(() -> Files.list(eq(dir2)))
.thenAnswer(invocation -> Stream.of(tempFile2, dir3));
mockedFiles.when(() -> Files.list(eq(dir3))).thenReturn(Stream.of(tempFile3)); mockedFiles
.when(() -> Files.list(eq(dir3)))
.thenAnswer(invocation -> Stream.of(tempFile3));
// Configure Files.isDirectory for each path // Configure Files.isDirectory for each path
mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true); mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true);
@ -430,6 +447,9 @@ public class TempFileCleanupServiceTest {
// Configure Files.exists to return true for all paths // Configure Files.exists to return true for all paths
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.size to return 0 for all files (ensure they're not empty)
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
// Configure Files.getLastModifiedTime to return different times based on file names // Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class))) .when(() -> Files.getLastModifiedTime(any(Path.class)))
@ -439,19 +459,14 @@ public class TempFileCleanupServiceTest {
String fileName = path.getFileName().toString(); String fileName = path.getFileName().toString();
if (fileName.contains("old")) { if (fileName.contains("old")) {
// Old file // Old file - very old timestamp (older than 1 hour)
return FileTime.fromMillis( return FileTime.fromMillis(testTime - 7200000); // 2 hours ago
System.currentTimeMillis() - 5000000);
} else { } else {
// Recent file // Recent file - very recent timestamp (less than 1 hour)
return FileTime.fromMillis(System.currentTimeMillis() - 60000); return FileTime.fromMillis(testTime - 60000); // 1 minute ago
} }
}); });
// Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
// For deleteIfExists, track which files would be deleted
mockedFiles mockedFiles
.when(() -> Files.deleteIfExists(any(Path.class))) .when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer( .thenAnswer(
@ -461,13 +476,9 @@ public class TempFileCleanupServiceTest {
return true; return true;
}); });
// Act // Act - pass maxAgeMillis = 3600000 (1 hour)
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000); invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
// Debug - print what was deleted
System.out.println("Deleted files: " + deletedFiles);
System.out.println("Looking for: " + tempFile3);
// Assert // Assert
assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved"); assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved"); assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved");

View File

@ -36,7 +36,7 @@ class CustomHtmlSanitizerTest {
// strict-stubbing failures when individual tests bypass certain branches. // strict-stubbing failures when individual tests bypass certain branches.
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true); lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true);
lenient().when(applicationProperties.getSystem()).thenReturn(systemProperties); lenient().when(applicationProperties.getSystem()).thenReturn(systemProperties);
lenient().when(systemProperties.getDisableSanitize()).thenReturn(false); lenient().when(systemProperties.isDisableSanitize()).thenReturn(false);
customHtmlSanitizer = new CustomHtmlSanitizer(ssrfProtectionService, applicationProperties); customHtmlSanitizer = new CustomHtmlSanitizer(ssrfProtectionService, applicationProperties);
} }
@ -374,7 +374,7 @@ class CustomHtmlSanitizerTest {
"<p>ok</p><script>alert('XSS')</script><img src=\"http://blocked.local/a.png\">"; "<p>ok</p><script>alert('XSS')</script><img src=\"http://blocked.local/a.png\">";
// For this test, disable sanitize // For this test, disable sanitize
when(systemProperties.getDisableSanitize()).thenReturn(true); when(systemProperties.isDisableSanitize()).thenReturn(true);
// Also ensure SSRF would block it if sanitization were enabled (to prove bypass) // Also ensure SSRF would block it if sanitization were enabled (to prove bypass)
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false); lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);

View File

@ -48,7 +48,7 @@ class EmlToPdfTest {
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString())) when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(true); .thenReturn(true);
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem); when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
when(mockSystem.getDisableSanitize()).thenReturn(false); when(mockSystem.isDisableSanitize()).thenReturn(false);
customHtmlSanitizer = customHtmlSanitizer =
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties); new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);

View File

@ -45,12 +45,15 @@ class FileMonitorTest {
@Test @Test
void testIsFileReadyForProcessing_OldFile() throws IOException { void testIsFileReadyForProcessing_OldFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file // Create a test file
Path testFile = tempDir.resolve("test-file.txt"); Path testFile = tempDir.resolve("test-file.txt");
Files.write(testFile, "test content".getBytes()); Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago // Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// File should be ready for processing as it was modified more than 5 seconds ago // File should be ready for processing as it was modified more than 5 seconds ago
assertTrue(fileMonitor.isFileReadyForProcessing(testFile)); assertTrue(fileMonitor.isFileReadyForProcessing(testFile));
@ -58,12 +61,15 @@ class FileMonitorTest {
@Test @Test
void testIsFileReadyForProcessing_RecentFile() throws IOException { void testIsFileReadyForProcessing_RecentFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file // Create a test file
Path testFile = tempDir.resolve("recent-file.txt"); Path testFile = tempDir.resolve("recent-file.txt");
Files.write(testFile, "test content".getBytes()); Files.write(testFile, "test content".getBytes());
// Set modified time to just now // Set modified time to just now (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now())); Files.setLastModifiedTime(testFile, FileTime.from(testTime));
// File should not be ready for processing as it was just modified // File should not be ready for processing as it was just modified
assertFalse(fileMonitor.isFileReadyForProcessing(testFile)); assertFalse(fileMonitor.isFileReadyForProcessing(testFile));
@ -80,12 +86,16 @@ class FileMonitorTest {
@Test @Test
void testIsFileReadyForProcessing_LockedFile() throws IOException { void testIsFileReadyForProcessing_LockedFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file // Create a test file
Path testFile = tempDir.resolve("locked-file.txt"); Path testFile = tempDir.resolve("locked-file.txt");
Files.write(testFile, "test content".getBytes()); Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago to make sure it passes the time check // Set modified time to 10 seconds ago (relative to test start time) to make sure it passes
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); // the time check
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// Verify the file is considered ready when it meets the time criteria // Verify the file is considered ready when it meets the time criteria
assertTrue( assertTrue(
@ -104,12 +114,12 @@ class FileMonitorTest {
// Create a PDF file // Create a PDF file
Path pdfFile = tempDir.resolve("test.pdf"); Path pdfFile = tempDir.resolve("test.pdf");
Files.write(pdfFile, "pdf content".getBytes()); Files.write(pdfFile, "pdf content".getBytes());
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.now().minusMillis(10000))); Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
// Create a TXT file // Create a TXT file
Path txtFile = tempDir.resolve("test.txt"); Path txtFile = tempDir.resolve("test.txt");
Files.write(txtFile, "text content".getBytes()); Files.write(txtFile, "text content".getBytes());
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.now().minusMillis(10000))); Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
// PDF file should be ready for processing // PDF file should be ready for processing
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile)); assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
@ -125,12 +135,15 @@ class FileMonitorTest {
@Test @Test
void testIsFileReadyForProcessing_FileInUse() throws IOException { void testIsFileReadyForProcessing_FileInUse() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file // Create a test file
Path testFile = tempDir.resolve("in-use-file.txt"); Path testFile = tempDir.resolve("in-use-file.txt");
Files.write(testFile, "initial content".getBytes()); Files.write(testFile, "initial content".getBytes());
// Set modified time to 10 seconds ago // Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// First check that the file is ready when meeting time criteria // First check that the file is ready when meeting time criteria
assertTrue( assertTrue(
@ -139,7 +152,7 @@ class FileMonitorTest {
// After modifying the file to simulate closing, it should still be ready // After modifying the file to simulate closing, it should still be ready
Files.write(testFile, "updated content".getBytes()); Files.write(testFile, "updated content".getBytes());
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
assertTrue( assertTrue(
fileMonitor.isFileReadyForProcessing(testFile), fileMonitor.isFileReadyForProcessing(testFile),
@ -148,12 +161,15 @@ class FileMonitorTest {
@Test @Test
void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException { void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file // Create a test file
Path testFile = tempDir.resolve("absolute-path-file.txt"); Path testFile = tempDir.resolve("absolute-path-file.txt");
Files.write(testFile, "test content".getBytes()); Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago // Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// File should be ready for processing as it was modified more than 5 seconds ago // File should be ready for processing as it was modified more than 5 seconds ago
// Use the absolute path to make sure it's handled correctly // Use the absolute path to make sure it's handled correctly
@ -167,7 +183,7 @@ class FileMonitorTest {
Files.createDirectory(testDir); Files.createDirectory(testDir);
// Set modified time to 10 seconds ago // Set modified time to 10 seconds ago
Files.setLastModifiedTime(testDir, FileTime.from(Instant.now().minusMillis(10000))); Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
// A directory should not be considered ready for processing // A directory should not be considered ready for processing
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir); boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);

View File

@ -29,7 +29,7 @@ public class FileToPdfTest {
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString())) when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(true); .thenReturn(true);
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem); when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
when(mockSystem.getDisableSanitize()).thenReturn(false); when(mockSystem.isDisableSanitize()).thenReturn(false);
customHtmlSanitizer = customHtmlSanitizer =
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties); new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);

View File

@ -55,7 +55,7 @@ dependencies {
implementation project(':common') implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty' implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'com.posthog.java:posthog:1.2.0' implementation 'com.posthog.java:posthog:1.2.0'
implementation 'commons-io:commons-io:2.20.0' implementation 'commons-io:commons-io:2.21.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion" implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core:1.15.5' implementation 'io.micrometer:micrometer-core:1.15.5'

View File

@ -257,6 +257,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "html-to-pdf"); addEndpointToGroup("Convert", "html-to-pdf");
addEndpointToGroup("Convert", "url-to-pdf"); addEndpointToGroup("Convert", "url-to-pdf");
addEndpointToGroup("Convert", "markdown-to-pdf"); addEndpointToGroup("Convert", "markdown-to-pdf");
addEndpointToGroup("Convert", "ebook-to-pdf");
addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf"); addEndpointToGroup("Convert", "eml-to-pdf");
@ -446,6 +447,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Weasyprint", "markdown-to-pdf"); addEndpointToGroup("Weasyprint", "markdown-to-pdf");
addEndpointToGroup("Weasyprint", "eml-to-pdf"); addEndpointToGroup("Weasyprint", "eml-to-pdf");
// Calibre dependent endpoints
addEndpointToGroup("Calibre", "ebook-to-pdf");
// Pdftohtml dependent endpoints // Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html"); addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown"); addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
@ -475,7 +479,7 @@ public class EndpointConfiguration {
disableGroup("enterprise"); disableGroup("enterprise");
} }
if (!applicationProperties.getSystem().getEnableUrlToPDF()) { if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
disableEndpoint("url-to-pdf"); disableEndpoint("url-to-pdf");
} }
} }
@ -498,6 +502,7 @@ public class EndpointConfiguration {
|| "Javascript".equals(group) || "Javascript".equals(group)
|| "Weasyprint".equals(group) || "Weasyprint".equals(group)
|| "Pdftohtml".equals(group) || "Pdftohtml".equals(group)
|| "Calibre".equals(group)
|| "rar".equals(group) || "rar".equals(group)
|| "FFmpeg".equals(group); || "FFmpeg".equals(group);
} }

View File

@ -40,6 +40,7 @@ public class ExternalAppDepConfig {
private final String weasyprintPath; private final String weasyprintPath;
private final String unoconvPath; private final String unoconvPath;
private final String calibrePath;
/** /**
* Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid * Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid
@ -56,6 +57,7 @@ public class ExternalAppDepConfig {
this.endpointConfiguration = endpointConfiguration; this.endpointConfiguration = endpointConfiguration;
this.weasyprintPath = runtimePathConfig.getWeasyPrintPath(); this.weasyprintPath = runtimePathConfig.getWeasyPrintPath();
this.unoconvPath = runtimePathConfig.getUnoConvertPath(); this.unoconvPath = runtimePathConfig.getUnoConvertPath();
this.calibrePath = runtimePathConfig.getCalibrePath();
Map<String, List<String>> tmp = new HashMap<>(); Map<String, List<String>> tmp = new HashMap<>();
tmp.put("gs", List.of("Ghostscript")); tmp.put("gs", List.of("Ghostscript"));
@ -67,6 +69,7 @@ public class ExternalAppDepConfig {
tmp.put("qpdf", List.of("qpdf")); tmp.put("qpdf", List.of("qpdf"));
tmp.put("tesseract", List.of("tesseract")); tmp.put("tesseract", List.of("tesseract"));
tmp.put("rar", List.of("rar")); tmp.put("rar", List.of("rar"));
tmp.put(calibrePath, List.of("Calibre"));
tmp.put("ffmpeg", List.of("FFmpeg")); tmp.put("ffmpeg", List.of("FFmpeg"));
this.commandToGroupMapping = Collections.unmodifiableMap(tmp); this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
} }

View File

@ -61,11 +61,9 @@ public class InitialSetup {
public void initEnableCSRFSecurity() throws IOException { public void initEnableCSRFSecurity() throws IOException {
if (GeneralUtils.isVersionHigher( if (GeneralUtils.isVersionHigher(
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) { "0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled(); boolean csrf = applicationProperties.getSecurity().isCsrfDisabled();
if (!csrf) { if (!csrf) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true); GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
applicationProperties.getSecurity().setCsrfDisabled(false);
} }
} }
} }

View File

@ -50,7 +50,7 @@ public class OpenApiConfig {
.url("https://www.stirlingpdf.com") .url("https://www.stirlingpdf.com")
.email("contact@stirlingpdf.com")) .email("contact@stirlingpdf.com"))
.description(DEFAULT_DESCRIPTION); .description(DEFAULT_DESCRIPTION);
if (!applicationProperties.getSecurity().getEnableLogin()) { if (!applicationProperties.getSecurity().isEnableLogin()) {
return new OpenAPI().components(new Components()).info(info); return new OpenAPI().components(new Components()).info(info);
} else { } else {
SecurityScheme apiKeyScheme = SecurityScheme apiKeyScheme =

View File

@ -72,19 +72,29 @@ public class MergeController {
// fileOrder is newline-delimited original filenames in the desired order. // fileOrder is newline-delimited original filenames in the desired order.
private static MultipartFile[] reorderFilesByProvidedOrder( private static MultipartFile[] reorderFilesByProvidedOrder(
MultipartFile[] files, String fileOrder) { MultipartFile[] files, String fileOrder) {
String[] desired = fileOrder.split("\n", -1); // Split by various line endings and trim each entry
String[] desired =
stirling.software.common.util.RegexPatternUtils.getInstance()
.getNewlineSplitPattern()
.split(fileOrder);
List<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files)); List<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files));
List<MultipartFile> ordered = new ArrayList<>(files.length); List<MultipartFile> ordered = new ArrayList<>(files.length);
for (String name : desired) { for (String name : desired) {
if (name == null || name.isEmpty()) continue; name = name.trim();
if (name.isEmpty()) {
log.debug("Skipping empty entry");
continue;
}
int idx = indexOfByOriginalFilename(remaining, name); int idx = indexOfByOriginalFilename(remaining, name);
if (idx >= 0) { if (idx >= 0) {
ordered.add(remaining.remove(idx)); ordered.add(remaining.remove(idx));
} else {
log.debug("Filename from order list not found in uploaded files: {}", name);
} }
} }
// Append any files not explicitly listed, preserving their relative order
ordered.addAll(remaining); ordered.addAll(remaining);
return ordered.toArray(new MultipartFile[0]); return ordered.toArray(new MultipartFile[0]);
} }
@ -252,8 +262,10 @@ public class MergeController {
// If front-end provided explicit visible order, honor it and override backend sorting // If front-end provided explicit visible order, honor it and override backend sorting
if (fileOrder != null && !fileOrder.isBlank()) { if (fileOrder != null && !fileOrder.isBlank()) {
log.info("Reordering files based on fileOrder parameter");
files = reorderFilesByProvidedOrder(files, fileOrder); files = reorderFilesByProvidedOrder(files, fileOrder);
} else { } else {
log.info("Sorting files based on sortType: {}", request.getSortType());
Arrays.sort( Arrays.sort(
files, files,
getSortComparator( getSortComparator(

View File

@ -0,0 +1,208 @@
package stirling.software.SPDF.controller.api.converters;
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.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
@Slf4j
public class ConvertEbookToPDFController {
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.of("epub", "mobi", "azw3", "fb2", "txt", "docx");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private boolean isCalibreEnabled() {
return endpointConfiguration.isGroupEnabled("Calibre");
}
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
@Operation(
summary = "Convert an eBook file to PDF",
description =
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertEbookToPdf(
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
if (!isCalibreEnabled()) {
throw new IllegalStateException("Calibre support is disabled");
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw new IllegalArgumentException("No input file provided");
}
boolean optimizeForEbook = Boolean.TRUE.equals(request.getOptimizeForEbook());
if (optimizeForEbook && !isGhostscriptEnabled()) {
log.warn(
"Ghostscript optimization requested but Ghostscript is not enabled/available"
+ " for ebook conversion");
optimizeForEbook = false;
}
boolean embedAllFonts = Boolean.TRUE.equals(request.getEmbedAllFonts());
boolean includeTableOfContents = Boolean.TRUE.equals(request.getIncludeTableOfContents());
boolean includePageNumbers = Boolean.TRUE.equals(request.getIncludePageNumbers());
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "document";
}
String extension = FilenameUtils.getExtension(originalFilename);
if (extension == null || extension.isBlank()) {
throw new IllegalArgumentException("Unable to determine file type");
}
String lowerExtension = extension.toLowerCase(Locale.ROOT);
if (!SUPPORTED_EXTENSIONS.contains(lowerExtension)) {
throw new IllegalArgumentException("Unsupported eBook file extension: " + extension);
}
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "document";
}
Path workingDirectory = tempFileManager.createTempDirectory();
Path inputPath = workingDirectory.resolve(baseName + "." + lowerExtension);
Path outputPath = workingDirectory.resolve(baseName + ".pdf");
try (InputStream inputStream = inputFile.getInputStream()) {
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
}
List<String> command =
buildCalibreCommand(
inputPath,
outputPath,
embedAllFonts,
includeTableOfContents,
includePageNumbers);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
.runCommandWithOutputHandling(command, workingDirectory.toFile());
if (result == null) {
throw new IllegalStateException("Calibre conversion returned no result");
}
if (result.getRc() != 0) {
String errorMessage = result.getMessages();
if (errorMessage == null || errorMessage.isBlank()) {
errorMessage = "Calibre conversion failed";
}
throw new IllegalStateException(errorMessage);
}
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
throw new IllegalStateException("Calibre did not produce a PDF output");
}
String outputFilename =
GeneralUtils.generateFilename(originalFilename, "_convertedToPDF.pdf");
try {
if (optimizeForEbook) {
byte[] pdfBytes = Files.readAllBytes(outputPath);
try {
byte[] optimizedPdf = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
return WebResponseUtils.bytesToWebResponse(optimizedPdf, outputFilename);
} catch (IOException e) {
log.warn(
"Ghostscript optimization failed for ebook conversion, returning"
+ " original PDF",
e);
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
}
try (PDDocument document = pdfDocumentFactory.load(outputPath.toFile())) {
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
}
} finally {
cleanupTempFiles(workingDirectory, inputPath, outputPath);
}
}
private List<String> buildCalibreCommand(
Path inputPath,
Path outputPath,
boolean embedAllFonts,
boolean includeTableOfContents,
boolean includePageNumbers) {
List<String> command = new ArrayList<>();
command.add("ebook-convert");
command.add(inputPath.toString());
command.add(outputPath.toString());
if (embedAllFonts) {
command.add("--embed-all-fonts");
}
if (includeTableOfContents) {
command.add("--pdf-add-toc");
}
if (includePageNumbers) {
command.add("--pdf-page-numbers");
}
return command;
}
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
List<Path> pathsToDelete = new ArrayList<>();
pathsToDelete.add(inputPath);
pathsToDelete.add(outputPath);
for (Path path : pathsToDelete) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", path, e);
}
}
tempFileManager.deleteTempDirectory(workingDirectory);
}
}

View File

@ -71,7 +71,7 @@ public class ConvertWebsiteToPDF {
URI location = null; URI location = null;
HttpStatus status = HttpStatus.SEE_OTHER; HttpStatus status = HttpStatus.SEE_OTHER;
if (!applicationProperties.getSystem().getEnableUrlToPDF()) { if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
location = location =
uriComponentsBuilder uriComponentsBuilder
.queryParam("error", "error.endpointDisabled") .queryParam("error", "error.endpointDisabled")

View File

@ -47,6 +47,13 @@ public class ConverterWebController {
return "convert/cbr-to-pdf"; return "convert/cbr-to-pdf";
} }
@GetMapping("/ebook-to-pdf")
@Hidden
public String convertEbookToPdfForm(Model model) {
model.addAttribute("currentPage", "ebook-to-pdf");
return "convert/ebook-to-pdf";
}
@GetMapping("/pdf-to-cbr") @GetMapping("/pdf-to-cbr")
@Hidden @Hidden
public String convertPdfToCbrForm(Model model) { public String convertPdfToCbrForm(Model model) {

View File

@ -84,8 +84,8 @@ public class HomeWebController {
@ResponseBody @ResponseBody
@Hidden @Hidden
public String getRobotsTxt() { public String getRobotsTxt() {
Boolean allowGoogle = applicationProperties.getSystem().getGooglevisibility(); boolean allowGoogle = applicationProperties.getSystem().isGooglevisibility();
if (Boolean.TRUE.equals(allowGoogle)) { if (allowGoogle) {
return "User-agent: Googlebot\nAllow: /\n\nUser-agent: *\nAllow: /"; return "User-agent: Googlebot\nAllow: /\n\nUser-agent: *\nAllow: /";
} else { } else {
return "User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /"; return "User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /";

View File

@ -42,9 +42,7 @@ public class MetricsController {
@PostConstruct @PostConstruct
public void init() { public void init() {
Boolean metricsEnabled = applicationProperties.getMetrics().getEnabled(); metricsEnabled = applicationProperties.getMetrics().isEnabled();
if (metricsEnabled == null) metricsEnabled = true;
this.metricsEnabled = metricsEnabled;
} }
@GetMapping("/status") @GetMapping("/status")

View File

@ -0,0 +1,53 @@
package stirling.software.SPDF.model.api.converters;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ConvertEbookToPdfRequest {
@Schema(
description =
"The input eBook file to be converted to a PDF file (EPUB, MOBI, AZW3, FB2,"
+ " TXT, DOCX)",
contentMediaType =
"application/epub+zip, application/x-mobipocket-ebook, application/x-azw3,"
+ " text/xml, text/plain,"
+ " application/vnd.openxmlformats-officedocument.wordprocessingml.document",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@Schema(
description = "Embed all fonts from the eBook into the generated PDF",
allowableValues = {"true", "false"},
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean embedAllFonts;
@Schema(
description = "Add a generated table of contents to the resulting PDF",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean includeTableOfContents;
@Schema(
description = "Add page numbers to the generated PDF",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean includePageNumbers;
@Schema(
description =
"Optimize the PDF for eBook reading (smaller file size, better rendering on"
+ " eInk devices)",
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean optimizeForEbook;
}

View File

@ -616,6 +616,10 @@ home.cbrToPdf.title=CBR zu PDF
home.cbrToPdf.desc=CBR-Comicarchive in das PDF-Format konvertieren. home.cbrToPdf.desc=CBR-Comicarchive in das PDF-Format konvertieren.
cbrToPdf.tags=konvertierung,comic,buch,archiv,cbr,rar cbrToPdf.tags=konvertierung,comic,buch,archiv,cbr,rar
home.ebookToPdf.title=E-Book zu PDF
home.ebookToPdf.desc=E-Book-Dateien (EPUB, MOBI, AZW3, FB2, TXT, DOCX) mit Calibre in PDF konvertieren.
ebookToPdf.tags=konvertierung,ebook,calibre,epub,mobi,azw3
home.pdfToCbz.title=PDF zu CBZ home.pdfToCbz.title=PDF zu CBZ
home.pdfToCbz.desc=PDF-Dateien in CBZ-Comicarchive umwandeln. home.pdfToCbz.desc=PDF-Dateien in CBZ-Comicarchive umwandeln.
pdfToCbz.tags=konvertierung,comic,buch,archiv,cbz,pdf pdfToCbz.tags=konvertierung,comic,buch,archiv,cbz,pdf
@ -1490,6 +1494,17 @@ cbrToPDF.submit=Zu PDF konvertieren
cbrToPDF.selectText=CBR-Datei auswählen cbrToPDF.selectText=CBR-Datei auswählen
cbrToPDF.optimizeForEbook=PDF für E-Book-Reader optimieren (verwendet Ghostscript) cbrToPDF.optimizeForEbook=PDF für E-Book-Reader optimieren (verwendet Ghostscript)
#ebookToPDF
ebookToPDF.title=E-Book zu PDF
ebookToPDF.header=E-Book zu PDF
ebookToPDF.submit=Zu PDF konvertieren
ebookToPDF.selectText=E-Book-Datei auswählen
ebookToPDF.embedAllFonts=Alle Schriftarten in der erzeugten PDF einbetten (kann die Dateigröße erhöhen)
ebookToPDF.includeTableOfContents=Inhaltsverzeichnis zur erzeugten PDF hinzufügen
ebookToPDF.includePageNumbers=Seitenzahlen zur erzeugten PDF hinzufügen
ebookToPDF.optimizeForEbook=PDF für E-Book-Reader optimieren (verwendet Ghostscript)
ebookToPDF.calibreDisabled=Calibre-Unterstützung ist deaktiviert. Aktivieren Sie die Calibre-Werkzeuggruppe oder installieren Sie Calibre, um diese Funktion zu nutzen.
#pdfToCBR #pdfToCBR
pdfToCBR.title=PDF zu CBR pdfToCBR.title=PDF zu CBR
pdfToCBR.header=PDF zu CBR pdfToCBR.header=PDF zu CBR

View File

@ -616,6 +616,10 @@ home.cbrToPdf.title=CBR to PDF
home.cbrToPdf.desc=Convert CBR comic book archives to PDF format. home.cbrToPdf.desc=Convert CBR comic book archives to PDF format.
cbrToPdf.tags=conversion,comic,book,archive,cbr,rar cbrToPdf.tags=conversion,comic,book,archive,cbr,rar
home.ebookToPdf.title=eBook to PDF
home.ebookToPdf.desc=Convert eBook files (EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF using Calibre.
ebookToPdf.tags=conversion,ebook,calibre,epub,mobi,azw3
home.pdfToCbz.title=PDF to CBZ home.pdfToCbz.title=PDF to CBZ
home.pdfToCbz.desc=Convert PDF files to CBZ comic book archives. home.pdfToCbz.desc=Convert PDF files to CBZ comic book archives.
pdfToCbz.tags=conversion,comic,book,archive,cbz,pdf pdfToCbz.tags=conversion,comic,book,archive,cbz,pdf
@ -1503,6 +1507,17 @@ cbrToPDF.submit=Convert to PDF
cbrToPDF.selectText=Select CBR file cbrToPDF.selectText=Select CBR file
cbrToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript) cbrToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript)
#ebookToPDF
ebookToPDF.title=eBook to PDF
ebookToPDF.header=eBook to PDF
ebookToPDF.submit=Convert to PDF
ebookToPDF.selectText=Select eBook file
ebookToPDF.embedAllFonts=Embed all fonts in the output PDF (may increase file size)
ebookToPDF.includeTableOfContents=Add a generated table of contents to the PDF
ebookToPDF.includePageNumbers=Add page numbers to the generated PDF
ebookToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript)
ebookToPDF.calibreDisabled=Calibre support is disabled. Enable the Calibre tool group or install Calibre to use this feature.
#pdfToCBR #pdfToCBR
pdfToCBR.title=PDF to CBR pdfToCBR.title=PDF to CBR
pdfToCBR.header=PDF to CBR pdfToCBR.header=PDF to CBR

View File

@ -149,6 +149,7 @@ system:
operations: operations:
weasyprint: '' # Defaults to /opt/venv/bin/weasyprint weasyprint: '' # Defaults to /opt/venv/bin/weasyprint
unoconvert: '' # Defaults to /opt/venv/bin/unoconvert unoconvert: '' # Defaults to /opt/venv/bin/unoconvert
calibre: '' # Defaults to /usr/bin/ebook-convert
fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB". fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
tempFileManagement: tempFileManagement:
baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf

View File

@ -74,6 +74,12 @@
showGameBtn.style.display = 'none'; showGameBtn.style.display = 'none';
} }
// Log fileOrder for debugging
const fileOrderValue = formData.get('fileOrder');
if (fileOrderValue) {
console.log('FormData fileOrder:', fileOrderValue);
}
// Remove empty file entries // Remove empty file entries
for (let [key, value] of formData.entries()) { for (let [key, value] of formData.entries()) {
if (value instanceof File && !value.name) { if (value instanceof File && !value.name) {

View File

@ -123,39 +123,38 @@ function attachMoveButtons() {
} }
} }
document.getElementById("sortByNameBtn").addEventListener("click", function () { document.getElementById("sortByNameBtn").addEventListener("click", async function () {
if (currentSort.field === "name" && !currentSort.descending) { if (currentSort.field === "name" && !currentSort.descending) {
currentSort.descending = true; currentSort.descending = true;
sortFiles((a, b) => b.name.localeCompare(a.name)); await sortFiles((a, b) => b.name.localeCompare(a.name));
} else { } else {
currentSort.field = "name"; currentSort.field = "name";
currentSort.descending = false; currentSort.descending = false;
sortFiles((a, b) => a.name.localeCompare(b.name)); await sortFiles((a, b) => a.name.localeCompare(b.name));
} }
}); });
document.getElementById("sortByDateBtn").addEventListener("click", function () { document.getElementById("sortByDateBtn").addEventListener("click", async function () {
if (currentSort.field === "lastModified" && !currentSort.descending) { if (currentSort.field === "lastModified" && !currentSort.descending) {
currentSort.descending = true; currentSort.descending = true;
sortFiles((a, b) => b.lastModified - a.lastModified); await sortFiles((a, b) => b.lastModified - a.lastModified);
} else { } else {
currentSort.field = "lastModified"; currentSort.field = "lastModified";
currentSort.descending = false; currentSort.descending = false;
sortFiles((a, b) => a.lastModified - b.lastModified); await sortFiles((a, b) => a.lastModified - b.lastModified);
} }
}); });
function sortFiles(comparator) { async function sortFiles(comparator) {
// Convert FileList to array and sort // Convert FileList to array and sort
const sortedFilesArray = Array.from(document.getElementById("fileInput-input").files).sort(comparator); const sortedFilesArray = Array.from(document.getElementById("fileInput-input").files).sort(comparator);
// Refresh displayed list // Refresh displayed list (wait for it to complete since it's async)
displayFiles(sortedFilesArray); await displayFiles(sortedFilesArray);
// Update the files property // Update the file input and fileOrder based on the current display order
const dataTransfer = new DataTransfer(); // This ensures consistency between display and file input
sortedFilesArray.forEach((file) => dataTransfer.items.add(file)); updateFiles();
document.getElementById("fileInput-input").files = dataTransfer.files;
} }
function updateFiles() { function updateFiles() {
@ -163,25 +162,36 @@ function updateFiles() {
var liElements = document.querySelectorAll("#selectedFiles li"); var liElements = document.querySelectorAll("#selectedFiles li");
const files = document.getElementById("fileInput-input").files; const files = document.getElementById("fileInput-input").files;
console.log("updateFiles: found", liElements.length, "LI elements and", files.length, "files");
for (var i = 0; i < liElements.length; i++) { for (var i = 0; i < liElements.length; i++) {
var fileNameFromList = liElements[i].querySelector(".filename").innerText; var fileNameFromList = liElements[i].querySelector(".filename").innerText;
var fileFromFiles; var found = false;
for (var j = 0; j < files.length; j++) { for (var j = 0; j < files.length; j++) {
var file = files[j]; var file = files[j];
if (file.name === fileNameFromList) { if (file.name === fileNameFromList) {
dataTransfer.items.add(file); dataTransfer.items.add(file);
found = true;
break; break;
} }
} }
if (!found) {
console.warn("updateFiles: Could not find file:", fileNameFromList);
}
} }
document.getElementById("fileInput-input").files = dataTransfer.files; document.getElementById("fileInput-input").files = dataTransfer.files;
console.log("updateFiles: Updated file input with", dataTransfer.files.length, "files");
// Also populate hidden fileOrder to preserve visible order // Also populate hidden fileOrder to preserve visible order
const order = Array.from(liElements) const order = Array.from(liElements)
.map((li) => li.querySelector(".filename").innerText) .map((li) => li.querySelector(".filename").innerText)
.join("\n"); .join("\n");
const orderInput = document.getElementById("fileOrder"); const orderInput = document.getElementById("fileOrder");
if (orderInput) orderInput.value = order; if (orderInput) {
orderInput.value = order;
console.log("Updated fileOrder:", order);
}
} }
document.querySelector("#resetFileInputBtn").addEventListener("click", ()=>{ document.querySelector("#resetFileInputBtn").addEventListener("click", ()=>{

View File

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html th:data-language="${#locale.toString()}"
th:dir="#{language.direction}"
th:lang="${#locale.language}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{ebookToPDF.title}, header=#{ebookToPDF.header})}"></th:block>
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon convertto">menu_book</span>
<span class="tool-header-text"
th:text="#{ebookToPDF.header}"></span>
</div>
<p th:text="#{processTimeWarning}"></p>
<div class="alert alert-warning"
th:if="${!@endpointConfiguration.isGroupEnabled('Calibre')}">
<span th:text="#{ebookToPDF.calibreDisabled}">Calibre support is disabled.</span>
</div>
<form enctype="multipart/form-data"
id="ebookToPDFForm"
method="post"
th:action="@{'/api/v1/convert/ebook/pdf'}"
th:if="${@endpointConfiguration.isGroupEnabled('Calibre')}">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.epub,.mobi,.azw3,.fb2,.txt,.docx', inputText=#{ebookToPDF.selectText})}">
</div>
<div class="form-check mb-2">
<input class="form-check-input"
id="embedAllFonts"
name="embedAllFonts"
type="checkbox"
value="true">
<label for="embedAllFonts"
th:text="#{ebookToPDF.embedAllFonts}">
Embed all fonts in PDF
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input"
id="includeTableOfContents"
name="includeTableOfContents"
type="checkbox"
value="true">
<label
for="includeTableOfContents"
th:text="#{ebookToPDF.includeTableOfContents}">
Add table of contents
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input"
id="includePageNumbers"
name="includePageNumbers"
type="checkbox"
value="true">
<label
for="includePageNumbers"
th:text="#{ebookToPDF.includePageNumbers}">
Add page numbers
</label>
</div>
<div class="form-check mb-3"
th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
<input class="form-check-input"
id="optimizeForEbook"
name="optimizeForEbook"
type="checkbox"
value="true">
<label
for="optimizeForEbook"
th:text="#{ebookToPDF.optimizeForEbook}">
Optimize PDF for ebook readers (uses Ghostscript)
</label>
</div>
<button class="btn btn-primary"
id="submitBtn"
th:text="#{ebookToPDF.submit}"
type="submit">Convert to
PDF</button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@ -53,6 +53,9 @@
<div <div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}"> th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
</div> </div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('ebook-to-pdf', 'menu_book', 'home.ebookToPdf.title', 'home.ebookToPdf.desc', 'ebookToPdf.tags', 'convertto')}">
</div>
<div <div
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}"> th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
</div> </div>
@ -132,6 +135,9 @@
<div <div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}"> th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
</div> </div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('ebook-to-pdf', 'menu_book', 'home.ebookToPdf.title', 'home.ebookToPdf.desc', 'ebookToPdf.tags', 'convertto')}">
</div>
<div <div
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}"> th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
</div> </div>

View File

@ -0,0 +1,266 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.ProcessExecutor.Processes;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertEbookToPDFControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertEbookToPDFController controller;
@Test
void convertEbookToPdf_buildsCalibreCommandAndCleansUp() throws Exception {
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
MockMultipartFile ebookFile =
new MockMultipartFile(
"fileInput", "ebook.epub", "application/epub+zip", "content".getBytes());
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
request.setFileInput(ebookFile);
request.setEmbedAllFonts(true);
request.setIncludeTableOfContents(true);
request.setIncludePageNumbers(true);
Path workingDir = Files.createTempDirectory("ebook-convert-test-");
when(tempFileManager.createTempDirectory()).thenReturn(workingDir);
AtomicReference<Path> deletedDir = new AtomicReference<>();
Mockito.doAnswer(
invocation -> {
Path dir = invocation.getArgument(0);
deletedDir.set(dir);
if (Files.exists(dir)) {
try (Stream<Path> paths = Files.walk(dir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
return null;
})
.when(tempFileManager)
.deleteTempDirectory(any(Path.class));
PDDocument mockDocument = Mockito.mock(PDDocument.class);
when(pdfDocumentFactory.load(any(File.class))).thenReturn(mockDocument);
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor);
ProcessExecutorResult execResult = Mockito.mock(ProcessExecutorResult.class);
when(execResult.getRc()).thenReturn(0);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
Path expectedInput = workingDir.resolve("ebook.epub");
Path expectedOutput = workingDir.resolve("ebook.pdf");
when(executor.runCommandWithOutputHandling(
commandCaptor.capture(), eq(workingDir.toFile())))
.thenAnswer(
invocation -> {
Files.writeString(expectedOutput, "pdf");
return execResult;
});
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok("result".getBytes());
wr.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
mockDocument, "ebook_convertedToPDF.pdf"))
.thenReturn(expectedResponse);
gu.when(() -> GeneralUtils.generateFilename("ebook.epub", "_convertedToPDF.pdf"))
.thenReturn("ebook_convertedToPDF.pdf");
ResponseEntity<byte[]> response = controller.convertEbookToPdf(request);
assertSame(expectedResponse, response);
List<String> command = commandCaptor.getValue();
assertEquals(6, command.size());
assertEquals("ebook-convert", command.get(0));
assertEquals(expectedInput.toString(), command.get(1));
assertEquals(expectedOutput.toString(), command.get(2));
assertEquals("--embed-all-fonts", command.get(3));
assertEquals("--pdf-add-toc", command.get(4));
assertEquals("--pdf-page-numbers", command.get(5));
assertFalse(Files.exists(expectedInput));
assertFalse(Files.exists(expectedOutput));
assertEquals(workingDir, deletedDir.get());
Mockito.verify(tempFileManager).deleteTempDirectory(workingDir);
}
if (Files.exists(workingDir)) {
try (Stream<Path> paths = Files.walk(workingDir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
}
@Test
void convertEbookToPdf_withUnsupportedExtensionThrows() {
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
MockMultipartFile unsupported =
new MockMultipartFile(
"fileInput", "ebook.exe", "application/octet-stream", new byte[] {1, 2, 3});
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
request.setFileInput(unsupported);
assertThrows(IllegalArgumentException.class, () -> controller.convertEbookToPdf(request));
}
@Test
void convertEbookToPdf_withOptimizeForEbookUsesGhostscript() throws Exception {
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
MockMultipartFile ebookFile =
new MockMultipartFile(
"fileInput", "ebook.epub", "application/epub+zip", "content".getBytes());
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
request.setFileInput(ebookFile);
request.setOptimizeForEbook(true);
Path workingDir = Files.createTempDirectory("ebook-convert-opt-test-");
when(tempFileManager.createTempDirectory()).thenReturn(workingDir);
AtomicReference<Path> deletedDir = new AtomicReference<>();
Mockito.doAnswer(
invocation -> {
Path dir = invocation.getArgument(0);
deletedDir.set(dir);
if (Files.exists(dir)) {
try (Stream<Path> paths = Files.walk(dir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
return null;
})
.when(tempFileManager)
.deleteTempDirectory(any(Path.class));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor);
ProcessExecutorResult execResult = Mockito.mock(ProcessExecutorResult.class);
when(execResult.getRc()).thenReturn(0);
Path expectedInput = workingDir.resolve("ebook.epub");
Path expectedOutput = workingDir.resolve("ebook.pdf");
when(executor.runCommandWithOutputHandling(any(List.class), eq(workingDir.toFile())))
.thenAnswer(
invocation -> {
Files.writeString(expectedOutput, "pdf");
return execResult;
});
gu.when(() -> GeneralUtils.generateFilename("ebook.epub", "_convertedToPDF.pdf"))
.thenReturn("ebook_convertedToPDF.pdf");
byte[] optimizedBytes = "optimized".getBytes(StandardCharsets.UTF_8);
gu.when(() -> GeneralUtils.optimizePdfWithGhostscript(Mockito.any(byte[].class)))
.thenReturn(optimizedBytes);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(optimizedBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
optimizedBytes, "ebook_convertedToPDF.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertEbookToPdf(request);
assertSame(expectedResponse, response);
gu.verify(() -> GeneralUtils.optimizePdfWithGhostscript(Mockito.any(byte[].class)));
Mockito.verifyNoInteractions(pdfDocumentFactory);
Mockito.verify(tempFileManager).deleteTempDirectory(workingDir);
assertEquals(workingDir, deletedDir.get());
assertFalse(Files.exists(expectedInput));
assertFalse(Files.exists(expectedOutput));
}
if (Files.exists(workingDir)) {
try (Stream<Path> paths = Files.walk(workingDir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
}
}

View File

@ -0,0 +1,195 @@
package stirling.software.SPDF.controller.web;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.CheckProgramInstall;
@ExtendWith(MockitoExtension.class)
class ConverterWebControllerTest {
private MockMvc mockMvc;
private ConverterWebController controller;
@BeforeEach
void setup() {
controller = new ConverterWebController();
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
private static Stream<Object[]> simpleEndpoints() {
return Stream.of(
new Object[] {"/img-to-pdf", "convert/img-to-pdf", "img-to-pdf"},
new Object[] {"/cbz-to-pdf", "convert/cbz-to-pdf", "cbz-to-pdf"},
new Object[] {"/pdf-to-cbz", "convert/pdf-to-cbz", "pdf-to-cbz"},
new Object[] {"/cbr-to-pdf", "convert/cbr-to-pdf", "cbr-to-pdf"},
new Object[] {"/html-to-pdf", "convert/html-to-pdf", "html-to-pdf"},
new Object[] {"/markdown-to-pdf", "convert/markdown-to-pdf", "markdown-to-pdf"},
new Object[] {"/pdf-to-markdown", "convert/pdf-to-markdown", "pdf-to-markdown"},
new Object[] {"/url-to-pdf", "convert/url-to-pdf", "url-to-pdf"},
new Object[] {"/file-to-pdf", "convert/file-to-pdf", "file-to-pdf"},
new Object[] {"/pdf-to-pdfa", "convert/pdf-to-pdfa", "pdf-to-pdfa"},
new Object[] {"/pdf-to-vector", "convert/pdf-to-vector", "pdf-to-vector"},
new Object[] {"/vector-to-pdf", "convert/vector-to-pdf", "vector-to-pdf"},
new Object[] {"/pdf-to-xml", "convert/pdf-to-xml", "pdf-to-xml"},
new Object[] {"/pdf-to-csv", "convert/pdf-to-csv", "pdf-to-csv"},
new Object[] {"/pdf-to-html", "convert/pdf-to-html", "pdf-to-html"},
new Object[] {
"/pdf-to-presentation", "convert/pdf-to-presentation", "pdf-to-presentation"
},
new Object[] {"/pdf-to-text", "convert/pdf-to-text", "pdf-to-text"},
new Object[] {"/pdf-to-word", "convert/pdf-to-word", "pdf-to-word"},
new Object[] {"/eml-to-pdf", "convert/eml-to-pdf", "eml-to-pdf"});
}
@ParameterizedTest(name = "[{index}] GET {0}")
@MethodSource("simpleEndpoints")
@DisplayName("Should return correct view and model for simple endpoints")
void shouldReturnCorrectViewForSimpleEndpoints(String path, String viewName, String page)
throws Exception {
mockMvc.perform(get(path))
.andExpect(status().isOk())
.andExpect(view().name(viewName))
.andExpect(model().attribute("currentPage", page));
}
@Nested
@DisplayName("PDF to CBR endpoint tests")
class PdfToCbrTests {
@Test
@DisplayName("Should return 404 when endpoint disabled")
void shouldReturn404WhenDisabled() throws Exception {
try (MockedStatic<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class);
when(endpointConfig.isEndpointEnabled(eq("pdf-to-cbr"))).thenReturn(false);
acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class))
.thenReturn(endpointConfig);
mockMvc.perform(get("/pdf-to-cbr")).andExpect(status().isNotFound());
}
}
@Test
@DisplayName("Should return OK when endpoint enabled")
void shouldReturnOkWhenEnabled() throws Exception {
try (MockedStatic<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class);
when(endpointConfig.isEndpointEnabled(eq("pdf-to-cbr"))).thenReturn(true);
acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class))
.thenReturn(endpointConfig);
mockMvc.perform(get("/pdf-to-cbr"))
.andExpect(status().isOk())
.andExpect(view().name("convert/pdf-to-cbr"))
.andExpect(model().attribute("currentPage", "pdf-to-cbr"));
}
}
}
@Test
@DisplayName("Should handle pdf-to-img with default maxDPI=500")
void shouldHandlePdfToImgWithDefaultMaxDpi() throws Exception {
try (MockedStatic<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class);
MockedStatic<CheckProgramInstall> cpi =
org.mockito.Mockito.mockStatic(CheckProgramInstall.class)) {
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(null);
mockMvc.perform(get("/pdf-to-img"))
.andExpect(status().isOk())
.andExpect(view().name("convert/pdf-to-img"))
.andExpect(model().attribute("isPython", true))
.andExpect(model().attribute("maxDPI", 500));
}
}
@Test
@DisplayName("Should handle pdf-to-video with default maxDPI=500")
void shouldHandlePdfToVideoWithDefaultMaxDpi() throws Exception {
try (MockedStatic<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(null);
mockMvc.perform(get("/pdf-to-video"))
.andExpect(status().isOk())
.andExpect(view().name("convert/pdf-to-video"))
.andExpect(model().attribute("maxDPI", 500))
.andExpect(model().attribute("currentPage", "pdf-to-video"));
}
}
@Test
@DisplayName("Should handle pdf-to-img with configured maxDPI from properties")
void shouldHandlePdfToImgWithConfiguredMaxDpi() throws Exception {
// Covers the 'if' branch (properties and system not null)
try (MockedStatic<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class);
MockedStatic<CheckProgramInstall> cpi =
org.mockito.Mockito.mockStatic(CheckProgramInstall.class)) {
ApplicationProperties properties =
org.mockito.Mockito.mock(
ApplicationProperties.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
when(properties.getSystem().getMaxDPI()).thenReturn(777);
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(properties);
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
mockMvc.perform(get("/pdf-to-img"))
.andExpect(status().isOk())
.andExpect(view().name("convert/pdf-to-img"))
.andExpect(model().attribute("isPython", true))
.andExpect(model().attribute("maxDPI", 777))
.andExpect(model().attribute("currentPage", "pdf-to-img"));
}
}
@Test
@DisplayName("Should handle pdf-to-video with configured maxDPI from properties")
void shouldHandlePdfToVideoWithConfiguredMaxDpi() throws Exception {
// Covers the 'if' branch (properties and system not null)
try (MockedStatic<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
ApplicationProperties properties =
org.mockito.Mockito.mock(
ApplicationProperties.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
when(properties.getSystem().getMaxDPI()).thenReturn(640);
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(properties);
mockMvc.perform(get("/pdf-to-video"))
.andExpect(status().isOk())
.andExpect(view().name("convert/pdf-to-video"))
.andExpect(model().attribute("maxDPI", 640))
.andExpect(model().attribute("currentPage", "pdf-to-video"));
}
}
}

View File

@ -0,0 +1,406 @@
package stirling.software.SPDF.controller.web;
import static org.hamcrest.Matchers.empty;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.view.AbstractView;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.GeneralUtils;
@ExtendWith(MockitoExtension.class)
class GeneralWebControllerTest {
private static final String CLASSPATH_WOFF2 = "classpath:static/fonts/*.woff2";
private static final String FILE_FONTS_GLOB = "file:/opt/static/fonts/*";
private static String normalize(String s) {
return s.replace('\\', '/');
}
private static ViewResolver noOpViewResolver() {
return (viewName, locale) ->
new AbstractView() {
@Override
protected void renderMergedOutputModel(
Map<String, Object> model,
HttpServletRequest request,
HttpServletResponse response) {
// no-op
}
};
}
@SuppressWarnings("unused")
private static Stream<Object[]> simpleEndpoints() {
return Stream.of(
new Object[] {"/merge-pdfs", "merge-pdfs", "merge-pdfs"},
new Object[] {
"/split-pdf-by-sections", "split-pdf-by-sections", "split-pdf-by-sections"
},
new Object[] {
"/split-pdf-by-chapters", "split-pdf-by-chapters", "split-pdf-by-chapters"
},
new Object[] {"/view-pdf", "view-pdf", "view-pdf"},
new Object[] {
"/edit-table-of-contents", "edit-table-of-contents", "edit-table-of-contents"
},
new Object[] {"/multi-tool", "multi-tool", "multi-tool"},
new Object[] {"/remove-pages", "remove-pages", "remove-pages"},
new Object[] {"/pdf-organizer", "pdf-organizer", "pdf-organizer"},
new Object[] {"/extract-page", "extract-page", "extract-page"},
new Object[] {"/pdf-to-single-page", "pdf-to-single-page", "pdf-to-single-page"},
new Object[] {"/rotate-pdf", "rotate-pdf", "rotate-pdf"},
new Object[] {"/split-pdfs", "split-pdfs", "split-pdfs"},
new Object[] {"/multi-page-layout", "multi-page-layout", "multi-page-layout"},
new Object[] {"/scale-pages", "scale-pages", "scale-pages"},
new Object[] {
"/split-by-size-or-count", "split-by-size-or-count", "split-by-size-or-count"
},
new Object[] {"/overlay-pdf", "overlay-pdf", "overlay-pdf"},
new Object[] {"/crop", "crop", "crop"},
new Object[] {"/auto-split-pdf", "auto-split-pdf", "auto-split-pdf"},
new Object[] {"/remove-image-pdf", "remove-image-pdf", "remove-image-pdf"});
}
private MockMvc mockMvc;
private SignatureService signatureService;
private UserServiceInterface userService;
private RuntimePathConfig runtimePathConfig;
private org.springframework.core.io.ResourceLoader resourceLoader;
private GeneralWebController controller;
@BeforeEach
void setUp() {
signatureService = mock(SignatureService.class);
userService = mock(UserServiceInterface.class);
runtimePathConfig = mock(RuntimePathConfig.class);
resourceLoader = mock(org.springframework.core.io.ResourceLoader.class);
controller =
new GeneralWebController(
signatureService, userService, resourceLoader, runtimePathConfig);
mockMvc =
MockMvcBuilders.standaloneSetup(controller)
.setViewResolvers(noOpViewResolver())
.build();
}
@Nested
@DisplayName("Simple endpoints")
class SimpleEndpoints {
@DisplayName("Should render simple pages with correct currentPage")
@ParameterizedTest(name = "[{index}] GET {0} -> view {1}")
@MethodSource(
"stirling.software.SPDF.controller.web.GeneralWebControllerTest#simpleEndpoints")
void shouldRenderSimplePages(String path, String expectedView, String currentPage)
throws Exception {
mockMvc.perform(get(path))
.andExpect(status().isOk())
.andExpect(view().name(expectedView))
.andExpect(model().attribute("currentPage", currentPage));
}
}
@Nested
@DisplayName("/sign endpoint")
class SignForm {
@Test
@DisplayName("Should use current username, list signatures and fonts")
void shouldPopulateModelWithUserSignaturesAndFonts() throws Exception {
when(userService.getCurrentUsername()).thenReturn("alice");
List<SignatureFile> signatures = List.of(new SignatureFile(), new SignatureFile());
when(signatureService.getAvailableSignatures("alice")).thenReturn(signatures);
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> ipc =
mockStatic(InstallationPathConfig.class)) {
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
Resource woff2 = mock(Resource.class);
when(woff2.getFilename()).thenReturn("Roboto-Regular.woff2");
Resource ttf = mock(Resource.class);
when(ttf.getFilename()).thenReturn("MyFont.ttf");
// Windows-safe conditional stub (normalize backslashes)
gu.when(
() ->
GeneralUtils.getResourcesFromLocationPattern(
anyString(), eq(resourceLoader)))
.thenAnswer(
inv -> {
String pattern = normalize(inv.getArgument(0, String.class));
if (CLASSPATH_WOFF2.equals(pattern))
return new Resource[] {woff2};
if (FILE_FONTS_GLOB.equals(pattern))
return new Resource[] {ttf};
return new Resource[0];
});
var mvcResult =
mockMvc.perform(get("/sign"))
.andExpect(status().isOk())
.andExpect(view().name("sign"))
.andExpect(model().attribute("currentPage", "sign"))
.andExpect(model().attributeExists("fonts"))
.andExpect(model().attribute("signatures", signatures))
.andReturn();
Object fontsAttr = mvcResult.getModelAndView().getModel().get("fonts");
Assertions.assertTrue(fontsAttr instanceof List<?>);
List<?> fonts = (List<?>) fontsAttr;
Assertions.assertEquals(
2, fonts.size(), "Expected two font entries (classpath + external)");
}
}
@Test
@DisplayName("Should handle missing UserService (username empty string)")
void shouldHandleNullUserService() throws Exception {
GeneralWebController ctrl =
new GeneralWebController(
signatureService, null, resourceLoader, runtimePathConfig);
MockMvc localMvc =
MockMvcBuilders.standaloneSetup(ctrl)
.setViewResolvers(noOpViewResolver())
.build();
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> ipc =
mockStatic(InstallationPathConfig.class)) {
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
gu.when(
() ->
GeneralUtils.getResourcesFromLocationPattern(
anyString(), eq(resourceLoader)))
.thenReturn(new Resource[0]);
when(signatureService.getAvailableSignatures(""))
.thenReturn(Collections.emptyList());
localMvc.perform(get("/sign"))
.andExpect(status().isOk())
.andExpect(view().name("sign"))
.andExpect(model().attribute("currentPage", "sign"))
.andExpect(model().attribute("signatures", empty()));
}
}
@Test
@DisplayName(
"Throws ServletException when a font file cannot be processed (inner try/catch"
+ " path)")
void shouldThrowServletExceptionWhenFontProcessingFails() {
when(userService.getCurrentUsername()).thenReturn("alice");
when(signatureService.getAvailableSignatures("alice"))
.thenReturn(Collections.emptyList());
Resource bad = mock(Resource.class);
when(bad.getFilename()).thenThrow(new RuntimeException("boom"));
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> ipc =
mockStatic(InstallationPathConfig.class)) {
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
gu.when(
() ->
GeneralUtils.getResourcesFromLocationPattern(
anyString(), eq(resourceLoader)))
.thenReturn(new Resource[] {bad});
Assertions.assertThrows(
ServletException.class,
() -> {
mockMvc.perform(get("/sign")).andReturn();
});
}
}
@Test
@DisplayName("Ignores font resource without extension (no crash, filtered out)")
void shouldIgnoreFontWithoutExtension() throws Exception {
when(userService.getCurrentUsername()).thenReturn("bob");
when(signatureService.getAvailableSignatures("bob"))
.thenReturn(Collections.emptyList());
Resource noExt = mock(Resource.class);
when(noExt.getFilename()).thenReturn("JustAName"); // no dot -> filtered out
Resource good = mock(Resource.class);
when(good.getFilename()).thenReturn("SomeFont.woff2");
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> ipc =
mockStatic(InstallationPathConfig.class)) {
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
gu.when(
() ->
GeneralUtils.getResourcesFromLocationPattern(
anyString(), eq(resourceLoader)))
.thenAnswer(
inv -> {
String p = normalize(inv.getArgument(0, String.class));
if (CLASSPATH_WOFF2.equals(p))
return new Resource[] {noExt}; // ignored
if (FILE_FONTS_GLOB.equals(p))
return new Resource[] {good}; // kept
return new Resource[0];
});
var mvcResult =
mockMvc.perform(get("/sign"))
.andExpect(status().isOk())
.andExpect(view().name("sign"))
.andExpect(model().attribute("currentPage", "sign"))
.andReturn();
Object fontsAttr = mvcResult.getModelAndView().getModel().get("fonts");
Assertions.assertTrue(fontsAttr instanceof List<?>);
List<?> fonts = (List<?>) fontsAttr;
Assertions.assertEquals(1, fonts.size(), "Only the valid font should remain");
}
}
}
@Nested
@DisplayName("/pipeline endpoint")
class PipelineForm {
@Test
@DisplayName("Should load JSON configs from runtime path and infer names")
void shouldLoadJsonConfigs() throws Exception {
Path tempDir = Files.createTempDirectory("pipelines");
Path a = tempDir.resolve("a.json");
Path b = tempDir.resolve("b.json");
Files.writeString(a, "{\"name\":\"Config A\",\"x\":1}", StandardCharsets.UTF_8);
Files.writeString(b, "{\"y\":2}", StandardCharsets.UTF_8);
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(tempDir.toString());
var mvcResult =
mockMvc.perform(get("/pipeline"))
.andExpect(status().isOk())
.andExpect(view().name("pipeline"))
.andExpect(model().attribute("currentPage", "pipeline"))
.andExpect(
model().attributeExists(
"pipelineConfigs", "pipelineConfigsWithNames"))
.andReturn();
Map<String, Object> model = mvcResult.getModelAndView().getModel();
@SuppressWarnings("unchecked")
List<String> configsRaw = (List<String>) model.get("pipelineConfigs");
@SuppressWarnings("unchecked")
List<Map<String, String>> configsNamed =
(List<Map<String, String>>) model.get("pipelineConfigsWithNames");
Assertions.assertEquals(2, configsRaw.size());
Assertions.assertEquals(2, configsNamed.size());
Set<String> names = new HashSet<>();
for (Map<String, String> m : configsNamed) {
names.add(m.get("name"));
Assertions.assertTrue(configsRaw.contains(m.get("json")));
}
Assertions.assertTrue(names.contains("Config A"));
Assertions.assertTrue(names.contains("b"));
}
@Test
@DisplayName("Should fall back to default entry when Files.walk throws IOException")
void shouldFallbackWhenWalkThrowsIOException() throws Exception {
Path tempDir = Files.createTempDirectory("pipelines"); // exists() -> true
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(tempDir.toString());
try (MockedStatic<Files> files = mockStatic(Files.class)) {
files.when(() -> Files.walk(any(Path.class)))
.thenThrow(new IOException("fail walk"));
var mvcResult =
mockMvc.perform(get("/pipeline"))
.andExpect(status().isOk())
.andExpect(view().name("pipeline"))
.andExpect(model().attribute("currentPage", "pipeline"))
.andReturn();
@SuppressWarnings("unchecked")
List<Map<String, String>> configsNamed =
(List<Map<String, String>>)
mvcResult
.getModelAndView()
.getModel()
.get("pipelineConfigsWithNames");
Assertions.assertEquals(
1, configsNamed.size(), "Should add a default placeholder on IOException");
Assertions.assertEquals(
"No preloaded configs found", configsNamed.get(0).get("name"));
Assertions.assertEquals("", configsNamed.get(0).get("json"));
}
}
}
@Nested
@DisplayName("getFormatFromExtension")
class GetFormatFromExtension {
@Test
@DisplayName("Should return empty string for unknown extensions (default branch)")
void shouldReturnDefaultForUnknown() {
Assertions.assertEquals("", controller.getFormatFromExtension("otf"));
Assertions.assertEquals("", controller.getFormatFromExtension("unknown"));
}
@Test
@DisplayName("Known extensions should map correctly")
void shouldMapKnownExtensions() {
Assertions.assertEquals("truetype", controller.getFormatFromExtension("ttf"));
Assertions.assertEquals("woff", controller.getFormatFromExtension("woff"));
Assertions.assertEquals("woff2", controller.getFormatFromExtension("woff2"));
Assertions.assertEquals("embedded-opentype", controller.getFormatFromExtension("eot"));
Assertions.assertEquals("svg", controller.getFormatFromExtension("svg"));
}
}
}

View File

@ -0,0 +1,223 @@
package stirling.software.SPDF.controller.web;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.view.AbstractView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
@ExtendWith(MockitoExtension.class)
class HomeWebControllerTest {
private MockMvc mockMvc;
private ApplicationProperties applicationProperties;
@BeforeEach
void setup() {
applicationProperties = mock(ApplicationProperties.class, RETURNS_DEEP_STUBS);
HomeWebController controller = new HomeWebController(applicationProperties);
mockMvc =
MockMvcBuilders.standaloneSetup(controller)
.setViewResolvers(noOpViewResolver())
.build();
}
private static ViewResolver noOpViewResolver() {
return (viewName, locale) ->
new AbstractView() {
@Override
protected void renderMergedOutputModel(
Map<String, Object> model,
HttpServletRequest request,
HttpServletResponse response) {
// no-op
}
};
}
@Nested
@DisplayName("Simple pages & redirects")
class SimplePagesAndRedirects {
@Test
@DisplayName("/about should return correct view and currentPage")
void about_shouldReturnView() throws Exception {
mockMvc.perform(get("/about"))
.andExpect(status().isOk())
.andExpect(view().name("about"))
.andExpect(model().attribute("currentPage", "about"));
}
@Test
@DisplayName("/releases should return correct view")
void releases_shouldReturnView() throws Exception {
mockMvc.perform(get("/releases"))
.andExpect(status().isOk())
.andExpect(view().name("releases"));
}
@Test
@DisplayName("/home should redirect to root")
void home_shouldRedirect() throws Exception {
// With the no-op resolver, "redirect:/" is treated as a view -> status OK
mockMvc.perform(get("/home"))
.andExpect(status().isOk())
.andExpect(view().name("redirect:/"));
}
@Test
@DisplayName("/home-legacy should redirect to root")
void homeLegacy_shouldRedirect() throws Exception {
mockMvc.perform(get("/home-legacy"))
.andExpect(status().isOk())
.andExpect(view().name("redirect:/"));
}
}
@Nested
@DisplayName("Home page with SHOW_SURVEY environment variable")
class HomePage {
@Test
@DisplayName("Should correctly map SHOW_SURVEY env var to showSurveyFromDocker")
void root_mapsEnvCorrectly() throws Exception {
String env = System.getenv("SHOW_SURVEY");
boolean expected = (env == null) || "true".equalsIgnoreCase(env);
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(view().name("home"))
.andExpect(model().attribute("currentPage", "home"))
.andExpect(model().attribute("showSurveyFromDocker", expected));
}
}
@Nested
@DisplayName("/robots.txt behavior")
class RobotsTxt {
@Test
@DisplayName("googlevisibility=true -> allow all agents")
void robots_allow() throws Exception {
when(applicationProperties.getSystem().isGooglevisibility()).thenReturn(true);
mockMvc.perform(get("/robots.txt"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN))
.andExpect(
content()
.string(
"User-agent: Googlebot\n"
+ "Allow: /\n\n"
+ "User-agent: *\n"
+ "Allow: /"));
}
@Test
@DisplayName("googlevisibility=false -> disallow all agents")
void robots_disallow() throws Exception {
when(applicationProperties.getSystem().isGooglevisibility()).thenReturn(false);
mockMvc.perform(get("/robots.txt"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN))
.andExpect(
content()
.string(
"User-agent: Googlebot\n"
+ "Disallow: /\n\n"
+ "User-agent: *\n"
+ "Disallow: /"));
}
@Test
@DisplayName("googlevisibility not set (default false) -> disallow all")
void robots_disallowWhenNotSet() throws Exception {
when(applicationProperties.getSystem().isGooglevisibility()).thenReturn(false);
mockMvc.perform(get("/robots.txt"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN))
.andExpect(
content()
.string(
"User-agent: Googlebot\n"
+ "Disallow: /\n\n"
+ "User-agent: *\n"
+ "Disallow: /"));
}
}
@Nested
@DisplayName("/licenses endpoint")
class Licenses {
@Test
@DisplayName("Should read JSON and set dependencies + currentPage on model")
void licenses_success() throws Exception {
// Minimal valid JSON matching Map<String, List<Dependency>>
String json = "{\"dependencies\":[{}]}";
try (MockedConstruction<ClassPathResource> mockedResource =
mockConstruction(
ClassPathResource.class,
(mock, ctx) ->
when(mock.getInputStream())
.thenReturn(
new ByteArrayInputStream(
json.getBytes(
StandardCharsets.UTF_8))))) {
var mvcResult =
mockMvc.perform(get("/licenses"))
.andExpect(status().isOk())
.andExpect(view().name("licenses"))
.andExpect(model().attribute("currentPage", "licenses"))
.andExpect(model().attributeExists("dependencies"))
.andReturn();
Object depsObj = mvcResult.getModelAndView().getModel().get("dependencies");
Assertions.assertTrue(depsObj instanceof java.util.List<?>);
Assertions.assertEquals(
1, ((java.util.List<?>) depsObj).size(), "Exactly one dependency expected");
}
}
@Test
@DisplayName("IOException while reading -> still returns licenses view")
void licenses_ioException() throws Exception {
try (MockedConstruction<ClassPathResource> mockedResource =
mockConstruction(
ClassPathResource.class,
(mock, ctx) ->
when(mock.getInputStream())
.thenThrow(new IOException("boom")))) {
mockMvc.perform(get("/licenses"))
.andExpect(status().isOk())
.andExpect(view().name("licenses"))
.andExpect(model().attribute("currentPage", "licenses"));
}
}
}
}

View File

@ -412,11 +412,11 @@ class TextFinderTest {
addTextToPage(document.getPage(i), "Page " + i + " contains searchable content."); addTextToPage(document.getPage(i), "Page " + i + " contains searchable content.");
} }
long startTime = System.currentTimeMillis(); long startTime = 1000000L; // Fixed start time
TextFinder textFinder = new TextFinder("searchable", false, false); TextFinder textFinder = new TextFinder("searchable", false, false);
textFinder.getText(document); textFinder.getText(document);
List<PDFText> foundTexts = textFinder.getFoundTexts(); List<PDFText> foundTexts = textFinder.getFoundTexts();
long endTime = System.currentTimeMillis(); long endTime = 1001000L; // Fixed end time
assertEquals(10, foundTexts.size()); assertEquals(10, foundTexts.size());
assertTrue( assertTrue(

View File

@ -126,7 +126,7 @@ public class AccountWebController {
SAML2 saml2 = securityProps.getSaml2(); SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active() if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality() && applicationProperties.getSystem().isEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) { && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider(); String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();

View File

@ -125,7 +125,7 @@ public class SecurityConfiguration {
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { if (securityProperties.isCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable); http.csrf(CsrfConfigurer::disable);
} }
@ -146,7 +146,7 @@ public class SecurityConfiguration {
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) .addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) { if (!securityProperties.isCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse(); CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler = CsrfTokenRequestAttributeHandler requestHandler =

View File

@ -27,7 +27,7 @@ class AppUpdateAuthService implements ShowAdminInterface {
if (!showUpdate) { if (!showUpdate) {
return showUpdate; return showUpdate;
} }
boolean showUpdateOnlyAdmin = applicationProperties.getSystem().getShowUpdateOnlyAdmin(); boolean showUpdateOnlyAdmin = applicationProperties.getSystem().isShowUpdateOnlyAdmin();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) { if (authentication == null || !authentication.isAuthenticated()) {
return !showUpdateOnlyAdmin; return !showUpdateOnlyAdmin;

View File

@ -8,6 +8,11 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
/**
* Comprehensive tests for AttemptCounter. Notes: - We avoid timing flakiness by using generous
* windows or setting lastAttemptTime to 'now'. - Where assumptions are made about edge-case
* behavior, they are documented in comments.
*/
class AttemptCounterTest { class AttemptCounterTest {
// --- Helper functions for reflection access to private fields --- // --- Helper functions for reflection access to private fields ---
@ -113,11 +118,14 @@ class AttemptCounterTest {
@DisplayName("returns FALSE when time difference is smaller than window") @DisplayName("returns FALSE when time difference is smaller than window")
void shouldReturnFalseWhenWithinWindow() { void shouldReturnFalseWhenWithinWindow() {
AttemptCounter counter = new AttemptCounter(); AttemptCounter counter = new AttemptCounter();
long window = 500L; // 500 ms long window = 5_000L; // 5 seconds - generous buffer to avoid timing flakiness
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
// Simulate: last action was (window - 1) ms ago // Changed: Avoid flaky 1ms margin. We set lastAttemptTime to 'now' and choose a large
setPrivateLong(counter, "lastAttemptTime", now - (window - 1)); // window so elapsed < window is reliably true despite scheduling/clock granularity.
// Changed: Reason for change -> eliminate timing flakiness that caused sporadic
// failures.
setPrivateLong(counter, "lastAttemptTime", now);
// Purpose: Inside the window -> no reset // Purpose: Inside the window -> no reset
assertFalse(counter.shouldReset(window), "Within the window, no reset should occur"); assertFalse(counter.shouldReset(window), "Within the window, no reset should occur");
@ -154,6 +162,39 @@ class AttemptCounterTest {
} }
} }
@Nested
@DisplayName("shouldReset(attemptIncrementTime) additional edge cases")
class AdditionalEdgeCases {
@Test
@DisplayName("returns TRUE when window is zero (elapsed >= 0 is always true)")
void shouldReset_shouldReturnTrueWhenWindowIsZero() {
AttemptCounter counter = new AttemptCounter();
// Set lastAttemptTime == now to avoid timing flakiness
long now = System.currentTimeMillis();
setPrivateLong(counter, "lastAttemptTime", now);
// Assumption/Documentation: current implementation uses 'elapsed >=
// attemptIncrementTime'
// With attemptIncrementTime == 0, condition is always true.
assertTrue(counter.shouldReset(0L), "Window=0 means the window has already elapsed");
}
@Test
@DisplayName("returns TRUE when window is negative (elapsed >= negative is always true)")
void shouldReset_shouldReturnTrueWhenWindowIsNegative() {
AttemptCounter counter = new AttemptCounter();
long now = System.currentTimeMillis();
setPrivateLong(counter, "lastAttemptTime", now);
// Assumption/Documentation: Negative window is treated as already elapsed.
assertTrue(
counter.shouldReset(-1L),
"Negative window is nonsensical and should result in reset=true (elapsed >="
+ " negative)");
}
}
@Test @Test
@DisplayName("Getters: return current values") @DisplayName("Getters: return current values")
void getters_shouldReturnCurrentValues() { void getters_shouldReturnCurrentValues() {

View File

@ -0,0 +1,239 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import stirling.software.proprietary.security.model.AttemptCounter;
/**
* Tests for LoginAttemptService#getRemainingAttempts(...) focusing on edge cases and documented
* behavior. We instantiate the service reflectively to avoid depending on a specific constructor
* signature. Private fields are set via reflection to keep existing production code unchanged.
*
* <p>Assumptions: - 'MAX_ATTEMPT' is a private int (possibly static final); we read it via
* reflection (static-aware). - 'attemptsCache' is a ConcurrentHashMap<String, AttemptCounter>. -
* 'isBlockedEnabled' is a boolean flag. - Behavior without clamping is intentional for now (can
* return negative values).
*/
class LoginAttemptServiceTest {
// --- Reflection helpers ---
private static Object constructLoginAttemptService() {
try {
Class<?> clazz =
Class.forName(
"stirling.software.proprietary.security.service.LoginAttemptService");
// Prefer a no-arg constructor if present; otherwise use the first and mock parameters.
Constructor<?>[] ctors = clazz.getDeclaredConstructors();
Arrays.stream(ctors).forEach(c -> c.setAccessible(true));
Constructor<?> target =
Arrays.stream(ctors)
.filter(c -> c.getParameterCount() == 0)
.findFirst()
.orElse(ctors[0]);
Object[] args = new Object[target.getParameterCount()];
Class<?>[] paramTypes = target.getParameterTypes();
for (int i = 0; i < paramTypes.length; i++) {
Class<?> p = paramTypes[i];
if (p.isPrimitive()) {
// Provide basic defaults for primitives
args[i] = defaultValueForPrimitive(p);
} else {
args[i] = Mockito.mock(p);
}
}
return target.newInstance(args);
} catch (Exception e) {
fail("Could not construct LoginAttemptService reflectively: " + e.getMessage());
return null; // unreachable
}
}
private static Object defaultValueForPrimitive(Class<?> p) {
if (p == boolean.class) return false;
if (p == byte.class) return (byte) 0;
if (p == short.class) return (short) 0;
if (p == char.class) return (char) 0;
if (p == int.class) return 0;
if (p == long.class) return 0L;
if (p == float.class) return 0f;
if (p == double.class) return 0d;
throw new IllegalArgumentException("Unsupported primitive: " + p);
}
private static void setPrivate(Object target, String fieldName, Object value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
if (Modifier.isStatic(f.getModifiers())) {
f.set(null, value);
} else {
f.set(target, value);
}
} catch (Exception e) {
fail("Could not set field '" + fieldName + "': " + e.getMessage());
}
}
private static void setPrivateBoolean(Object target, String fieldName, boolean value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
if (Modifier.isStatic(f.getModifiers())) {
f.setBoolean(null, value);
} else {
f.setBoolean(target, value);
}
} catch (Exception e) {
fail("Could not set boolean field '" + fieldName + "': " + e.getMessage());
}
}
private static int getPrivateInt(Object targetOrClassInstance, String fieldName) {
try {
Class<?> clazz =
targetOrClassInstance instanceof Class
? (Class<?>) targetOrClassInstance
: targetOrClassInstance.getClass();
Field f = clazz.getDeclaredField(fieldName);
f.setAccessible(true);
if (Modifier.isStatic(f.getModifiers())) {
return f.getInt(null);
} else {
return f.getInt(targetOrClassInstance);
}
} catch (Exception e) {
fail("Could not read int field '" + fieldName + "': " + e.getMessage());
return -1; // unreachable
}
}
// --- Tests ---
@Test
@DisplayName("getRemainingAttempts(): returns Integer.MAX_VALUE when disabled or key blank")
void getRemainingAttempts_shouldReturnMaxValueWhenDisabledOrBlankKey() throws Exception {
Object svc = constructLoginAttemptService();
// Ensure blocking disabled
setPrivateBoolean(svc, "isBlockedEnabled", false);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
// Case 1: disabled -> always MAX_VALUE regardless of key
int disabledVal = (Integer) method.invoke(svc, "someUser");
assertEquals(
Integer.MAX_VALUE,
disabledVal,
"Disabled tracking should return Integer.MAX_VALUE");
// Enable and verify blank/whitespace/null handling
setPrivateBoolean(svc, "isBlockedEnabled", true);
int nullKeyVal = (Integer) method.invoke(svc, (Object) null);
int blankKeyVal = (Integer) method.invoke(svc, " ");
assertEquals(
Integer.MAX_VALUE,
nullKeyVal,
"Null key should return Integer.MAX_VALUE per current contract");
assertEquals(
Integer.MAX_VALUE,
blankKeyVal,
"Blank key should return Integer.MAX_VALUE per current contract");
}
@Test
@DisplayName("getRemainingAttempts(): returns MAX_ATTEMPT when no counter exists for key")
void getRemainingAttempts_shouldReturnMaxAttemptWhenNoEntry() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT"); // Reads current policy value
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
int v1 = (Integer) method.invoke(svc, "UserA");
int v2 =
(Integer)
method.invoke(svc, "uSeRa"); // case-insensitive by service (normalization)
assertEquals(maxAttempt, v1, "Unknown user should start with MAX_ATTEMPT remaining");
assertEquals(
maxAttempt,
v2,
"Case-insensitivity should not create separate entries if none exists yet");
}
@Test
@DisplayName("getRemainingAttempts(): decreases with attemptCount in cache")
void getRemainingAttempts_shouldDecreaseAfterAttemptCount() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT");
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
// Prepare a counter with attemptCount = 1
AttemptCounter c1 = new AttemptCounter();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
ac.setInt(c1, 1);
attemptsCache.put("userx".toLowerCase(Locale.ROOT), c1);
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
int actual = (Integer) method.invoke(svc, "USERX");
assertEquals(
maxAttempt - 1,
actual,
"Remaining attempts should reflect current attemptCount (case-insensitive lookup)");
}
@Test
@DisplayName(
"getRemainingAttempts(): can become negative when attemptCount > MAX_ATTEMPT (document"
+ " current behavior)")
void getRemainingAttempts_shouldBecomeNegativeWhenOverLimit_CurrentBehavior() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT");
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
// Create counter with attemptCount = MAX_ATTEMPT + 5
AttemptCounter c = new AttemptCounter();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
ac.setInt(c, maxAttempt + 5);
attemptsCache.put("over".toLowerCase(Locale.ROOT), c);
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
int actual = (Integer) method.invoke(svc, "OVER");
int expected = maxAttempt - (maxAttempt + 5); // -5
// Documentation test: current implementation returns a negative number.
// If you later clamp to 0, update this assertion accordingly and add a new test.
assertEquals(expected, actual, "Current behavior returns negative values without clamping");
}
}

View File

@ -12,6 +12,7 @@ Stirling-PDF is built using:
- PDFBox - PDFBox
- LibreOffice - LibreOffice
- qpdf - qpdf
- Calibre (`ebook-convert` CLI) for eBook conversions
- HTML, CSS, JavaScript - HTML, CSS, JavaScript
- Docker - Docker
- PDF.js - PDF.js
@ -54,7 +55,12 @@ Stirling-PDF is built using:
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment: Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE. Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable 5. Install Calibre CLI (optional but required for eBook conversions)
Ensure the `ebook-convert` binary from Calibre is available on your PATH when working on the
eBook to PDF feature. The Calibre tool group is automatically disabled when the binary is
missing, so having it installed locally allows you to exercise the full workflow.
6. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step. For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
## 4. Project Structure ## 4. Project Structure

View File

@ -158,7 +158,7 @@ ui:
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled. languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints: endpoints:
toRemove: [crop, merge-pdfs, multi-page-layout, overlay-pdfs, pdf-to-single-page, rearrange-pages, remove-image-pdf, remove-pages, rotate-pdf, scale-pages, split-by-size-or-count, split-pages, split-pdf-by-chapters, split-pdf-by-sections, add-password, add-watermark, auto-redact, cert-sign, get-info-on-pdf, redact, remove-cert-sign, remove-password, sanitize-pdf, validate-signature, file-to-pdf, html-to-pdf, img-to-pdf, markdown-to-pdf, pdf-to-csv, pdf-to-html, pdf-to-img, pdf-to-markdown, pdf-to-pdfa, pdf-to-presentation, pdf-to-text, pdf-to-word, pdf-to-xml, url-to-pdf, add-image, add-page-numbers, add-stamp, auto-rename, auto-split-pdf, compress-pdf, decompress-pdf, extract-image-scans, extract-images, flatten, ocr-pdf, remove-blanks, repair, replace-invert-pdf, show-javascript, update-metadata, filter-contains-image, filter-contains-text, filter-file-size, filter-page-count, filter-page-rotation, filter-page-size, add-attachments] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages']) toRemove: [ebook-to-pdf, crop, merge-pdfs, multi-page-layout, overlay-pdfs, pdf-to-single-page, rearrange-pages, remove-image-pdf, remove-pages, rotate-pdf, scale-pages, split-by-size-or-count, split-pages, split-pdf-by-chapters, split-pdf-by-sections, add-password, add-watermark, auto-redact, cert-sign, get-info-on-pdf, redact, remove-cert-sign, remove-password, sanitize-pdf, validate-signature, file-to-pdf, html-to-pdf, img-to-pdf, markdown-to-pdf, pdf-to-csv, pdf-to-html, pdf-to-img, pdf-to-markdown, pdf-to-pdfa, pdf-to-presentation, pdf-to-text, pdf-to-word, pdf-to-xml, url-to-pdf, add-image, add-page-numbers, add-stamp, auto-rename, auto-split-pdf, compress-pdf, decompress-pdf, extract-image-scans, extract-images, flatten, ocr-pdf, remove-blanks, repair, replace-invert-pdf, show-javascript, update-metadata, filter-contains-image, filter-contains-text, filter-file-size, filter-page-count, filter-page-rotation, filter-page-size, add-attachments] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice']) groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
metrics: metrics:

View File

@ -44,6 +44,7 @@
/api/v1/convert/markdown/pdf /api/v1/convert/markdown/pdf
/api/v1/convert/img/pdf /api/v1/convert/img/pdf
/api/v1/convert/html/pdf /api/v1/convert/html/pdf
/api/v1/convert/ebook/pdf
/api/v1/convert/file/pdf /api/v1/convert/file/pdf
/api/v1/general/split-pdf-by-sections /api/v1/general/split-pdf-by-sections
/api/v1/general/split-pdf-by-chapters /api/v1/general/split-pdf-by-chapters

View File

@ -14,6 +14,7 @@
/compare /compare
/compress-pdf /compress-pdf
/crop /crop
/ebook-to-pdf
/extract-image-scans /extract-image-scans
/extract-images /extract-images
/extract-page /extract-page
@ -62,4 +63,4 @@
/stamp /stamp
/validate-signature /validate-signature
/view-pdf /view-pdf
/swagger-ui/index.html /swagger-ui/index.html