mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Merge branch 'main' into issue-397
This commit is contained in:
commit
be39e097fd
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/PR-Demo-cleanup.yml
vendored
2
.github/workflows/PR-Demo-cleanup.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/ai_pr_title_review.yml
vendored
2
.github/workflows/ai_pr_title_review.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/auto-labelerV2.yml
vendored
2
.github/workflows/auto-labelerV2.yml
vendored
@ -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
|
||||
|
||||
|
||||
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/check_properties.yml
vendored
2
.github/workflows/check_properties.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/licenses-update.yml
vendored
2
.github/workflows/licenses-update.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/manage-label.yml
vendored
2
.github/workflows/manage-label.yml
vendored
@ -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
|
||||
|
||||
|
||||
12
.github/workflows/multiOSReleases.yml
vendored
12
.github/workflows/multiOSReleases.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/pre_commit.yml
vendored
2
.github/workflows/pre_commit.yml
vendored
@ -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
|
||||
|
||||
|
||||
10
.github/workflows/push-docker.yml
vendored
10
.github/workflows/push-docker.yml
vendored
@ -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
|
||||
|
||||
6
.github/workflows/releaseArtifacts.yml
vendored
6
.github/workflows/releaseArtifacts.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/scorecards.yml
vendored
2
.github/workflows/scorecards.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/swagger.yml
vendored
2
.github/workflows/swagger.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/sync_files.yml
vendored
2
.github/workflows/sync_files.yml
vendored
@ -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
|
||||
|
||||
|
||||
6
.github/workflows/testdriver.yml
vendored
6
.github/workflows/testdriver.yml
vendored
@ -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
|
||||
|
||||
|
||||
30
Dockerfile
30
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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -166,13 +166,13 @@ class JobExecutorServiceTest {
|
||||
// Given
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(100); // Simulate long-running job
|
||||
return "test-result";
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException(e);
|
||||
// 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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -131,6 +131,9 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for each directory we'll process
|
||||
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<Files> 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<Files> 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<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for each directory
|
||||
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");
|
||||
|
||||
@ -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 {
|
||||
"<p>ok</p><script>alert('XSS')</script><img src=\"http://blocked.local/a.png\">";
|
||||
|
||||
// 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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<String, List<String>> 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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files));
|
||||
List<MultipartFile> 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(
|
||||
|
||||
@ -0,0 +1,208 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertEbookToPDFController {
|
||||
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
Set.of("epub", "mobi", "azw3", "fb2", "txt", "docx");
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private boolean isCalibreEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Calibre");
|
||||
}
|
||||
|
||||
private boolean isGhostscriptEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
|
||||
@Operation(
|
||||
summary = "Convert an eBook file to PDF",
|
||||
description =
|
||||
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
|
||||
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> convertEbookToPdf(
|
||||
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
|
||||
if (!isCalibreEnabled()) {
|
||||
throw new IllegalStateException("Calibre support is disabled");
|
||||
}
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
throw new IllegalArgumentException("No input file provided");
|
||||
}
|
||||
|
||||
boolean optimizeForEbook = Boolean.TRUE.equals(request.getOptimizeForEbook());
|
||||
if (optimizeForEbook && !isGhostscriptEnabled()) {
|
||||
log.warn(
|
||||
"Ghostscript optimization requested but Ghostscript is not enabled/available"
|
||||
+ " for ebook conversion");
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
boolean embedAllFonts = Boolean.TRUE.equals(request.getEmbedAllFonts());
|
||||
boolean includeTableOfContents = Boolean.TRUE.equals(request.getIncludeTableOfContents());
|
||||
boolean includePageNumbers = Boolean.TRUE.equals(request.getIncludePageNumbers());
|
||||
|
||||
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "document";
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension == null || extension.isBlank()) {
|
||||
throw new IllegalArgumentException("Unable to determine file type");
|
||||
}
|
||||
|
||||
String lowerExtension = extension.toLowerCase(Locale.ROOT);
|
||||
if (!SUPPORTED_EXTENSIONS.contains(lowerExtension)) {
|
||||
throw new IllegalArgumentException("Unsupported eBook file extension: " + extension);
|
||||
}
|
||||
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
baseName = "document";
|
||||
}
|
||||
|
||||
Path workingDirectory = tempFileManager.createTempDirectory();
|
||||
Path inputPath = workingDirectory.resolve(baseName + "." + lowerExtension);
|
||||
Path outputPath = workingDirectory.resolve(baseName + ".pdf");
|
||||
|
||||
try (InputStream inputStream = inputFile.getInputStream()) {
|
||||
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
List<String> command =
|
||||
buildCalibreCommand(
|
||||
inputPath,
|
||||
outputPath,
|
||||
embedAllFonts,
|
||||
includeTableOfContents,
|
||||
includePageNumbers);
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
|
||||
.runCommandWithOutputHandling(command, workingDirectory.toFile());
|
||||
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("Calibre conversion returned no result");
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
String errorMessage = result.getMessages();
|
||||
if (errorMessage == null || errorMessage.isBlank()) {
|
||||
errorMessage = "Calibre conversion failed";
|
||||
}
|
||||
throw new IllegalStateException(errorMessage);
|
||||
}
|
||||
|
||||
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
|
||||
throw new IllegalStateException("Calibre did not produce a PDF output");
|
||||
}
|
||||
|
||||
String outputFilename =
|
||||
GeneralUtils.generateFilename(originalFilename, "_convertedToPDF.pdf");
|
||||
|
||||
try {
|
||||
if (optimizeForEbook) {
|
||||
byte[] pdfBytes = Files.readAllBytes(outputPath);
|
||||
try {
|
||||
byte[] optimizedPdf = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
return WebResponseUtils.bytesToWebResponse(optimizedPdf, outputFilename);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Ghostscript optimization failed for ebook conversion, returning"
|
||||
+ " original PDF",
|
||||
e);
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
|
||||
}
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(outputPath.toFile())) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
|
||||
}
|
||||
} finally {
|
||||
cleanupTempFiles(workingDirectory, inputPath, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> buildCalibreCommand(
|
||||
Path inputPath,
|
||||
Path outputPath,
|
||||
boolean embedAllFonts,
|
||||
boolean includeTableOfContents,
|
||||
boolean includePageNumbers) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("ebook-convert");
|
||||
command.add(inputPath.toString());
|
||||
command.add(outputPath.toString());
|
||||
|
||||
if (embedAllFonts) {
|
||||
command.add("--embed-all-fonts");
|
||||
}
|
||||
if (includeTableOfContents) {
|
||||
command.add("--pdf-add-toc");
|
||||
}
|
||||
if (includePageNumbers) {
|
||||
command.add("--pdf-page-numbers");
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
|
||||
List<Path> pathsToDelete = new ArrayList<>();
|
||||
pathsToDelete.add(inputPath);
|
||||
pathsToDelete.add(outputPath);
|
||||
|
||||
for (Path path : pathsToDelete) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", path, e);
|
||||
}
|
||||
}
|
||||
tempFileManager.deleteTempDirectory(workingDirectory);
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: /";
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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", ()=>{
|
||||
|
||||
107
app/core/src/main/resources/templates/convert/ebook-to-pdf.html
Normal file
107
app/core/src/main/resources/templates/convert/ebook-to-pdf.html
Normal file
@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:data-language="${#locale.toString()}"
|
||||
th:dir="#{language.direction}"
|
||||
th:lang="${#locale.language}"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{ebookToPDF.title}, header=#{ebookToPDF.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon convertto">menu_book</span>
|
||||
<span class="tool-header-text"
|
||||
th:text="#{ebookToPDF.header}"></span>
|
||||
</div>
|
||||
<p th:text="#{processTimeWarning}"></p>
|
||||
|
||||
<div class="alert alert-warning"
|
||||
th:if="${!@endpointConfiguration.isGroupEnabled('Calibre')}">
|
||||
<span th:text="#{ebookToPDF.calibreDisabled}">Calibre support is disabled.</span>
|
||||
</div>
|
||||
|
||||
<form enctype="multipart/form-data"
|
||||
id="ebookToPDFForm"
|
||||
method="post"
|
||||
th:action="@{'/api/v1/convert/ebook/pdf'}"
|
||||
th:if="${@endpointConfiguration.isGroupEnabled('Calibre')}">
|
||||
<div
|
||||
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.epub,.mobi,.azw3,.fb2,.txt,.docx', inputText=#{ebookToPDF.selectText})}">
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input"
|
||||
id="embedAllFonts"
|
||||
name="embedAllFonts"
|
||||
type="checkbox"
|
||||
value="true">
|
||||
<label for="embedAllFonts"
|
||||
th:text="#{ebookToPDF.embedAllFonts}">
|
||||
Embed all fonts in PDF
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input"
|
||||
id="includeTableOfContents"
|
||||
name="includeTableOfContents"
|
||||
type="checkbox"
|
||||
value="true">
|
||||
<label
|
||||
for="includeTableOfContents"
|
||||
th:text="#{ebookToPDF.includeTableOfContents}">
|
||||
Add table of contents
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input"
|
||||
id="includePageNumbers"
|
||||
name="includePageNumbers"
|
||||
type="checkbox"
|
||||
value="true">
|
||||
<label
|
||||
for="includePageNumbers"
|
||||
th:text="#{ebookToPDF.includePageNumbers}">
|
||||
Add page numbers
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3"
|
||||
th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
|
||||
<input class="form-check-input"
|
||||
id="optimizeForEbook"
|
||||
name="optimizeForEbook"
|
||||
type="checkbox"
|
||||
value="true">
|
||||
<label
|
||||
for="optimizeForEbook"
|
||||
th:text="#{ebookToPDF.optimizeForEbook}">
|
||||
Optimize PDF for ebook readers (uses Ghostscript)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary"
|
||||
id="submitBtn"
|
||||
th:text="#{ebookToPDF.submit}"
|
||||
type="submit">Convert to
|
||||
PDF</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -53,6 +53,9 @@
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
|
||||
</div>
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('ebook-to-pdf', 'menu_book', 'home.ebookToPdf.title', 'home.ebookToPdf.desc', 'ebookToPdf.tags', 'convertto')}">
|
||||
</div>
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
|
||||
</div>
|
||||
@ -132,6 +135,9 @@
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
|
||||
</div>
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('ebook-to-pdf', 'menu_book', 'home.ebookToPdf.title', 'home.ebookToPdf.desc', 'ebookToPdf.tags', 'convertto')}">
|
||||
</div>
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,266 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.ProcessExecutor.Processes;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ConvertEbookToPDFControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@InjectMocks private ConvertEbookToPDFController controller;
|
||||
|
||||
@Test
|
||||
void convertEbookToPdf_buildsCalibreCommandAndCleansUp() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
|
||||
|
||||
MockMultipartFile ebookFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "ebook.epub", "application/epub+zip", "content".getBytes());
|
||||
|
||||
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
|
||||
request.setFileInput(ebookFile);
|
||||
request.setEmbedAllFonts(true);
|
||||
request.setIncludeTableOfContents(true);
|
||||
request.setIncludePageNumbers(true);
|
||||
|
||||
Path workingDir = Files.createTempDirectory("ebook-convert-test-");
|
||||
when(tempFileManager.createTempDirectory()).thenReturn(workingDir);
|
||||
|
||||
AtomicReference<Path> deletedDir = new AtomicReference<>();
|
||||
Mockito.doAnswer(
|
||||
invocation -> {
|
||||
Path dir = invocation.getArgument(0);
|
||||
deletedDir.set(dir);
|
||||
if (Files.exists(dir)) {
|
||||
try (Stream<Path> paths = Files.walk(dir)) {
|
||||
paths.sorted(Comparator.reverseOrder())
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.when(tempFileManager)
|
||||
.deleteTempDirectory(any(Path.class));
|
||||
|
||||
PDDocument mockDocument = Mockito.mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(mockDocument);
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
|
||||
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
|
||||
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor);
|
||||
|
||||
ProcessExecutorResult execResult = Mockito.mock(ProcessExecutorResult.class);
|
||||
when(execResult.getRc()).thenReturn(0);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
|
||||
Path expectedInput = workingDir.resolve("ebook.epub");
|
||||
Path expectedOutput = workingDir.resolve("ebook.pdf");
|
||||
when(executor.runCommandWithOutputHandling(
|
||||
commandCaptor.capture(), eq(workingDir.toFile())))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
Files.writeString(expectedOutput, "pdf");
|
||||
return execResult;
|
||||
});
|
||||
|
||||
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok("result".getBytes());
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfDocToWebResponse(
|
||||
mockDocument, "ebook_convertedToPDF.pdf"))
|
||||
.thenReturn(expectedResponse);
|
||||
gu.when(() -> GeneralUtils.generateFilename("ebook.epub", "_convertedToPDF.pdf"))
|
||||
.thenReturn("ebook_convertedToPDF.pdf");
|
||||
|
||||
ResponseEntity<byte[]> response = controller.convertEbookToPdf(request);
|
||||
|
||||
assertSame(expectedResponse, response);
|
||||
|
||||
List<String> command = commandCaptor.getValue();
|
||||
assertEquals(6, command.size());
|
||||
assertEquals("ebook-convert", command.get(0));
|
||||
assertEquals(expectedInput.toString(), command.get(1));
|
||||
assertEquals(expectedOutput.toString(), command.get(2));
|
||||
assertEquals("--embed-all-fonts", command.get(3));
|
||||
assertEquals("--pdf-add-toc", command.get(4));
|
||||
assertEquals("--pdf-page-numbers", command.get(5));
|
||||
|
||||
assertFalse(Files.exists(expectedInput));
|
||||
assertFalse(Files.exists(expectedOutput));
|
||||
assertEquals(workingDir, deletedDir.get());
|
||||
Mockito.verify(tempFileManager).deleteTempDirectory(workingDir);
|
||||
}
|
||||
|
||||
if (Files.exists(workingDir)) {
|
||||
try (Stream<Path> paths = Files.walk(workingDir)) {
|
||||
paths.sorted(Comparator.reverseOrder())
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertEbookToPdf_withUnsupportedExtensionThrows() {
|
||||
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
|
||||
|
||||
MockMultipartFile unsupported =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "ebook.exe", "application/octet-stream", new byte[] {1, 2, 3});
|
||||
|
||||
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
|
||||
request.setFileInput(unsupported);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.convertEbookToPdf(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertEbookToPdf_withOptimizeForEbookUsesGhostscript() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
|
||||
MockMultipartFile ebookFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "ebook.epub", "application/epub+zip", "content".getBytes());
|
||||
|
||||
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
|
||||
request.setFileInput(ebookFile);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Path workingDir = Files.createTempDirectory("ebook-convert-opt-test-");
|
||||
when(tempFileManager.createTempDirectory()).thenReturn(workingDir);
|
||||
|
||||
AtomicReference<Path> deletedDir = new AtomicReference<>();
|
||||
Mockito.doAnswer(
|
||||
invocation -> {
|
||||
Path dir = invocation.getArgument(0);
|
||||
deletedDir.set(dir);
|
||||
if (Files.exists(dir)) {
|
||||
try (Stream<Path> paths = Files.walk(dir)) {
|
||||
paths.sorted(Comparator.reverseOrder())
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.when(tempFileManager)
|
||||
.deleteTempDirectory(any(Path.class));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor);
|
||||
|
||||
ProcessExecutorResult execResult = Mockito.mock(ProcessExecutorResult.class);
|
||||
when(execResult.getRc()).thenReturn(0);
|
||||
|
||||
Path expectedInput = workingDir.resolve("ebook.epub");
|
||||
Path expectedOutput = workingDir.resolve("ebook.pdf");
|
||||
when(executor.runCommandWithOutputHandling(any(List.class), eq(workingDir.toFile())))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
Files.writeString(expectedOutput, "pdf");
|
||||
return execResult;
|
||||
});
|
||||
|
||||
gu.when(() -> GeneralUtils.generateFilename("ebook.epub", "_convertedToPDF.pdf"))
|
||||
.thenReturn("ebook_convertedToPDF.pdf");
|
||||
byte[] optimizedBytes = "optimized".getBytes(StandardCharsets.UTF_8);
|
||||
gu.when(() -> GeneralUtils.optimizePdfWithGhostscript(Mockito.any(byte[].class)))
|
||||
.thenReturn(optimizedBytes);
|
||||
|
||||
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(optimizedBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
optimizedBytes, "ebook_convertedToPDF.pdf"))
|
||||
.thenReturn(expectedResponse);
|
||||
|
||||
ResponseEntity<byte[]> response = controller.convertEbookToPdf(request);
|
||||
|
||||
assertSame(expectedResponse, response);
|
||||
gu.verify(() -> GeneralUtils.optimizePdfWithGhostscript(Mockito.any(byte[].class)));
|
||||
Mockito.verifyNoInteractions(pdfDocumentFactory);
|
||||
Mockito.verify(tempFileManager).deleteTempDirectory(workingDir);
|
||||
assertEquals(workingDir, deletedDir.get());
|
||||
assertFalse(Files.exists(expectedInput));
|
||||
assertFalse(Files.exists(expectedOutput));
|
||||
}
|
||||
|
||||
if (Files.exists(workingDir)) {
|
||||
try (Stream<Path> paths = Files.walk(workingDir)) {
|
||||
paths.sorted(Comparator.reverseOrder())
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,195 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ConverterWebControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private ConverterWebController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
controller = new ConverterWebController();
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
private static Stream<Object[]> simpleEndpoints() {
|
||||
return Stream.of(
|
||||
new Object[] {"/img-to-pdf", "convert/img-to-pdf", "img-to-pdf"},
|
||||
new Object[] {"/cbz-to-pdf", "convert/cbz-to-pdf", "cbz-to-pdf"},
|
||||
new Object[] {"/pdf-to-cbz", "convert/pdf-to-cbz", "pdf-to-cbz"},
|
||||
new Object[] {"/cbr-to-pdf", "convert/cbr-to-pdf", "cbr-to-pdf"},
|
||||
new Object[] {"/html-to-pdf", "convert/html-to-pdf", "html-to-pdf"},
|
||||
new Object[] {"/markdown-to-pdf", "convert/markdown-to-pdf", "markdown-to-pdf"},
|
||||
new Object[] {"/pdf-to-markdown", "convert/pdf-to-markdown", "pdf-to-markdown"},
|
||||
new Object[] {"/url-to-pdf", "convert/url-to-pdf", "url-to-pdf"},
|
||||
new Object[] {"/file-to-pdf", "convert/file-to-pdf", "file-to-pdf"},
|
||||
new Object[] {"/pdf-to-pdfa", "convert/pdf-to-pdfa", "pdf-to-pdfa"},
|
||||
new Object[] {"/pdf-to-vector", "convert/pdf-to-vector", "pdf-to-vector"},
|
||||
new Object[] {"/vector-to-pdf", "convert/vector-to-pdf", "vector-to-pdf"},
|
||||
new Object[] {"/pdf-to-xml", "convert/pdf-to-xml", "pdf-to-xml"},
|
||||
new Object[] {"/pdf-to-csv", "convert/pdf-to-csv", "pdf-to-csv"},
|
||||
new Object[] {"/pdf-to-html", "convert/pdf-to-html", "pdf-to-html"},
|
||||
new Object[] {
|
||||
"/pdf-to-presentation", "convert/pdf-to-presentation", "pdf-to-presentation"
|
||||
},
|
||||
new Object[] {"/pdf-to-text", "convert/pdf-to-text", "pdf-to-text"},
|
||||
new Object[] {"/pdf-to-word", "convert/pdf-to-word", "pdf-to-word"},
|
||||
new Object[] {"/eml-to-pdf", "convert/eml-to-pdf", "eml-to-pdf"});
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "[{index}] GET {0}")
|
||||
@MethodSource("simpleEndpoints")
|
||||
@DisplayName("Should return correct view and model for simple endpoints")
|
||||
void shouldReturnCorrectViewForSimpleEndpoints(String path, String viewName, String page)
|
||||
throws Exception {
|
||||
mockMvc.perform(get(path))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name(viewName))
|
||||
.andExpect(model().attribute("currentPage", page));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("PDF to CBR endpoint tests")
|
||||
class PdfToCbrTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return 404 when endpoint disabled")
|
||||
void shouldReturn404WhenDisabled() throws Exception {
|
||||
try (MockedStatic<ApplicationContextProvider> acp =
|
||||
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class);
|
||||
when(endpointConfig.isEndpointEnabled(eq("pdf-to-cbr"))).thenReturn(false);
|
||||
acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class))
|
||||
.thenReturn(endpointConfig);
|
||||
|
||||
mockMvc.perform(get("/pdf-to-cbr")).andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return OK when endpoint enabled")
|
||||
void shouldReturnOkWhenEnabled() throws Exception {
|
||||
try (MockedStatic<ApplicationContextProvider> acp =
|
||||
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class);
|
||||
when(endpointConfig.isEndpointEnabled(eq("pdf-to-cbr"))).thenReturn(true);
|
||||
acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class))
|
||||
.thenReturn(endpointConfig);
|
||||
|
||||
mockMvc.perform(get("/pdf-to-cbr"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("convert/pdf-to-cbr"))
|
||||
.andExpect(model().attribute("currentPage", "pdf-to-cbr"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle pdf-to-img with default maxDPI=500")
|
||||
void shouldHandlePdfToImgWithDefaultMaxDpi() throws Exception {
|
||||
try (MockedStatic<ApplicationContextProvider> acp =
|
||||
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class);
|
||||
MockedStatic<CheckProgramInstall> cpi =
|
||||
org.mockito.Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/pdf-to-img"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("convert/pdf-to-img"))
|
||||
.andExpect(model().attribute("isPython", true))
|
||||
.andExpect(model().attribute("maxDPI", 500));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle pdf-to-video with default maxDPI=500")
|
||||
void shouldHandlePdfToVideoWithDefaultMaxDpi() throws Exception {
|
||||
try (MockedStatic<ApplicationContextProvider> acp =
|
||||
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/pdf-to-video"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("convert/pdf-to-video"))
|
||||
.andExpect(model().attribute("maxDPI", 500))
|
||||
.andExpect(model().attribute("currentPage", "pdf-to-video"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle pdf-to-img with configured maxDPI from properties")
|
||||
void shouldHandlePdfToImgWithConfiguredMaxDpi() throws Exception {
|
||||
// Covers the 'if' branch (properties and system not null)
|
||||
try (MockedStatic<ApplicationContextProvider> acp =
|
||||
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class);
|
||||
MockedStatic<CheckProgramInstall> cpi =
|
||||
org.mockito.Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
|
||||
ApplicationProperties properties =
|
||||
org.mockito.Mockito.mock(
|
||||
ApplicationProperties.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(properties.getSystem().getMaxDPI()).thenReturn(777);
|
||||
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(properties);
|
||||
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/pdf-to-img"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("convert/pdf-to-img"))
|
||||
.andExpect(model().attribute("isPython", true))
|
||||
.andExpect(model().attribute("maxDPI", 777))
|
||||
.andExpect(model().attribute("currentPage", "pdf-to-img"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle pdf-to-video with configured maxDPI from properties")
|
||||
void shouldHandlePdfToVideoWithConfiguredMaxDpi() throws Exception {
|
||||
// Covers the 'if' branch (properties and system not null)
|
||||
try (MockedStatic<ApplicationContextProvider> acp =
|
||||
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
|
||||
ApplicationProperties properties =
|
||||
org.mockito.Mockito.mock(
|
||||
ApplicationProperties.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(properties.getSystem().getMaxDPI()).thenReturn(640);
|
||||
acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(properties);
|
||||
|
||||
mockMvc.perform(get("/pdf-to-video"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("convert/pdf-to-video"))
|
||||
.andExpect(model().attribute("maxDPI", 640))
|
||||
.andExpect(model().attribute("currentPage", "pdf-to-video"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,406 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import static org.hamcrest.Matchers.empty;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.servlet.ViewResolver;
|
||||
import org.springframework.web.servlet.view.AbstractView;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeneralWebControllerTest {
|
||||
|
||||
private static final String CLASSPATH_WOFF2 = "classpath:static/fonts/*.woff2";
|
||||
private static final String FILE_FONTS_GLOB = "file:/opt/static/fonts/*";
|
||||
|
||||
private static String normalize(String s) {
|
||||
return s.replace('\\', '/');
|
||||
}
|
||||
|
||||
private static ViewResolver noOpViewResolver() {
|
||||
return (viewName, locale) ->
|
||||
new AbstractView() {
|
||||
@Override
|
||||
protected void renderMergedOutputModel(
|
||||
Map<String, Object> model,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static Stream<Object[]> simpleEndpoints() {
|
||||
return Stream.of(
|
||||
new Object[] {"/merge-pdfs", "merge-pdfs", "merge-pdfs"},
|
||||
new Object[] {
|
||||
"/split-pdf-by-sections", "split-pdf-by-sections", "split-pdf-by-sections"
|
||||
},
|
||||
new Object[] {
|
||||
"/split-pdf-by-chapters", "split-pdf-by-chapters", "split-pdf-by-chapters"
|
||||
},
|
||||
new Object[] {"/view-pdf", "view-pdf", "view-pdf"},
|
||||
new Object[] {
|
||||
"/edit-table-of-contents", "edit-table-of-contents", "edit-table-of-contents"
|
||||
},
|
||||
new Object[] {"/multi-tool", "multi-tool", "multi-tool"},
|
||||
new Object[] {"/remove-pages", "remove-pages", "remove-pages"},
|
||||
new Object[] {"/pdf-organizer", "pdf-organizer", "pdf-organizer"},
|
||||
new Object[] {"/extract-page", "extract-page", "extract-page"},
|
||||
new Object[] {"/pdf-to-single-page", "pdf-to-single-page", "pdf-to-single-page"},
|
||||
new Object[] {"/rotate-pdf", "rotate-pdf", "rotate-pdf"},
|
||||
new Object[] {"/split-pdfs", "split-pdfs", "split-pdfs"},
|
||||
new Object[] {"/multi-page-layout", "multi-page-layout", "multi-page-layout"},
|
||||
new Object[] {"/scale-pages", "scale-pages", "scale-pages"},
|
||||
new Object[] {
|
||||
"/split-by-size-or-count", "split-by-size-or-count", "split-by-size-or-count"
|
||||
},
|
||||
new Object[] {"/overlay-pdf", "overlay-pdf", "overlay-pdf"},
|
||||
new Object[] {"/crop", "crop", "crop"},
|
||||
new Object[] {"/auto-split-pdf", "auto-split-pdf", "auto-split-pdf"},
|
||||
new Object[] {"/remove-image-pdf", "remove-image-pdf", "remove-image-pdf"});
|
||||
}
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private SignatureService signatureService;
|
||||
private UserServiceInterface userService;
|
||||
private RuntimePathConfig runtimePathConfig;
|
||||
private org.springframework.core.io.ResourceLoader resourceLoader;
|
||||
|
||||
private GeneralWebController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
signatureService = mock(SignatureService.class);
|
||||
userService = mock(UserServiceInterface.class);
|
||||
runtimePathConfig = mock(RuntimePathConfig.class);
|
||||
resourceLoader = mock(org.springframework.core.io.ResourceLoader.class);
|
||||
|
||||
controller =
|
||||
new GeneralWebController(
|
||||
signatureService, userService, resourceLoader, runtimePathConfig);
|
||||
|
||||
mockMvc =
|
||||
MockMvcBuilders.standaloneSetup(controller)
|
||||
.setViewResolvers(noOpViewResolver())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Simple endpoints")
|
||||
class SimpleEndpoints {
|
||||
|
||||
@DisplayName("Should render simple pages with correct currentPage")
|
||||
@ParameterizedTest(name = "[{index}] GET {0} -> view {1}")
|
||||
@MethodSource(
|
||||
"stirling.software.SPDF.controller.web.GeneralWebControllerTest#simpleEndpoints")
|
||||
void shouldRenderSimplePages(String path, String expectedView, String currentPage)
|
||||
throws Exception {
|
||||
mockMvc.perform(get(path))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name(expectedView))
|
||||
.andExpect(model().attribute("currentPage", currentPage));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("/sign endpoint")
|
||||
class SignForm {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use current username, list signatures and fonts")
|
||||
void shouldPopulateModelWithUserSignaturesAndFonts() throws Exception {
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
List<SignatureFile> signatures = List.of(new SignatureFile(), new SignatureFile());
|
||||
when(signatureService.getAvailableSignatures("alice")).thenReturn(signatures);
|
||||
|
||||
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
|
||||
MockedStatic<InstallationPathConfig> ipc =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
|
||||
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
|
||||
|
||||
Resource woff2 = mock(Resource.class);
|
||||
when(woff2.getFilename()).thenReturn("Roboto-Regular.woff2");
|
||||
Resource ttf = mock(Resource.class);
|
||||
when(ttf.getFilename()).thenReturn("MyFont.ttf");
|
||||
|
||||
// Windows-safe conditional stub (normalize backslashes)
|
||||
gu.when(
|
||||
() ->
|
||||
GeneralUtils.getResourcesFromLocationPattern(
|
||||
anyString(), eq(resourceLoader)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
String pattern = normalize(inv.getArgument(0, String.class));
|
||||
if (CLASSPATH_WOFF2.equals(pattern))
|
||||
return new Resource[] {woff2};
|
||||
if (FILE_FONTS_GLOB.equals(pattern))
|
||||
return new Resource[] {ttf};
|
||||
return new Resource[0];
|
||||
});
|
||||
|
||||
var mvcResult =
|
||||
mockMvc.perform(get("/sign"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("sign"))
|
||||
.andExpect(model().attribute("currentPage", "sign"))
|
||||
.andExpect(model().attributeExists("fonts"))
|
||||
.andExpect(model().attribute("signatures", signatures))
|
||||
.andReturn();
|
||||
|
||||
Object fontsAttr = mvcResult.getModelAndView().getModel().get("fonts");
|
||||
Assertions.assertTrue(fontsAttr instanceof List<?>);
|
||||
List<?> fonts = (List<?>) fontsAttr;
|
||||
Assertions.assertEquals(
|
||||
2, fonts.size(), "Expected two font entries (classpath + external)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle missing UserService (username empty string)")
|
||||
void shouldHandleNullUserService() throws Exception {
|
||||
GeneralWebController ctrl =
|
||||
new GeneralWebController(
|
||||
signatureService, null, resourceLoader, runtimePathConfig);
|
||||
MockMvc localMvc =
|
||||
MockMvcBuilders.standaloneSetup(ctrl)
|
||||
.setViewResolvers(noOpViewResolver())
|
||||
.build();
|
||||
|
||||
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
|
||||
MockedStatic<InstallationPathConfig> ipc =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
|
||||
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
|
||||
gu.when(
|
||||
() ->
|
||||
GeneralUtils.getResourcesFromLocationPattern(
|
||||
anyString(), eq(resourceLoader)))
|
||||
.thenReturn(new Resource[0]);
|
||||
|
||||
when(signatureService.getAvailableSignatures(""))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
localMvc.perform(get("/sign"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("sign"))
|
||||
.andExpect(model().attribute("currentPage", "sign"))
|
||||
.andExpect(model().attribute("signatures", empty()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"Throws ServletException when a font file cannot be processed (inner try/catch"
|
||||
+ " path)")
|
||||
void shouldThrowServletExceptionWhenFontProcessingFails() {
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
when(signatureService.getAvailableSignatures("alice"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
Resource bad = mock(Resource.class);
|
||||
when(bad.getFilename()).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
|
||||
MockedStatic<InstallationPathConfig> ipc =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
|
||||
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
|
||||
|
||||
gu.when(
|
||||
() ->
|
||||
GeneralUtils.getResourcesFromLocationPattern(
|
||||
anyString(), eq(resourceLoader)))
|
||||
.thenReturn(new Resource[] {bad});
|
||||
|
||||
Assertions.assertThrows(
|
||||
ServletException.class,
|
||||
() -> {
|
||||
mockMvc.perform(get("/sign")).andReturn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ignores font resource without extension (no crash, filtered out)")
|
||||
void shouldIgnoreFontWithoutExtension() throws Exception {
|
||||
when(userService.getCurrentUsername()).thenReturn("bob");
|
||||
when(signatureService.getAvailableSignatures("bob"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
Resource noExt = mock(Resource.class);
|
||||
when(noExt.getFilename()).thenReturn("JustAName"); // no dot -> filtered out
|
||||
|
||||
Resource good = mock(Resource.class);
|
||||
when(good.getFilename()).thenReturn("SomeFont.woff2");
|
||||
|
||||
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
|
||||
MockedStatic<InstallationPathConfig> ipc =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
|
||||
ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/");
|
||||
|
||||
gu.when(
|
||||
() ->
|
||||
GeneralUtils.getResourcesFromLocationPattern(
|
||||
anyString(), eq(resourceLoader)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
String p = normalize(inv.getArgument(0, String.class));
|
||||
if (CLASSPATH_WOFF2.equals(p))
|
||||
return new Resource[] {noExt}; // ignored
|
||||
if (FILE_FONTS_GLOB.equals(p))
|
||||
return new Resource[] {good}; // kept
|
||||
return new Resource[0];
|
||||
});
|
||||
|
||||
var mvcResult =
|
||||
mockMvc.perform(get("/sign"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("sign"))
|
||||
.andExpect(model().attribute("currentPage", "sign"))
|
||||
.andReturn();
|
||||
|
||||
Object fontsAttr = mvcResult.getModelAndView().getModel().get("fonts");
|
||||
Assertions.assertTrue(fontsAttr instanceof List<?>);
|
||||
List<?> fonts = (List<?>) fontsAttr;
|
||||
Assertions.assertEquals(1, fonts.size(), "Only the valid font should remain");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("/pipeline endpoint")
|
||||
class PipelineForm {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should load JSON configs from runtime path and infer names")
|
||||
void shouldLoadJsonConfigs() throws Exception {
|
||||
Path tempDir = Files.createTempDirectory("pipelines");
|
||||
Path a = tempDir.resolve("a.json");
|
||||
Path b = tempDir.resolve("b.json");
|
||||
Files.writeString(a, "{\"name\":\"Config A\",\"x\":1}", StandardCharsets.UTF_8);
|
||||
Files.writeString(b, "{\"y\":2}", StandardCharsets.UTF_8);
|
||||
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(tempDir.toString());
|
||||
|
||||
var mvcResult =
|
||||
mockMvc.perform(get("/pipeline"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("pipeline"))
|
||||
.andExpect(model().attribute("currentPage", "pipeline"))
|
||||
.andExpect(
|
||||
model().attributeExists(
|
||||
"pipelineConfigs", "pipelineConfigsWithNames"))
|
||||
.andReturn();
|
||||
|
||||
Map<String, Object> model = mvcResult.getModelAndView().getModel();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> configsRaw = (List<String>) model.get("pipelineConfigs");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, String>> configsNamed =
|
||||
(List<Map<String, String>>) model.get("pipelineConfigsWithNames");
|
||||
|
||||
Assertions.assertEquals(2, configsRaw.size());
|
||||
Assertions.assertEquals(2, configsNamed.size());
|
||||
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Map<String, String> m : configsNamed) {
|
||||
names.add(m.get("name"));
|
||||
Assertions.assertTrue(configsRaw.contains(m.get("json")));
|
||||
}
|
||||
Assertions.assertTrue(names.contains("Config A"));
|
||||
Assertions.assertTrue(names.contains("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should fall back to default entry when Files.walk throws IOException")
|
||||
void shouldFallbackWhenWalkThrowsIOException() throws Exception {
|
||||
Path tempDir = Files.createTempDirectory("pipelines"); // exists() -> true
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(tempDir.toString());
|
||||
|
||||
try (MockedStatic<Files> files = mockStatic(Files.class)) {
|
||||
files.when(() -> Files.walk(any(Path.class)))
|
||||
.thenThrow(new IOException("fail walk"));
|
||||
|
||||
var mvcResult =
|
||||
mockMvc.perform(get("/pipeline"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("pipeline"))
|
||||
.andExpect(model().attribute("currentPage", "pipeline"))
|
||||
.andReturn();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, String>> configsNamed =
|
||||
(List<Map<String, String>>)
|
||||
mvcResult
|
||||
.getModelAndView()
|
||||
.getModel()
|
||||
.get("pipelineConfigsWithNames");
|
||||
|
||||
Assertions.assertEquals(
|
||||
1, configsNamed.size(), "Should add a default placeholder on IOException");
|
||||
Assertions.assertEquals(
|
||||
"No preloaded configs found", configsNamed.get(0).get("name"));
|
||||
Assertions.assertEquals("", configsNamed.get(0).get("json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getFormatFromExtension")
|
||||
class GetFormatFromExtension {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return empty string for unknown extensions (default branch)")
|
||||
void shouldReturnDefaultForUnknown() {
|
||||
Assertions.assertEquals("", controller.getFormatFromExtension("otf"));
|
||||
Assertions.assertEquals("", controller.getFormatFromExtension("unknown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Known extensions should map correctly")
|
||||
void shouldMapKnownExtensions() {
|
||||
Assertions.assertEquals("truetype", controller.getFormatFromExtension("ttf"));
|
||||
Assertions.assertEquals("woff", controller.getFormatFromExtension("woff"));
|
||||
Assertions.assertEquals("woff2", controller.getFormatFromExtension("woff2"));
|
||||
Assertions.assertEquals("embedded-opentype", controller.getFormatFromExtension("eot"));
|
||||
Assertions.assertEquals("svg", controller.getFormatFromExtension("svg"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,223 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.MockedConstruction;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.servlet.ViewResolver;
|
||||
import org.springframework.web.servlet.view.AbstractView;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HomeWebControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private ApplicationProperties applicationProperties;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
applicationProperties = mock(ApplicationProperties.class, RETURNS_DEEP_STUBS);
|
||||
HomeWebController controller = new HomeWebController(applicationProperties);
|
||||
|
||||
mockMvc =
|
||||
MockMvcBuilders.standaloneSetup(controller)
|
||||
.setViewResolvers(noOpViewResolver())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ViewResolver noOpViewResolver() {
|
||||
return (viewName, locale) ->
|
||||
new AbstractView() {
|
||||
@Override
|
||||
protected void renderMergedOutputModel(
|
||||
Map<String, Object> model,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Simple pages & redirects")
|
||||
class SimplePagesAndRedirects {
|
||||
|
||||
@Test
|
||||
@DisplayName("/about should return correct view and currentPage")
|
||||
void about_shouldReturnView() throws Exception {
|
||||
mockMvc.perform(get("/about"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("about"))
|
||||
.andExpect(model().attribute("currentPage", "about"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/releases should return correct view")
|
||||
void releases_shouldReturnView() throws Exception {
|
||||
mockMvc.perform(get("/releases"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("releases"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/home should redirect to root")
|
||||
void home_shouldRedirect() throws Exception {
|
||||
// With the no-op resolver, "redirect:/" is treated as a view -> status OK
|
||||
mockMvc.perform(get("/home"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("redirect:/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/home-legacy should redirect to root")
|
||||
void homeLegacy_shouldRedirect() throws Exception {
|
||||
mockMvc.perform(get("/home-legacy"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("redirect:/"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Home page with SHOW_SURVEY environment variable")
|
||||
class HomePage {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should correctly map SHOW_SURVEY env var to showSurveyFromDocker")
|
||||
void root_mapsEnvCorrectly() throws Exception {
|
||||
String env = System.getenv("SHOW_SURVEY");
|
||||
boolean expected = (env == null) || "true".equalsIgnoreCase(env);
|
||||
|
||||
mockMvc.perform(get("/"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("home"))
|
||||
.andExpect(model().attribute("currentPage", "home"))
|
||||
.andExpect(model().attribute("showSurveyFromDocker", expected));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("/robots.txt behavior")
|
||||
class RobotsTxt {
|
||||
|
||||
@Test
|
||||
@DisplayName("googlevisibility=true -> allow all agents")
|
||||
void robots_allow() throws Exception {
|
||||
when(applicationProperties.getSystem().isGooglevisibility()).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/robots.txt"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN))
|
||||
.andExpect(
|
||||
content()
|
||||
.string(
|
||||
"User-agent: Googlebot\n"
|
||||
+ "Allow: /\n\n"
|
||||
+ "User-agent: *\n"
|
||||
+ "Allow: /"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("googlevisibility=false -> disallow all agents")
|
||||
void robots_disallow() throws Exception {
|
||||
when(applicationProperties.getSystem().isGooglevisibility()).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/robots.txt"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN))
|
||||
.andExpect(
|
||||
content()
|
||||
.string(
|
||||
"User-agent: Googlebot\n"
|
||||
+ "Disallow: /\n\n"
|
||||
+ "User-agent: *\n"
|
||||
+ "Disallow: /"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("googlevisibility not set (default false) -> disallow all")
|
||||
void robots_disallowWhenNotSet() throws Exception {
|
||||
when(applicationProperties.getSystem().isGooglevisibility()).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/robots.txt"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN))
|
||||
.andExpect(
|
||||
content()
|
||||
.string(
|
||||
"User-agent: Googlebot\n"
|
||||
+ "Disallow: /\n\n"
|
||||
+ "User-agent: *\n"
|
||||
+ "Disallow: /"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("/licenses endpoint")
|
||||
class Licenses {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should read JSON and set dependencies + currentPage on model")
|
||||
void licenses_success() throws Exception {
|
||||
// Minimal valid JSON matching Map<String, List<Dependency>>
|
||||
String json = "{\"dependencies\":[{}]}";
|
||||
|
||||
try (MockedConstruction<ClassPathResource> mockedResource =
|
||||
mockConstruction(
|
||||
ClassPathResource.class,
|
||||
(mock, ctx) ->
|
||||
when(mock.getInputStream())
|
||||
.thenReturn(
|
||||
new ByteArrayInputStream(
|
||||
json.getBytes(
|
||||
StandardCharsets.UTF_8))))) {
|
||||
|
||||
var mvcResult =
|
||||
mockMvc.perform(get("/licenses"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("licenses"))
|
||||
.andExpect(model().attribute("currentPage", "licenses"))
|
||||
.andExpect(model().attributeExists("dependencies"))
|
||||
.andReturn();
|
||||
|
||||
Object depsObj = mvcResult.getModelAndView().getModel().get("dependencies");
|
||||
Assertions.assertTrue(depsObj instanceof java.util.List<?>);
|
||||
Assertions.assertEquals(
|
||||
1, ((java.util.List<?>) depsObj).size(), "Exactly one dependency expected");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException while reading -> still returns licenses view")
|
||||
void licenses_ioException() throws Exception {
|
||||
try (MockedConstruction<ClassPathResource> mockedResource =
|
||||
mockConstruction(
|
||||
ClassPathResource.class,
|
||||
(mock, ctx) ->
|
||||
when(mock.getInputStream())
|
||||
.thenThrow(new IOException("boom")))) {
|
||||
|
||||
mockMvc.perform(get("/licenses"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("licenses"))
|
||||
.andExpect(model().attribute("currentPage", "licenses"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<PDFText> foundTexts = textFinder.getFoundTexts();
|
||||
long endTime = System.currentTimeMillis();
|
||||
long endTime = 1001000L; // Fixed end time
|
||||
|
||||
assertEquals(10, foundTexts.size());
|
||||
assertTrue(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -0,0 +1,239 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.proprietary.security.model.AttemptCounter;
|
||||
|
||||
/**
|
||||
* Tests for LoginAttemptService#getRemainingAttempts(...) focusing on edge cases and documented
|
||||
* behavior. We instantiate the service reflectively to avoid depending on a specific constructor
|
||||
* signature. Private fields are set via reflection to keep existing production code unchanged.
|
||||
*
|
||||
* <p>Assumptions: - 'MAX_ATTEMPT' is a private int (possibly static final); we read it via
|
||||
* reflection (static-aware). - 'attemptsCache' is a ConcurrentHashMap<String, AttemptCounter>. -
|
||||
* 'isBlockedEnabled' is a boolean flag. - Behavior without clamping is intentional for now (can
|
||||
* return negative values).
|
||||
*/
|
||||
class LoginAttemptServiceTest {
|
||||
|
||||
// --- Reflection helpers ---
|
||||
|
||||
private static Object constructLoginAttemptService() {
|
||||
try {
|
||||
Class<?> clazz =
|
||||
Class.forName(
|
||||
"stirling.software.proprietary.security.service.LoginAttemptService");
|
||||
// Prefer a no-arg constructor if present; otherwise use the first and mock parameters.
|
||||
Constructor<?>[] ctors = clazz.getDeclaredConstructors();
|
||||
Arrays.stream(ctors).forEach(c -> c.setAccessible(true));
|
||||
|
||||
Constructor<?> target =
|
||||
Arrays.stream(ctors)
|
||||
.filter(c -> c.getParameterCount() == 0)
|
||||
.findFirst()
|
||||
.orElse(ctors[0]);
|
||||
|
||||
Object[] args = new Object[target.getParameterCount()];
|
||||
Class<?>[] paramTypes = target.getParameterTypes();
|
||||
for (int i = 0; i < paramTypes.length; i++) {
|
||||
Class<?> p = paramTypes[i];
|
||||
if (p.isPrimitive()) {
|
||||
// Provide basic defaults for primitives
|
||||
args[i] = defaultValueForPrimitive(p);
|
||||
} else {
|
||||
args[i] = Mockito.mock(p);
|
||||
}
|
||||
}
|
||||
return target.newInstance(args);
|
||||
} catch (Exception e) {
|
||||
fail("Could not construct LoginAttemptService reflectively: " + e.getMessage());
|
||||
return null; // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
private static Object defaultValueForPrimitive(Class<?> p) {
|
||||
if (p == boolean.class) return false;
|
||||
if (p == byte.class) return (byte) 0;
|
||||
if (p == short.class) return (short) 0;
|
||||
if (p == char.class) return (char) 0;
|
||||
if (p == int.class) return 0;
|
||||
if (p == long.class) return 0L;
|
||||
if (p == float.class) return 0f;
|
||||
if (p == double.class) return 0d;
|
||||
throw new IllegalArgumentException("Unsupported primitive: " + p);
|
||||
}
|
||||
|
||||
private static void setPrivate(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
Field f = target.getClass().getDeclaredField(fieldName);
|
||||
f.setAccessible(true);
|
||||
if (Modifier.isStatic(f.getModifiers())) {
|
||||
f.set(null, value);
|
||||
} else {
|
||||
f.set(target, value);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("Could not set field '" + fieldName + "': " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void setPrivateBoolean(Object target, String fieldName, boolean value) {
|
||||
try {
|
||||
Field f = target.getClass().getDeclaredField(fieldName);
|
||||
f.setAccessible(true);
|
||||
if (Modifier.isStatic(f.getModifiers())) {
|
||||
f.setBoolean(null, value);
|
||||
} else {
|
||||
f.setBoolean(target, value);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("Could not set boolean field '" + fieldName + "': " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static int getPrivateInt(Object targetOrClassInstance, String fieldName) {
|
||||
try {
|
||||
Class<?> clazz =
|
||||
targetOrClassInstance instanceof Class
|
||||
? (Class<?>) targetOrClassInstance
|
||||
: targetOrClassInstance.getClass();
|
||||
Field f = clazz.getDeclaredField(fieldName);
|
||||
f.setAccessible(true);
|
||||
if (Modifier.isStatic(f.getModifiers())) {
|
||||
return f.getInt(null);
|
||||
} else {
|
||||
return f.getInt(targetOrClassInstance);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("Could not read int field '" + fieldName + "': " + e.getMessage());
|
||||
return -1; // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
@Test
|
||||
@DisplayName("getRemainingAttempts(): returns Integer.MAX_VALUE when disabled or key blank")
|
||||
void getRemainingAttempts_shouldReturnMaxValueWhenDisabledOrBlankKey() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
|
||||
// Ensure blocking disabled
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", false);
|
||||
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
|
||||
|
||||
// Case 1: disabled -> always MAX_VALUE regardless of key
|
||||
int disabledVal = (Integer) method.invoke(svc, "someUser");
|
||||
assertEquals(
|
||||
Integer.MAX_VALUE,
|
||||
disabledVal,
|
||||
"Disabled tracking should return Integer.MAX_VALUE");
|
||||
|
||||
// Enable and verify blank/whitespace/null handling
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
|
||||
int nullKeyVal = (Integer) method.invoke(svc, (Object) null);
|
||||
int blankKeyVal = (Integer) method.invoke(svc, " ");
|
||||
|
||||
assertEquals(
|
||||
Integer.MAX_VALUE,
|
||||
nullKeyVal,
|
||||
"Null key should return Integer.MAX_VALUE per current contract");
|
||||
assertEquals(
|
||||
Integer.MAX_VALUE,
|
||||
blankKeyVal,
|
||||
"Blank key should return Integer.MAX_VALUE per current contract");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getRemainingAttempts(): returns MAX_ATTEMPT when no counter exists for key")
|
||||
void getRemainingAttempts_shouldReturnMaxAttemptWhenNoEntry() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT"); // Reads current policy value
|
||||
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
|
||||
|
||||
int v1 = (Integer) method.invoke(svc, "UserA");
|
||||
int v2 =
|
||||
(Integer)
|
||||
method.invoke(svc, "uSeRa"); // case-insensitive by service (normalization)
|
||||
|
||||
assertEquals(maxAttempt, v1, "Unknown user should start with MAX_ATTEMPT remaining");
|
||||
assertEquals(
|
||||
maxAttempt,
|
||||
v2,
|
||||
"Case-insensitivity should not create separate entries if none exists yet");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getRemainingAttempts(): decreases with attemptCount in cache")
|
||||
void getRemainingAttempts_shouldDecreaseAfterAttemptCount() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
|
||||
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT");
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
// Prepare a counter with attemptCount = 1
|
||||
AttemptCounter c1 = new AttemptCounter();
|
||||
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
|
||||
ac.setAccessible(true);
|
||||
ac.setInt(c1, 1);
|
||||
attemptsCache.put("userx".toLowerCase(Locale.ROOT), c1);
|
||||
|
||||
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
|
||||
int actual = (Integer) method.invoke(svc, "USERX");
|
||||
|
||||
assertEquals(
|
||||
maxAttempt - 1,
|
||||
actual,
|
||||
"Remaining attempts should reflect current attemptCount (case-insensitive lookup)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"getRemainingAttempts(): can become negative when attemptCount > MAX_ATTEMPT (document"
|
||||
+ " current behavior)")
|
||||
void getRemainingAttempts_shouldBecomeNegativeWhenOverLimit_CurrentBehavior() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
|
||||
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT");
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
// Create counter with attemptCount = MAX_ATTEMPT + 5
|
||||
AttemptCounter c = new AttemptCounter();
|
||||
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
|
||||
ac.setAccessible(true);
|
||||
ac.setInt(c, maxAttempt + 5);
|
||||
attemptsCache.put("over".toLowerCase(Locale.ROOT), c);
|
||||
|
||||
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
|
||||
|
||||
int actual = (Integer) method.invoke(svc, "OVER");
|
||||
int expected = maxAttempt - (maxAttempt + 5); // -5
|
||||
|
||||
// Documentation test: current implementation returns a negative number.
|
||||
// If you later clamp to 0, update this assertion accordingly and add a new test.
|
||||
assertEquals(expected, actual, "Current behavior returns negative values without clamping");
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
/swagger-ui/index.html
|
||||
|
||||
Loading…
Reference in New Issue
Block a user