diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 68b4ad196..2d241bf89 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -39,7 +39,7 @@ jobs: enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -127,7 +127,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -361,7 +361,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 47f1e8ed9..5efc9e857 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 77668d69a..59338bdff 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index e30f99f07..61fefb6fe 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f45316472..1a4ba013a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -143,7 +143,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -176,7 +176,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -225,7 +225,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -266,7 +266,7 @@ jobs: test-build-docker-images: 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 strategy: fail-fast: false @@ -274,7 +274,7 @@ jobs: docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"] steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -299,7 +299,7 @@ jobs: STIRLING_PDF_DESKTOP_UI: false - 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 id: buildx diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml index fd25ebaf9..b68bff54c 100644 --- a/.github/workflows/check_properties.yml +++ b/.github/workflows/check_properties.yml @@ -32,7 +32,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 49aa24fd9..88c0a40d5 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 613a8219b..15127b302 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -31,7 +31,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index d480249f2..e59757822 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 02087c613..201fae4f7 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -21,7 +21,7 @@ jobs: versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }} steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -60,7 +60,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -110,7 +110,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -148,7 +148,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -238,7 +238,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -301,7 +301,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index acd489f5b..8ac469e0f 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -21,7 +21,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 6f66b051f..1be914500 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -30,7 +30,7 @@ jobs: id-token: write steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -80,7 +80,7 @@ jobs: password: ${{ github.token }} - 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 id: repoowner @@ -88,7 +88,7 @@ jobs: - name: Generate tags id: meta - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 if: github.ref != 'refs/heads/main' with: images: | @@ -134,7 +134,7 @@ jobs: - name: Generate tags ultra-lite id: meta2 - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 if: github.ref != 'refs/heads/main' with: images: | @@ -165,7 +165,7 @@ jobs: - name: Generate tags fat id: meta3 - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 with: images: | ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 0577bb96e..24cce9d08 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -23,7 +23,7 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -83,7 +83,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -161,7 +161,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index b764dd675..a7999e14f 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c3c0b110a..e6bf2b78b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 16f0a3088..64df21429 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/sync_files.yml b/.github/workflows/sync_files.yml index 1233ac701..827b38f86 100644 --- a/.github/workflows/sync_files.yml +++ b/.github/workflows/sync_files.yml @@ -36,7 +36,7 @@ jobs: PIP_DISABLE_PIP_VERSION_CHECK: "1" steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index cd2cedb25..fb901e5e8 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -139,7 +139,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit @@ -175,7 +175,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit diff --git a/Dockerfile b/Dockerfile index d36ea60a9..bcb62ed58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,11 +38,14 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ TEMP=/tmp/stirling-pdf \ TMP=/tmp/stirling-pdf - # JDK for app -RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ - echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ - echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ +RUN apk add --no-cache bash \ + && ln -sf /bin/bash /bin/sh \ + && printf '%s\n' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/main' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/community' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/testing' \ + > /etc/apk/repositories && \ apk upgrade --no-cache -a && \ apk add --no-cache \ ca-certificates \ @@ -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) tesseract-ocr-data-eng \ tesseract-ocr-data-chi_sim \ - tesseract-ocr-data-deu \ - tesseract-ocr-data-fra \ - tesseract-ocr-data-por \ + tesseract-ocr-data-deu \ + tesseract-ocr-data-fra \ + tesseract-ocr-data-por \ unpaper \ - # CV + # CV / Python py3-opencv \ python3 \ ocrmypdf \ py3-pip \ - py3-pillow@testing \ - py3-pdf2image@testing \ + py3-pillow \ + py3-pdf2image \ + # Calibre + calibre \ # URW Base 35 fonts for better PDF rendering font-urw-base35 && \ + # Calibre fixes + apk fix --no-cache calibre && \ 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 unoserver weasyprint && \ @@ -93,7 +100,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a # User permissions 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 stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar && \ + ln -sf /bin/busybox /bin/sh EXPOSE 8080/tcp diff --git a/Dockerfile.dev b/Dockerfile.dev index 517e94b95..6098acd41 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -25,6 +25,12 @@ RUN apt-get update && apt-get install -y \ python3-venv \ # ss -tln 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/* # 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 RUN python3 -m venv --system-site-packages /opt/venv \ && . /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 ENV PATH="/opt/venv/bin:$PATH" diff --git a/Dockerfile.fat b/Dockerfile.fat index b6afec888..5609ffd20 100644 --- a/Dockerfile.fat +++ b/Dockerfile.fat @@ -17,9 +17,9 @@ WORKDIR /app COPY . . # Build the application with DISABLE_ADDITIONAL_FEATURES=false -RUN DISABLE_ADDITIONAL_FEATURES=false \ - STIRLING_PDF_DESKTOP_UI=false \ - ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube +ENV DISABLE_ADDITIONAL_FEATURES=false \ + STIRLING_PDF_DESKTOP_UI=false +RUN ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube # Main stage FROM alpine:3.22.2@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 @@ -52,11 +52,14 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ TEMP=/tmp/stirling-pdf \ TMP=/tmp/stirling-pdf - # JDK for app -RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ - echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ - echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ +RUN apk add --no-cache bash \ + && ln -sf /bin/bash /bin/sh \ + && printf '%s\n' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/main' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/community' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/testing' \ + > /etc/apk/repositories && \ apk upgrade --no-cache -a && \ apk add --no-cache \ ca-certificates \ @@ -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) tesseract-ocr-data-eng \ tesseract-ocr-data-chi_sim \ - tesseract-ocr-data-deu \ - tesseract-ocr-data-fra \ - tesseract-ocr-data-por \ + tesseract-ocr-data-deu \ + tesseract-ocr-data-fra \ + tesseract-ocr-data-por \ unpaper \ 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 \ python3 \ ocrmypdf \ py3-pip \ - py3-pillow@testing \ - py3-pdf2image@testing && \ + py3-pillow \ + py3-pdf2image \ + # Calibre (musl-native) + QtWebEngine Runtime + calibre && \ + # Calibre fixes + apk fix --no-cache calibre && \ 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 unoserver weasyprint && \ @@ -106,7 +113,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a # User permissions 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 stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar && \ + ln -sf /bin/busybox /bin/sh EXPOSE 8080/tcp # Set user and run command diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite index 04ba3de15..a49362d60 100644 --- a/Dockerfile.ultra-lite +++ b/Dockerfile.ultra-lite @@ -24,9 +24,13 @@ COPY scripts/installFonts.sh /scripts/installFonts.sh COPY app/core/build/libs/*.jar app.jar # Set up necessary directories and permissions -RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ - echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ - echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ +RUN apk add --no-cache bash \ + && ln -sf /bin/bash /bin/sh \ + && printf '%s\n' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/main' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/community' \ + 'https://dl-cdn.alpinelinux.org/alpine/edge/testing' \ + > /etc/apk/repositories && \ apk upgrade --no-cache -a && \ apk add --no-cache \ ca-certificates \ @@ -42,7 +46,8 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et chmod +x /scripts/*.sh && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ 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 ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI diff --git a/README.md b/README.md index ef37d70fe..e901dd273 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir - **CBZ to PDF**: Convert comic book archives - **CBR to PDF**: Convert comic book rar archives - **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 #### Convert from PDF diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 4c638c7cc..21b0668c3 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -70,7 +70,7 @@ public class AppConfig { @Bean(name = "loginEnabled") public boolean loginEnabled() { - return applicationProperties.getSecurity().getEnableLogin(); + return applicationProperties.getSecurity().isEnableLogin(); } @Bean(name = "appName") @@ -120,9 +120,7 @@ public class AppConfig { @Bean(name = "enableAlphaFunctionality") public boolean enableAlphaFunctionality() { - return applicationProperties.getSystem().getEnableAlphaFunctionality() != null - ? applicationProperties.getSystem().getEnableAlphaFunctionality() - : false; + return applicationProperties.getSystem().isEnableAlphaFunctionality(); } @Bean(name = "rateLimit") diff --git a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java index 53fa97c25..f8bc38a6b 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java @@ -21,6 +21,7 @@ public class RuntimePathConfig { private final String basePath; private final String weasyPrintPath; private final String unoConvertPath; + private final String calibrePath; // Pipeline paths private final String pipelineWatchedFoldersPath; @@ -57,6 +58,7 @@ public class RuntimePathConfig { // Initialize Operation paths String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint"; String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert"; + String defaultCalibrePath = isDocker ? "/usr/bin/ebook-convert" : "ebook-convert"; Operations operations = properties.getSystem().getCustomPaths().getOperations(); this.weasyPrintPath = @@ -67,6 +69,9 @@ public class RuntimePathConfig { resolvePath( defaultUnoConvertPath, operations != null ? operations.getUnoconvert() : null); + this.calibrePath = + resolvePath( + defaultCalibrePath, operations != null ? operations.getCalibre() : null); } private String resolvePath(String defaultPath, String customPath) { diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 91d39a1ff..e8606b1f9 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -112,8 +112,8 @@ public class ApplicationProperties { @Data public static class Security { - private Boolean enableLogin; - private Boolean csrfDisabled; + private boolean enableLogin; + private boolean csrfDisabled; private InitialLogin initialLogin = new InitialLogin(); private OAUTH2 oauth2 = new OAUTH2(); private SAML2 saml2 = new SAML2(); @@ -295,8 +295,8 @@ public class ApplicationProperties { throw new UnsupportedProviderException( "Logout from the provider " + registrationId - + " is not supported. " - + "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues"); + + " is not supported. Report it at" + + " https://github.com/Stirling-Tools/Stirling-PDF/issues"); }; } } @@ -314,19 +314,19 @@ public class ApplicationProperties { @Data public static class System { private String defaultLocale; - private Boolean googlevisibility; + private boolean googlevisibility; private boolean showUpdate; - private Boolean showUpdateOnlyAdmin; + private boolean showUpdateOnlyAdmin; private boolean customHTMLFiles; private String tessdataDir; - private Boolean enableAlphaFunctionality; + private boolean enableAlphaFunctionality; private Boolean enableAnalytics; private Boolean enablePosthog; private Boolean enableScarf; private Datasource datasource; - private Boolean disableSanitize; + private boolean disableSanitize; private int maxDPI; - private Boolean enableUrlToPDF; + private boolean enableUrlToPDF; private Html html = new Html(); private CustomPaths customPaths = new CustomPaths(); private String fileUploadLimit; @@ -371,6 +371,7 @@ public class ApplicationProperties { public static class Operations { private String weasyprint; private String unoconvert; + private String calibre; } } @@ -453,10 +454,10 @@ public class ApplicationProperties { @Override public String toString() { return """ - Driver { - driverName='%s' - } - """ + Driver { + driverName='%s' + } + """ .formatted(driverName); } } @@ -491,7 +492,7 @@ public class ApplicationProperties { @Data public static class Metrics { - private Boolean enabled; + private boolean enabled; } @Data diff --git a/app/common/src/main/java/stirling/software/common/service/PostHogService.java b/app/common/src/main/java/stirling/software/common/service/PostHogService.java index e788af9fb..6c42e093f 100644 --- a/app/common/src/main/java/stirling/software/common/service/PostHogService.java +++ b/app/common/src/main/java/stirling/software/common/service/PostHogService.java @@ -253,11 +253,11 @@ public class PostHogService { addIfNotEmpty( properties, "security_enableLogin", - applicationProperties.getSecurity().getEnableLogin()); + applicationProperties.getSecurity().isEnableLogin()); addIfNotEmpty( properties, "security_csrfDisabled", - applicationProperties.getSecurity().getCsrfDisabled()); + applicationProperties.getSecurity().isCsrfDisabled()); addIfNotEmpty( properties, "security_loginAttemptCount", @@ -302,13 +302,13 @@ public class PostHogService { addIfNotEmpty( properties, "system_googlevisibility", - applicationProperties.getSystem().getGooglevisibility()); + applicationProperties.getSystem().isGooglevisibility()); addIfNotEmpty( properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate()); addIfNotEmpty( properties, "system_showUpdateOnlyAdmin", - applicationProperties.getSystem().getShowUpdateOnlyAdmin()); + applicationProperties.getSystem().isShowUpdateOnlyAdmin()); addIfNotEmpty( properties, "system_customHTMLFiles", @@ -320,7 +320,7 @@ public class PostHogService { addIfNotEmpty( properties, "system_enableAlphaFunctionality", - applicationProperties.getSystem().getEnableAlphaFunctionality()); + applicationProperties.getSystem().isEnableAlphaFunctionality()); addIfNotEmpty( properties, "system_enableAnalytics", @@ -337,7 +337,7 @@ public class PostHogService { // Capture Metrics properties addIfNotEmpty( - properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled()); + properties, "metrics_enabled", applicationProperties.getMetrics().isEnabled()); // Capture EnterpriseEdition properties addIfNotEmpty( diff --git a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java index c5fb07645..05bb6e546 100644 --- a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java +++ b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java @@ -62,8 +62,7 @@ public class CustomHtmlSanitizer { .and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory()); public String sanitize(String html) { - boolean disableSanitize = - Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize()); + boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize(); return disableSanitize ? html : POLICY.sanitize(html); } } diff --git a/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java b/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java index 3ba0d7fc8..2989638e3 100644 --- a/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java +++ b/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java @@ -13,6 +13,8 @@ import org.junit.jupiter.params.provider.CsvSource; public class FileInfoTest { + private static final LocalDateTime FIXED_NOW = LocalDateTime.of(2025, 11, 1, 12, 0, 0); + @ParameterizedTest(name = "{index}: fileSize={0}") @CsvSource({ "0, '0 Bytes'", @@ -28,9 +30,9 @@ public class FileInfoTest { new FileInfo( "example.txt", "/path/to/example.txt", - LocalDateTime.now(), + FIXED_NOW, fileSize, - LocalDateTime.now().minusDays(1)); + FIXED_NOW.minusDays(1)); assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize()); } @@ -45,9 +47,9 @@ public class FileInfoTest { new FileInfo( "example.txt", "/path/to/example.txt", - LocalDateTime.now(), + FIXED_NOW, 123, - LocalDateTime.now().minusDays(1)); + FIXED_NOW.minusDays(1)); Path path = fi.getFilePathAsPath(); @@ -103,7 +105,7 @@ public class FileInfoTest { "/path/to/example.txt", null, // modificationDate null 1, - LocalDateTime.now()); + FIXED_NOW); assertThrows( NullPointerException.class, @@ -120,7 +122,7 @@ public class FileInfoTest { new FileInfo( "example.txt", "/path/to/example.txt", - LocalDateTime.now(), + FIXED_NOW, 1, null); // creationDate null @@ -142,9 +144,9 @@ public class FileInfoTest { new FileInfo( "example.txt", "/path/to/example.txt", - LocalDateTime.now(), + FIXED_NOW, 1536, // 1.5 KB - LocalDateTime.now().minusDays(1)); + FIXED_NOW.minusDays(1)); assertEquals("1.50 KB", fi.getFormattedFileSize()); } @@ -158,9 +160,9 @@ public class FileInfoTest { new FileInfo( "example.txt", "/path/to/example.txt", - LocalDateTime.now(), + FIXED_NOW, twoTB, - LocalDateTime.now().minusDays(1)); + FIXED_NOW.minusDays(1)); // 2 TB equals 2048.00 GB with current implementation assertEquals( diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java index d5fb1a597..6e3883403 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -6,7 +6,6 @@ import static org.mockito.Mockito.*; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -83,7 +82,7 @@ class FileStorageTest { void testRetrieveFile() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = UUID.randomUUID().toString(); + String fileId = "test-file-1"; Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); @@ -103,7 +102,7 @@ class FileStorageTest { void testRetrieveBytes() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = UUID.randomUUID().toString(); + String fileId = "test-file-2"; Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); @@ -136,7 +135,7 @@ class FileStorageTest { void testDeleteFile() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = UUID.randomUUID().toString(); + String fileId = "test-file-3"; Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); @@ -164,7 +163,7 @@ class FileStorageTest { void testFileExists() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = UUID.randomUUID().toString(); + String fileId = "test-file-4"; Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); diff --git a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java index 3b168552b..3257c4573 100644 --- a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java @@ -166,13 +166,13 @@ class JobExecutorServiceTest { // Given Supplier work = () -> { - try { - Thread.sleep(100); // Simulate long-running job - return "test-result"; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); + // Simulate long-running job without actual sleep + // Use a loop to consume time instead of Thread.sleep + long startTime = System.nanoTime(); + while (System.nanoTime() - startTime < 100_000_000) { // 100ms in nanoseconds + // Busy wait to simulate work without Thread.sleep } + return "test-result"; }; // Use reflection to access the private executeWithTimeout method diff --git a/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java index 2219e0edb..27d3a9f5e 100644 --- a/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java +++ b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java @@ -126,12 +126,15 @@ class ResourceMonitorTest { @Test void resourceMetricsShouldDetectStaleState() { + // Capture test time at the beginning for deterministic calculations + final Instant testTime = Instant.now(); + // Given - Instant now = Instant.now(); - Instant pastInstant = now.minusMillis(6000); + Instant pastInstant = + testTime.minusMillis(6000); // 6 seconds ago (relative to test start time) 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 assertTrue( diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java index e0597f725..7a61270d1 100644 --- a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -5,7 +5,6 @@ import static org.mockito.Mockito.*; import java.time.LocalDateTime; import java.util.Map; -import java.util.UUID; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -42,7 +41,7 @@ class TaskManagerTest { @Test void testCreateTask() { // Act - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-1"; taskManager.createTask(jobId); // Assert @@ -56,7 +55,7 @@ class TaskManagerTest { @Test void testSetResult() { // Arrange - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-2"; taskManager.createTask(jobId); Object resultObject = "Test result"; @@ -74,7 +73,7 @@ class TaskManagerTest { @Test void testSetFileResult() throws Exception { // Arrange - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-3"; taskManager.createTask(jobId); String fileId = "file-id"; String originalFileName = "test.pdf"; @@ -108,7 +107,7 @@ class TaskManagerTest { @Test void testSetError() { // Arrange - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-4"; taskManager.createTask(jobId); String errorMessage = "Test error"; @@ -126,7 +125,7 @@ class TaskManagerTest { @Test void testSetComplete_WithExistingResult() { // Arrange - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-5"; taskManager.createTask(jobId); Object resultObject = "Test result"; taskManager.setResult(jobId, resultObject); @@ -144,7 +143,7 @@ class TaskManagerTest { @Test void testSetComplete_WithoutExistingResult() { // Arrange - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-6"; taskManager.createTask(jobId); // Act @@ -160,7 +159,7 @@ class TaskManagerTest { @Test void testIsComplete() { // Arrange - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-7"; taskManager.createTask(jobId); // Assert - not complete initially @@ -216,6 +215,8 @@ class TaskManagerTest { @Test void testCleanupOldJobs() { + // Capture test time at the beginning for deterministic calculations + final LocalDateTime testTime = LocalDateTime.now(); // Arrange // 1. Create a recent completed job String recentJobId = "recent-job"; @@ -227,8 +228,9 @@ class TaskManagerTest { taskManager.createTask(oldJobId); JobResult oldJob = taskManager.getJobResult(oldJobId); - // Manually set the completion time to be older than the expiry - LocalDateTime oldTime = LocalDateTime.now().minusHours(1); + // Manually set the completion time to be older than the expiry (relative to test start + // time) + LocalDateTime oldTime = testTime.minusHours(1); ReflectionTestUtils.setField(oldJob, "completedAt", oldTime); ReflectionTestUtils.setField(oldJob, "complete", true); @@ -280,7 +282,7 @@ class TaskManagerTest { @Test void testAddNote() { // Arrange - String jobId = UUID.randomUUID().toString(); + String jobId = "test-job-8"; taskManager.createTask(jobId); String note = "Test note"; diff --git a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java index fa448e9c7..42cc78bfa 100644 --- a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java @@ -131,6 +131,9 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic 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 mockedFiles .when(() -> Files.list(eq(systemTempDir))) @@ -175,18 +178,17 @@ public class TempFileCleanupServiceTest { // maxAgeMillis if (fileName.contains("old")) { return FileTime.fromMillis( - System.currentTimeMillis() - 5000000); + testTime - 5000000); // ~1.4 hours ago } // For empty.tmp file, return a timestamp older than 5 minutes (for // empty file test) - else if (fileName.equals("empty.tmp")) { + else if ("empty.tmp".equals(fileName)) { return FileTime.fromMillis( - System.currentTimeMillis() - 6 * 60 * 1000); + testTime - 6 * 60 * 1000); // 6 minutes ago } // For all other files, return a recent timestamp else { - return FileTime.fromMillis( - System.currentTimeMillis() - 60000); // 1 minute ago + return FileTime.fromMillis(testTime - 60000); // 1 minute ago } }); @@ -199,7 +201,7 @@ public class TempFileCleanupServiceTest { String fileName = path.getFileName().toString(); // Return 0 bytes for the empty file - if (fileName.equals("empty.tmp")) { + if ("empty.tmp".equals(fileName)) { return 0L; } // Return normal size for all other files @@ -274,6 +276,9 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { + // Capture test time at the beginning for deterministic calculations + final long testTime = System.currentTimeMillis(); + // Mock Files.list for systemTempDir mockedFiles .when(() -> Files.list(eq(systemTempDir))) @@ -288,9 +293,7 @@ public class TempFileCleanupServiceTest { // Configure Files.getLastModifiedTime to return recent timestamps mockedFiles .when(() -> Files.getLastModifiedTime(any(Path.class))) - .thenReturn( - FileTime.fromMillis( - System.currentTimeMillis() - 60000)); // 1 minute ago + .thenReturn(FileTime.fromMillis(testTime - 60000)); // 1 minute ago // Configure Files.size to return normal size mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB @@ -335,6 +338,9 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { + // Capture test time at the beginning for deterministic calculations + final long testTime = System.currentTimeMillis(); + // Mock Files.list for systemTempDir mockedFiles .when(() -> Files.list(eq(systemTempDir))) @@ -354,14 +360,14 @@ public class TempFileCleanupServiceTest { Path path = invocation.getArgument(0); String fileName = path.getFileName().toString(); - if (fileName.equals("empty.tmp")) { + if ("empty.tmp".equals(fileName)) { // More than 5 minutes old return FileTime.fromMillis( - System.currentTimeMillis() - 6 * 60 * 1000); + testTime - 6 * 60 * 1000); // 6 minutes ago } else { // Less than 5 minutes old 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 try (MockedStatic mockedFiles = mockStatic(Files.class)) { + // Capture test time at the beginning for deterministic calculations + final long testTime = System.currentTimeMillis(); + // 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 mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true); @@ -430,6 +447,9 @@ public class TempFileCleanupServiceTest { // Configure Files.exists to return true for all paths 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 mockedFiles .when(() -> Files.getLastModifiedTime(any(Path.class))) @@ -439,19 +459,14 @@ public class TempFileCleanupServiceTest { String fileName = path.getFileName().toString(); if (fileName.contains("old")) { - // Old file - return FileTime.fromMillis( - System.currentTimeMillis() - 5000000); + // Old file - very old timestamp (older than 1 hour) + return FileTime.fromMillis(testTime - 7200000); // 2 hours ago } else { - // Recent file - return FileTime.fromMillis(System.currentTimeMillis() - 60000); + // Recent file - very recent timestamp (less than 1 hour) + 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 .when(() -> Files.deleteIfExists(any(Path.class))) .thenAnswer( @@ -461,13 +476,9 @@ public class TempFileCleanupServiceTest { return true; }); - // Act + // Act - pass maxAgeMillis = 3600000 (1 hour) invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000); - // Debug - print what was deleted - System.out.println("Deleted files: " + deletedFiles); - System.out.println("Looking for: " + tempFile3); - // Assert assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved"); assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved"); diff --git a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java index baef37251..aa2c64a84 100644 --- a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java +++ b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java @@ -36,7 +36,7 @@ class CustomHtmlSanitizerTest { // strict-stubbing failures when individual tests bypass certain branches. lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true); lenient().when(applicationProperties.getSystem()).thenReturn(systemProperties); - lenient().when(systemProperties.getDisableSanitize()).thenReturn(false); + lenient().when(systemProperties.isDisableSanitize()).thenReturn(false); customHtmlSanitizer = new CustomHtmlSanitizer(ssrfProtectionService, applicationProperties); } @@ -374,7 +374,7 @@ class CustomHtmlSanitizerTest { "

ok

"; // 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) lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false); diff --git a/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java b/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java index e39adb78e..7d0d9b4f0 100644 --- a/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java +++ b/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java @@ -48,7 +48,7 @@ class EmlToPdfTest { when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString())) .thenReturn(true); when(mockApplicationProperties.getSystem()).thenReturn(mockSystem); - when(mockSystem.getDisableSanitize()).thenReturn(false); + when(mockSystem.isDisableSanitize()).thenReturn(false); customHtmlSanitizer = new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties); diff --git a/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java b/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java index 514e9861d..cd137723f 100644 --- a/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java @@ -45,12 +45,15 @@ class FileMonitorTest { @Test void testIsFileReadyForProcessing_OldFile() throws IOException { + // Capture test time at the beginning for deterministic calculations + final Instant testTime = Instant.now(); + // Create a test file Path testFile = tempDir.resolve("test-file.txt"); Files.write(testFile, "test content".getBytes()); - // Set modified time to 10 seconds ago - Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); + // Set modified time to 10 seconds ago (relative to test start time) + Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000))); // File should be ready for processing as it was modified more than 5 seconds ago assertTrue(fileMonitor.isFileReadyForProcessing(testFile)); @@ -58,12 +61,15 @@ class FileMonitorTest { @Test void testIsFileReadyForProcessing_RecentFile() throws IOException { + // Capture test time at the beginning for deterministic calculations + final Instant testTime = Instant.now(); + // Create a test file Path testFile = tempDir.resolve("recent-file.txt"); Files.write(testFile, "test content".getBytes()); - // Set modified time to just now - Files.setLastModifiedTime(testFile, FileTime.from(Instant.now())); + // Set modified time to just now (relative to test start time) + Files.setLastModifiedTime(testFile, FileTime.from(testTime)); // File should not be ready for processing as it was just modified assertFalse(fileMonitor.isFileReadyForProcessing(testFile)); @@ -80,12 +86,16 @@ class FileMonitorTest { @Test void testIsFileReadyForProcessing_LockedFile() throws IOException { + // Capture test time at the beginning for deterministic calculations + final Instant testTime = Instant.now(); + // Create a test file Path testFile = tempDir.resolve("locked-file.txt"); Files.write(testFile, "test content".getBytes()); - // Set modified time to 10 seconds ago to make sure it passes the time check - Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); + // Set modified time to 10 seconds ago (relative to test start time) to make sure it passes + // the time check + Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000))); // Verify the file is considered ready when it meets the time criteria assertTrue( @@ -104,12 +114,12 @@ class FileMonitorTest { // Create a PDF file Path pdfFile = tempDir.resolve("test.pdf"); 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 Path txtFile = tempDir.resolve("test.txt"); 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 assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile)); @@ -125,12 +135,15 @@ class FileMonitorTest { @Test void testIsFileReadyForProcessing_FileInUse() throws IOException { + // Capture test time at the beginning for deterministic calculations + final Instant testTime = Instant.now(); + // Create a test file Path testFile = tempDir.resolve("in-use-file.txt"); Files.write(testFile, "initial content".getBytes()); - // Set modified time to 10 seconds ago - Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); + // Set modified time to 10 seconds ago (relative to test start time) + Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000))); // First check that the file is ready when meeting time criteria assertTrue( @@ -139,7 +152,7 @@ class FileMonitorTest { // After modifying the file to simulate closing, it should still be ready Files.write(testFile, "updated content".getBytes()); - Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); + Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000))); assertTrue( fileMonitor.isFileReadyForProcessing(testFile), @@ -148,12 +161,15 @@ class FileMonitorTest { @Test void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException { + // Capture test time at the beginning for deterministic calculations + final Instant testTime = Instant.now(); + // Create a test file Path testFile = tempDir.resolve("absolute-path-file.txt"); Files.write(testFile, "test content".getBytes()); - // Set modified time to 10 seconds ago - Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000))); + // Set modified time to 10 seconds ago (relative to test start time) + Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000))); // 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 @@ -167,7 +183,7 @@ class FileMonitorTest { Files.createDirectory(testDir); // 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 boolean isReady = fileMonitor.isFileReadyForProcessing(testDir); diff --git a/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java b/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java index 9fd09ab5e..5a98bdbb7 100644 --- a/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java @@ -29,7 +29,7 @@ public class FileToPdfTest { when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString())) .thenReturn(true); when(mockApplicationProperties.getSystem()).thenReturn(mockSystem); - when(mockSystem.getDisableSanitize()).thenReturn(false); + when(mockSystem.isDisableSanitize()).thenReturn(false); customHtmlSanitizer = new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties); diff --git a/app/core/build.gradle b/app/core/build.gradle index d12eed6ae..a95fc0051 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -55,7 +55,7 @@ dependencies { implementation project(':common') implementation 'org.springframework.boot:spring-boot-starter-jetty' 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:bcpkix-jdk18on:$bouncycastleVersion" implementation 'io.micrometer:micrometer-core:1.15.5' diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 65bcd420d..74d71825e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -257,6 +257,7 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "html-to-pdf"); addEndpointToGroup("Convert", "url-to-pdf"); addEndpointToGroup("Convert", "markdown-to-pdf"); + addEndpointToGroup("Convert", "ebook-to-pdf"); addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "eml-to-pdf"); @@ -446,6 +447,9 @@ public class EndpointConfiguration { addEndpointToGroup("Weasyprint", "markdown-to-pdf"); addEndpointToGroup("Weasyprint", "eml-to-pdf"); + // Calibre dependent endpoints + addEndpointToGroup("Calibre", "ebook-to-pdf"); + // Pdftohtml dependent endpoints addEndpointToGroup("Pdftohtml", "pdf-to-html"); addEndpointToGroup("Pdftohtml", "pdf-to-markdown"); @@ -475,7 +479,7 @@ public class EndpointConfiguration { disableGroup("enterprise"); } - if (!applicationProperties.getSystem().getEnableUrlToPDF()) { + if (!applicationProperties.getSystem().isEnableUrlToPDF()) { disableEndpoint("url-to-pdf"); } } @@ -498,6 +502,7 @@ public class EndpointConfiguration { || "Javascript".equals(group) || "Weasyprint".equals(group) || "Pdftohtml".equals(group) + || "Calibre".equals(group) || "rar".equals(group) || "FFmpeg".equals(group); } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 9f8d7d17c..a703d1da3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -40,6 +40,7 @@ public class ExternalAppDepConfig { private final String weasyprintPath; private final String unoconvPath; + private final String calibrePath; /** * Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid @@ -56,6 +57,7 @@ public class ExternalAppDepConfig { this.endpointConfiguration = endpointConfiguration; this.weasyprintPath = runtimePathConfig.getWeasyPrintPath(); this.unoconvPath = runtimePathConfig.getUnoConvertPath(); + this.calibrePath = runtimePathConfig.getCalibrePath(); Map> tmp = new HashMap<>(); tmp.put("gs", List.of("Ghostscript")); @@ -67,6 +69,7 @@ public class ExternalAppDepConfig { tmp.put("qpdf", List.of("qpdf")); tmp.put("tesseract", List.of("tesseract")); tmp.put("rar", List.of("rar")); + tmp.put(calibrePath, List.of("Calibre")); tmp.put("ffmpeg", List.of("FFmpeg")); this.commandToGroupMapping = Collections.unmodifiableMap(tmp); } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index 2d261c660..f8dbeea48 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -61,11 +61,9 @@ public class InitialSetup { public void initEnableCSRFSecurity() throws IOException { if (GeneralUtils.isVersionHigher( "0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) { - Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled(); + boolean csrf = applicationProperties.getSecurity().isCsrfDisabled(); if (!csrf) { - GeneralUtils.saveKeyToSettings("security.csrfDisabled", false); GeneralUtils.saveKeyToSettings("system.enableAnalytics", true); - applicationProperties.getSecurity().setCsrfDisabled(false); } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java index 78d2a3d2b..a00d40e7e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java @@ -50,7 +50,7 @@ public class OpenApiConfig { .url("https://www.stirlingpdf.com") .email("contact@stirlingpdf.com")) .description(DEFAULT_DESCRIPTION); - if (!applicationProperties.getSecurity().getEnableLogin()) { + if (!applicationProperties.getSecurity().isEnableLogin()) { return new OpenAPI().components(new Components()).info(info); } else { SecurityScheme apiKeyScheme = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 569c58f5e..b1132ec94 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -72,19 +72,29 @@ public class MergeController { // fileOrder is newline-delimited original filenames in the desired order. private static MultipartFile[] reorderFilesByProvidedOrder( 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 remaining = new ArrayList<>(Arrays.asList(files)); List ordered = new ArrayList<>(files.length); 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); if (idx >= 0) { 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); 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 (fileOrder != null && !fileOrder.isBlank()) { + log.info("Reordering files based on fileOrder parameter"); files = reorderFilesByProvidedOrder(files, fileOrder); } else { + log.info("Sorting files based on sortType: {}", request.getSortType()); Arrays.sort( files, getSortComparator( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFController.java new file mode 100644 index 000000000..c1b59bb41 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFController.java @@ -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 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 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 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 buildCalibreCommand( + Path inputPath, + Path outputPath, + boolean embedAllFonts, + boolean includeTableOfContents, + boolean includePageNumbers) { + List 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 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); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java index c35aa0282..7e471adc4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java @@ -71,7 +71,7 @@ public class ConvertWebsiteToPDF { URI location = null; HttpStatus status = HttpStatus.SEE_OTHER; - if (!applicationProperties.getSystem().getEnableUrlToPDF()) { + if (!applicationProperties.getSystem().isEnableUrlToPDF()) { location = uriComponentsBuilder .queryParam("error", "error.endpointDisabled") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java index db6c62bc4..ef0d840b2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java @@ -47,6 +47,13 @@ public class ConverterWebController { 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") @Hidden public String convertPdfToCbrForm(Model model) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java index 2b36f95af..c031e3baf 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java @@ -84,8 +84,8 @@ public class HomeWebController { @ResponseBody @Hidden public String getRobotsTxt() { - Boolean allowGoogle = applicationProperties.getSystem().getGooglevisibility(); - if (Boolean.TRUE.equals(allowGoogle)) { + boolean allowGoogle = applicationProperties.getSystem().isGooglevisibility(); + if (allowGoogle) { return "User-agent: Googlebot\nAllow: /\n\nUser-agent: *\nAllow: /"; } else { return "User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /"; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index d0a61a815..da352cf36 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -42,9 +42,7 @@ public class MetricsController { @PostConstruct public void init() { - Boolean metricsEnabled = applicationProperties.getMetrics().getEnabled(); - if (metricsEnabled == null) metricsEnabled = true; - this.metricsEnabled = metricsEnabled; + metricsEnabled = applicationProperties.getMetrics().isEnabled(); } @GetMapping("/status") diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertEbookToPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertEbookToPdfRequest.java new file mode 100644 index 000000000..9461bbb15 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertEbookToPdfRequest.java @@ -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; +} diff --git a/app/core/src/main/resources/messages_de_DE.properties b/app/core/src/main/resources/messages_de_DE.properties index c97b32e1c..9625d8935 100644 --- a/app/core/src/main/resources/messages_de_DE.properties +++ b/app/core/src/main/resources/messages_de_DE.properties @@ -616,6 +616,10 @@ home.cbrToPdf.title=CBR zu PDF home.cbrToPdf.desc=CBR-Comicarchive in das PDF-Format konvertieren. 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.desc=PDF-Dateien in CBZ-Comicarchive umwandeln. pdfToCbz.tags=konvertierung,comic,buch,archiv,cbz,pdf @@ -1490,6 +1494,17 @@ cbrToPDF.submit=Zu PDF konvertieren cbrToPDF.selectText=CBR-Datei auswählen 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.title=PDF zu CBR pdfToCBR.header=PDF zu CBR diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 016e2e8bf..6762e021c 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -616,6 +616,10 @@ home.cbrToPdf.title=CBR to PDF home.cbrToPdf.desc=Convert CBR comic book archives to PDF format. 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.desc=Convert PDF files to CBZ comic book archives. pdfToCbz.tags=conversion,comic,book,archive,cbz,pdf @@ -1503,6 +1507,17 @@ cbrToPDF.submit=Convert to PDF cbrToPDF.selectText=Select CBR file 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.title=PDF to CBR pdfToCBR.header=PDF to CBR diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index f5bf4ebcd..734f3f793 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -149,6 +149,7 @@ system: operations: weasyprint: '' # Defaults to /opt/venv/bin/weasyprint 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". tempFileManagement: baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf diff --git a/app/core/src/main/resources/static/js/downloader.js b/app/core/src/main/resources/static/js/downloader.js index 9e074be5e..070fd90af 100644 --- a/app/core/src/main/resources/static/js/downloader.js +++ b/app/core/src/main/resources/static/js/downloader.js @@ -74,6 +74,12 @@ showGameBtn.style.display = 'none'; } + // Log fileOrder for debugging + const fileOrderValue = formData.get('fileOrder'); + if (fileOrderValue) { + console.log('FormData fileOrder:', fileOrderValue); + } + // Remove empty file entries for (let [key, value] of formData.entries()) { if (value instanceof File && !value.name) { diff --git a/app/core/src/main/resources/static/js/merge.js b/app/core/src/main/resources/static/js/merge.js index 01d7d97d9..82a0a0d88 100644 --- a/app/core/src/main/resources/static/js/merge.js +++ b/app/core/src/main/resources/static/js/merge.js @@ -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) { currentSort.descending = true; - sortFiles((a, b) => b.name.localeCompare(a.name)); + await sortFiles((a, b) => b.name.localeCompare(a.name)); } else { currentSort.field = "name"; 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) { currentSort.descending = true; - sortFiles((a, b) => b.lastModified - a.lastModified); + await sortFiles((a, b) => b.lastModified - a.lastModified); } else { currentSort.field = "lastModified"; 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 const sortedFilesArray = Array.from(document.getElementById("fileInput-input").files).sort(comparator); - // Refresh displayed list - displayFiles(sortedFilesArray); + // Refresh displayed list (wait for it to complete since it's async) + await displayFiles(sortedFilesArray); - // Update the files property - const dataTransfer = new DataTransfer(); - sortedFilesArray.forEach((file) => dataTransfer.items.add(file)); - document.getElementById("fileInput-input").files = dataTransfer.files; + // Update the file input and fileOrder based on the current display order + // This ensures consistency between display and file input + updateFiles(); } function updateFiles() { @@ -163,25 +162,36 @@ function updateFiles() { var liElements = document.querySelectorAll("#selectedFiles li"); 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++) { var fileNameFromList = liElements[i].querySelector(".filename").innerText; - var fileFromFiles; + var found = false; for (var j = 0; j < files.length; j++) { var file = files[j]; if (file.name === fileNameFromList) { dataTransfer.items.add(file); + found = true; break; } } + if (!found) { + console.warn("updateFiles: Could not find file:", fileNameFromList); + } } + 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 const order = Array.from(liElements) .map((li) => li.querySelector(".filename").innerText) .join("\n"); 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", ()=>{ diff --git a/app/core/src/main/resources/templates/convert/ebook-to-pdf.html b/app/core/src/main/resources/templates/convert/ebook-to-pdf.html new file mode 100644 index 000000000..9c2614fcf --- /dev/null +++ b/app/core/src/main/resources/templates/convert/ebook-to-pdf.html @@ -0,0 +1,107 @@ + + + + + + + + + +
+
+ +

+
+
+
+
+ menu_book + +
+

+ +
+ Calibre support is disabled. +
+ +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/app/core/src/main/resources/templates/fragments/navElements.html b/app/core/src/main/resources/templates/fragments/navElements.html index bbf09985e..3bb9ea25c 100644 --- a/app/core/src/main/resources/templates/fragments/navElements.html +++ b/app/core/src/main/resources/templates/fragments/navElements.html @@ -53,6 +53,9 @@
+
+
@@ -132,6 +135,9 @@
+
+
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFControllerTest.java new file mode 100644 index 000000000..95f0de648 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFControllerTest.java @@ -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 deletedDir = new AtomicReference<>(); + Mockito.doAnswer( + invocation -> { + Path dir = invocation.getArgument(0); + deletedDir.set(dir); + if (Files.exists(dir)) { + try (Stream 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 pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class); + MockedStatic 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> 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 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 response = controller.convertEbookToPdf(request); + + assertSame(expectedResponse, response); + + List 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 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 deletedDir = new AtomicReference<>(); + Mockito.doAnswer( + invocation -> { + Path dir = invocation.getArgument(0); + deletedDir.set(dir); + if (Files.exists(dir)) { + try (Stream 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 pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 expectedResponse = ResponseEntity.ok(optimizedBytes); + wr.when( + () -> + WebResponseUtils.bytesToWebResponse( + optimizedBytes, "ebook_convertedToPDF.pdf")) + .thenReturn(expectedResponse); + + ResponseEntity 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 paths = Files.walk(workingDir)) { + paths.sorted(Comparator.reverseOrder()) + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + }); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java new file mode 100644 index 000000000..32a93b581 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java @@ -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 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 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 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 acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class); + MockedStatic 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 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 acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class); + MockedStatic 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 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")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/GeneralWebControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/GeneralWebControllerTest.java new file mode 100644 index 000000000..540e1379d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/GeneralWebControllerTest.java @@ -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 model, + HttpServletRequest request, + HttpServletResponse response) { + // no-op + } + }; + } + + @SuppressWarnings("unused") + private static Stream 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 signatures = List.of(new SignatureFile(), new SignatureFile()); + when(signatureService.getAvailableSignatures("alice")).thenReturn(signatures); + + try (MockedStatic gu = mockStatic(GeneralUtils.class); + MockedStatic 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 gu = mockStatic(GeneralUtils.class); + MockedStatic 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 gu = mockStatic(GeneralUtils.class); + MockedStatic 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 gu = mockStatic(GeneralUtils.class); + MockedStatic 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 model = mvcResult.getModelAndView().getModel(); + @SuppressWarnings("unchecked") + List configsRaw = (List) model.get("pipelineConfigs"); + @SuppressWarnings("unchecked") + List> configsNamed = + (List>) model.get("pipelineConfigsWithNames"); + + Assertions.assertEquals(2, configsRaw.size()); + Assertions.assertEquals(2, configsNamed.size()); + + Set names = new HashSet<>(); + for (Map 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 = 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> configsNamed = + (List>) + 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")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/HomeWebControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/HomeWebControllerTest.java new file mode 100644 index 000000000..07a9ef58f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/HomeWebControllerTest.java @@ -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 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 json = "{\"dependencies\":[{}]}"; + + try (MockedConstruction 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 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")); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java index a2b57cb43..8b5b4eaf2 100644 --- a/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java @@ -412,11 +412,11 @@ class TextFinderTest { 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.getText(document); List foundTexts = textFinder.getFoundTexts(); - long endTime = System.currentTimeMillis(); + long endTime = 1001000L; // Fixed end time assertEquals(10, foundTexts.size()); assertTrue( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index b78556bf9..25fd2b6a5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -126,7 +126,7 @@ public class AccountWebController { SAML2 saml2 = securityProps.getSaml2(); if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() + && applicationProperties.getSystem().isEnableAlphaFunctionality() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index aceb3b712..f0794ff6d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -125,7 +125,7 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { + if (securityProperties.isCsrfDisabled() || !loginEnabledValue) { http.csrf(CsrfConfigurer::disable); } @@ -146,7 +146,7 @@ public class SecurityConfiguration { .addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); - if (!securityProperties.getCsrfDisabled()) { + if (!securityProperties.isCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); CsrfTokenRequestAttributeHandler requestHandler = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java index 19e300585..c60c5e2d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java @@ -27,7 +27,7 @@ class AppUpdateAuthService implements ShowAdminInterface { if (!showUpdate) { return showUpdate; } - boolean showUpdateOnlyAdmin = applicationProperties.getSystem().getShowUpdateOnlyAdmin(); + boolean showUpdateOnlyAdmin = applicationProperties.getSystem().isShowUpdateOnlyAdmin(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !authentication.isAuthenticated()) { return !showUpdateOnlyAdmin; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java index b910a4b3f..a749a1da6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java @@ -8,6 +8,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; 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 { // --- Helper functions for reflection access to private fields --- @@ -113,11 +118,14 @@ class AttemptCounterTest { @DisplayName("returns FALSE when time difference is smaller than window") void shouldReturnFalseWhenWithinWindow() { 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(); - // Simulate: last action was (window - 1) ms ago - setPrivateLong(counter, "lastAttemptTime", now - (window - 1)); + // Changed: Avoid flaky 1ms margin. We set lastAttemptTime to 'now' and choose a large + // 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 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 @DisplayName("Getters: return current values") void getters_shouldReturnCurrentValues() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java new file mode 100644 index 000000000..fd6733d6d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java @@ -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. + * + *

Assumptions: - 'MAX_ATTEMPT' is a private int (possibly static final); we read it via + * reflection (static-aware). - 'attemptsCache' is a ConcurrentHashMap. - + * '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(); + 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(); + 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(); + 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(); + 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"); + } +} diff --git a/devGuide/DeveloperGuide.md b/devGuide/DeveloperGuide.md index fb8911eaf..746e09e24 100644 --- a/devGuide/DeveloperGuide.md +++ b/devGuide/DeveloperGuide.md @@ -12,6 +12,7 @@ Stirling-PDF is built using: - PDFBox - LibreOffice - qpdf +- Calibre (`ebook-convert` CLI) for eBook conversions - HTML, CSS, JavaScript - Docker - 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: 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. ## 4. Project Structure diff --git a/testing/allEndpointsRemovedSettings.yml b/testing/allEndpointsRemovedSettings.yml index 014556fc0..58e7fd9f9 100644 --- a/testing/allEndpointsRemovedSettings.yml +++ b/testing/allEndpointsRemovedSettings.yml @@ -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. 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']) metrics: diff --git a/testing/endpoints.txt b/testing/endpoints.txt index 149e3af3a..1df2e53e6 100644 --- a/testing/endpoints.txt +++ b/testing/endpoints.txt @@ -44,6 +44,7 @@ /api/v1/convert/markdown/pdf /api/v1/convert/img/pdf /api/v1/convert/html/pdf +/api/v1/convert/ebook/pdf /api/v1/convert/file/pdf /api/v1/general/split-pdf-by-sections /api/v1/general/split-pdf-by-chapters diff --git a/testing/webpage_urls_full.txt b/testing/webpage_urls_full.txt index 86b908720..6bba382e1 100644 --- a/testing/webpage_urls_full.txt +++ b/testing/webpage_urls_full.txt @@ -14,6 +14,7 @@ /compare /compress-pdf /crop +/ebook-to-pdf /extract-image-scans /extract-images /extract-page @@ -62,4 +63,4 @@ /stamp /validate-signature /view-pdf -/swagger-ui/index.html \ No newline at end of file +/swagger-ui/index.html