mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa37513395 | ||
|
|
1e5eb466b3 | ||
|
|
fb9cb74f9c | ||
|
|
dcdd517ffd | ||
|
|
d55d8acbfa | ||
|
|
af97e1b27b | ||
|
|
01c908e95d | ||
|
|
0920ea9493 | ||
|
|
96207a7304 | ||
|
|
d93049db9f | ||
|
|
54e839ae65 | ||
|
|
3aca7a26f6 | ||
|
|
5fe7df3933 | ||
|
|
b1e857fd01 | ||
|
|
b92f88361e | ||
|
|
f6124223e4 | ||
|
|
3235645203 | ||
|
|
1f4cc2612d | ||
|
|
e539eb1ab1 | ||
|
|
1bb6961414 | ||
|
|
124817c774 | ||
|
|
7b5a847b58 | ||
|
|
2b3349b24a | ||
|
|
d48bcab412 | ||
|
|
de4d769ec5 | ||
|
|
dde9259ee4 | ||
|
|
12cac259f8 | ||
|
|
4f69946a43 |
@@ -182,7 +182,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -353,7 +353,7 @@ jobs:
|
||||
|
||||
- name: Install Task for Storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Build and deploy Storybook
|
||||
id: storybook
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
engine/uv.lock
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
# Runs once per matrix combination - pick the cheapest leg
|
||||
# (core - no proprietary, no saas) so we don't wait for the
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (needed for playwright's vite preview webServer)
|
||||
@@ -330,8 +330,8 @@ jobs:
|
||||
rm -f /tmp/helpers.sh /tmp/backend.log /tmp/backend.pid
|
||||
continue-on-error: true
|
||||
|
||||
# Multi-node regression: builds + seeds the clustered stack once per Valkey topology and runs behave
|
||||
# features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
|
||||
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
multinode-e2e:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
@@ -341,22 +341,14 @@ jobs:
|
||||
if: >-
|
||||
always() && needs.pick.outputs.is_fork != 'true'
|
||||
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
# Depot is disabled repo-wide, so the depot-* class never gets a runner and the job dies unassigned.
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'ubuntu-24.04-8core' }}
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
# One leg per Valkey topology. fail-fast off so a sentinel break still reports cluster.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
valkey: [standalone, sentinel, cluster]
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
# Unquoted at every use site so it word-splits into repeated -f flags. The topology overlay
|
||||
# must come last: it overrides valkey.command and compose REPLACES command.
|
||||
MN_FILES: -f docker-compose-multinode.yml ${{ matrix.valkey != 'standalone' && format('-f docker-compose-multinode.valkey-{0}.yml', matrix.valkey) || '' }}
|
||||
MN_COMPOSE: docker-compose-multinode.yml
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
@@ -376,11 +368,11 @@ jobs:
|
||||
uv sync --project engine --locked --group cucumber
|
||||
- name: Build the multi-node image
|
||||
working-directory: testing/compose
|
||||
run: docker compose $MN_FILES build
|
||||
run: docker compose -f "$MN_COMPOSE" build
|
||||
- name: Bring up the cluster and wait for both nodes healthy
|
||||
working-directory: testing/compose
|
||||
run: |
|
||||
docker compose $MN_FILES up -d
|
||||
docker compose -f "$MN_COMPOSE" up -d
|
||||
for i in $(seq 1 90); do
|
||||
h1=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null || echo starting)
|
||||
h2=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-2 2>/dev/null || echo starting)
|
||||
@@ -388,11 +380,11 @@ jobs:
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::nodes did not become healthy"
|
||||
docker compose $MN_FILES logs --tail=200 stirling-1 stirling-2
|
||||
docker compose -f "$MN_COMPOSE" logs --tail=200 stirling-1 stirling-2
|
||||
exit 1
|
||||
- name: Seed the cluster (teams, users, S3 connection, policy)
|
||||
working-directory: testing/compose
|
||||
run: docker compose $MN_FILES --profile seed run --rm seed
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed run --rm seed
|
||||
- name: Run multi-node regression (implemented guarantees)
|
||||
working-directory: testing/cucumber
|
||||
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
|
||||
@@ -403,8 +395,8 @@ jobs:
|
||||
- name: Dump node logs on failure
|
||||
if: failure()
|
||||
working-directory: testing/compose
|
||||
run: docker compose $MN_FILES logs --tail=400 stirling-1 stirling-2
|
||||
run: docker compose -f "$MN_COMPOSE" logs --tail=400 stirling-1 stirling-2
|
||||
- name: Tear down
|
||||
if: always()
|
||||
working-directory: testing/compose
|
||||
run: docker compose $MN_FILES --profile seed down -v --remove-orphans
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed down -v --remove-orphans
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Verify generated models are up to date
|
||||
id: models-check
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Check licenses for compatibility
|
||||
run: task backend:licenses:check
|
||||
env:
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: task backend:swagger
|
||||
env:
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# runtime token isn't exposed) since the docker driver can't use it.
|
||||
- name: Set up Docker Buildx
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: a11y gate (changed stories)
|
||||
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
|
||||
- name: Upload scan reports
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Generate frontend license report (Push only)
|
||||
if: github.event_name == 'push'
|
||||
@@ -367,7 +367,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Quality-check frontend
|
||||
id: frontend-check
|
||||
run: task frontend:check:all
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Build JAR
|
||||
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Install all Playwright browsers
|
||||
run: task e2e:install
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: a11y gate (every story, ${{ matrix.theme }})
|
||||
run: task frontend:storybook:a11y:${{ matrix.theme }}
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
engine/uv.lock
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Start the fat image with login and storage enabled
|
||||
run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
engine/uv.lock
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
@@ -85,10 +85,10 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
SWAGGERHUB_USER: "Frooodle"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
uv sync --project engine --locked --group tools
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Build application
|
||||
run: task backend:build
|
||||
env:
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Set base image and platform for this build
|
||||
id: build-params
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
|
||||
Vendored
+1
-1
@@ -42,7 +42,7 @@
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.format.enabled": true,
|
||||
"java.format.settings.profile": "GoogleStyle",
|
||||
"java.format.settings.google.version": "1.28.0",
|
||||
"java.format.settings.google.version": "1.35.0",
|
||||
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
|
||||
// (DE) Aktiviert Kommentare im Java-Format.
|
||||
// (EN) Enables comments in Java formatting.
|
||||
|
||||
@@ -174,7 +174,8 @@ public class EndpointConfiguration {
|
||||
&& disabledGroups.contains(group)
|
||||
&& entry.getValue().contains(endpoint)) {
|
||||
log.debug(
|
||||
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
|
||||
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no"
|
||||
+ " alternatives)",
|
||||
original,
|
||||
group);
|
||||
return false;
|
||||
@@ -333,7 +334,8 @@ public class EndpointConfiguration {
|
||||
String.join(", ", functionallyDisabledEndpoints));
|
||||
} else if (!disabledToolGroups.isEmpty()) {
|
||||
log.info(
|
||||
"No endpoints disabled despite missing tools - fallback implementations available");
|
||||
"No endpoints disabled despite missing tools - fallback implementations"
|
||||
+ " available");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ public class AutoJobAspect {
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
"AutoJobAspect caught exception during job execution:"
|
||||
+ " {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
@@ -165,8 +166,8 @@ public class AutoJobAspect {
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
"AutoJobAspect caught exception during job execution"
|
||||
+ " (attempt {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
@@ -183,7 +184,8 @@ public class AutoJobAspect {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
"Recording retry attempt for job {} in"
|
||||
+ " TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API
|
||||
// access
|
||||
|
||||
@@ -1,87 +1,29 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
|
||||
/** Validates cluster config consistency. All guards are skipped when cluster.enabled=false. */
|
||||
/**
|
||||
* Validates that cluster mode is internally consistent.
|
||||
*
|
||||
* <p>Cluster settings are bound on the central {@link ApplicationProperties} under {@code
|
||||
* cluster.*}; this class reads {@link ApplicationProperties#getCluster()} and runs guards in {@link
|
||||
* PostConstruct}. When {@code cluster.enabled=false} (the default) all checks are skipped so a
|
||||
* single-instance install needs no new config.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class ClusterConfig {
|
||||
|
||||
private static final String MISSING_URL_MESSAGE =
|
||||
"cluster.enabled=true with backplane=valkey requires"
|
||||
+ " cluster.valkey.url to be set (e.g."
|
||||
+ " redis://valkey:6379).";
|
||||
|
||||
private static final String SHARED_SECRET_MESSAGE_SUFFIX =
|
||||
" must be set to the same UUID on every node when cluster.enabled=true (env"
|
||||
+ " AUTOMATICALLYGENERATED_KEY and AUTOMATICALLYGENERATED_UUID). Otherwise every"
|
||||
+ " node mints its own at first boot, so workflow metadata encrypted on one"
|
||||
+ " node cannot be decrypted on another and licence seat signatures do not"
|
||||
+ " verify across nodes. The value must be a UUID; anything else (the shipped"
|
||||
+ " 'example' placeholder included) is replaced by a per-node random UUID.";
|
||||
|
||||
/** Default bean name of stirling.software.SPDF.config.InitialSetup, which lives in :core. */
|
||||
private static final String INITIAL_SETUP_BEAN = "initialSetup";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final String automaticallyGeneratedKey;
|
||||
private final String automaticallyGeneratedUuid;
|
||||
|
||||
// Read from config, not from ApplicationProperties: InitialSetup overwrites the bound values
|
||||
// with per-node UUIDs in its own @PostConstruct, which would defeat the guard below.
|
||||
public ClusterConfig(
|
||||
ApplicationProperties applicationProperties,
|
||||
@Value("${AutomaticallyGenerated.key:}") String automaticallyGeneratedKey,
|
||||
@Value("${AutomaticallyGenerated.UUID:}") String automaticallyGeneratedUuid) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.automaticallyGeneratedKey = automaticallyGeneratedKey;
|
||||
this.automaticallyGeneratedUuid = automaticallyGeneratedUuid;
|
||||
}
|
||||
|
||||
// InitialSetup's @PostConstruct usually wins the race against ours and would persist a
|
||||
// per-node UUID before we can refuse; a BeanFactoryPostProcessor runs before either of them.
|
||||
@Bean
|
||||
static BeanFactoryPostProcessor clusterSharedSecretGuard(Environment environment) {
|
||||
return beanFactory -> {
|
||||
// InitialSetup is the only thing that mints per-node UUIDs; without it there is
|
||||
// nothing to pre-empt, and slice tests that wire ClusterConfig alone stay usable.
|
||||
if (!beanFactory.containsBeanDefinition(INITIAL_SETUP_BEAN)) {
|
||||
return;
|
||||
}
|
||||
validateSharedCryptoMaterial(
|
||||
environment.getProperty("cluster.enabled", Boolean.class, false),
|
||||
environment.getProperty("AutomaticallyGenerated.key", ""),
|
||||
environment.getProperty("AutomaticallyGenerated.UUID", ""));
|
||||
};
|
||||
}
|
||||
|
||||
/** Cluster nodes derive metadata encryption and licence HMAC keys from these two values. */
|
||||
static void validateSharedCryptoMaterial(boolean clusterEnabled, String key, String uuid) {
|
||||
if (!clusterEnabled) {
|
||||
return;
|
||||
}
|
||||
requireSharedUuid("AutomaticallyGenerated.key", key);
|
||||
requireSharedUuid("AutomaticallyGenerated.UUID", uuid);
|
||||
}
|
||||
|
||||
// InitialSetup replaces any non-UUID value, so only a valid UUID survives startup unchanged.
|
||||
private static void requireSharedUuid(String property, String value) {
|
||||
if (!GeneralUtils.isValidUUID(value)) {
|
||||
throw new IllegalStateException(property + SHARED_SECRET_MESSAGE_SUFFIX);
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
@@ -89,27 +31,21 @@ public class ClusterConfig {
|
||||
if (!cluster.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
validateSharedCryptoMaterial(true, automaticallyGeneratedKey, automaticallyGeneratedUuid);
|
||||
String backplane = cluster.getBackplane();
|
||||
if ("valkey".equalsIgnoreCase(backplane)) {
|
||||
// getValkey() re-seeds a null block, so an absent 'valkey:' reads as a missing url.
|
||||
ApplicationProperties.Cluster.Valkey valkey = cluster.getValkey();
|
||||
// resolvedMode() throws on an unknown/ambiguous mode; let it propagate so the
|
||||
// operator sees the property name rather than a later missing-bean error.
|
||||
ApplicationProperties.Cluster.Valkey.ValkeyMode mode = valkey.resolvedMode();
|
||||
validateModeConsistency(valkey, mode);
|
||||
switch (mode) {
|
||||
case STANDALONE -> validateStandalone(valkey);
|
||||
case SENTINEL -> validateSentinel(valkey);
|
||||
case CLUSTER -> validateCluster(valkey);
|
||||
String url = cluster.getValkey() == null ? null : cluster.getValkey().getUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.enabled=true with backplane=valkey requires"
|
||||
+ " cluster.valkey.url to be set (e.g."
|
||||
+ " redis://valkey:6379).");
|
||||
}
|
||||
validateCommon(valkey, mode);
|
||||
} else if ("inprocess".equalsIgnoreCase(backplane)) {
|
||||
// enabled+inprocess only coordinates the local JVM; cross-node lookups will 410.
|
||||
log.warn(
|
||||
"cluster.enabled=true with backplane=inprocess - only the local"
|
||||
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
|
||||
+ " Use backplane=valkey for real multi-node deployments.");
|
||||
"cluster.enabled=true with backplane=inprocess - only the local JVM is"
|
||||
+ " coordinated. Cross-node lookups and the file proxy will fail. Use"
|
||||
+ " backplane=valkey for real multi-node deployments.");
|
||||
} else {
|
||||
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
|
||||
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
|
||||
@@ -119,135 +55,9 @@ public class ClusterConfig {
|
||||
+ "'. Valid values: inprocess | valkey.");
|
||||
}
|
||||
log.info(
|
||||
"Cluster mode enabled (backplane={}, valkeyMode={}, nodeRole={}, nodeId={}).",
|
||||
"Cluster mode enabled (backplane={}, nodeRole={}, nodeId={}).",
|
||||
backplane,
|
||||
"valkey".equalsIgnoreCase(backplane) ? cluster.getValkey().resolvedMode() : "n/a",
|
||||
cluster.resolvedRole(),
|
||||
cluster.resolvedNodeId());
|
||||
}
|
||||
|
||||
private static void validateStandalone(ApplicationProperties.Cluster.Valkey valkey) {
|
||||
String url = valkey.getUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new IllegalStateException(MISSING_URL_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint lists are only read by their own mode. Without this an operator who sets the nodes
|
||||
* but forgets the mode selector silently connects to whatever {@code cluster.valkey.url} holds.
|
||||
*/
|
||||
private static void validateModeConsistency(
|
||||
ApplicationProperties.Cluster.Valkey valkey,
|
||||
ApplicationProperties.Cluster.Valkey.ValkeyMode mode) {
|
||||
var sentinel = valkey.getSentinel();
|
||||
boolean sentinelNodesSet = !sentinel.getNodes().isEmpty();
|
||||
if (sentinelNodesSet && mode != ApplicationProperties.Cluster.Valkey.ValkeyMode.SENTINEL) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.sentinel.nodes is set but the resolved mode is "
|
||||
+ mode
|
||||
+ ", so the sentinel list is ignored and the client would connect to"
|
||||
+ " cluster.valkey.url instead. Set cluster.valkey.sentinel.master (the"
|
||||
+ " monitored primary name, e.g. mymaster) or"
|
||||
+ " cluster.valkey.mode=sentinel.");
|
||||
}
|
||||
if (!valkey.getNodes().isEmpty()
|
||||
&& mode != ApplicationProperties.Cluster.Valkey.ValkeyMode.CLUSTER) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.nodes is set but the resolved mode is "
|
||||
+ mode
|
||||
+ ", so the seed node list is ignored. Set"
|
||||
+ " cluster.valkey.mode=cluster, or remove cluster.valkey.nodes if this"
|
||||
+ " deployment is not a Valkey Cluster.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateSentinel(ApplicationProperties.Cluster.Valkey valkey) {
|
||||
var sentinel = valkey.getSentinel();
|
||||
if (sentinel.getMaster() == null || sentinel.getMaster().isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.mode=sentinel requires cluster.valkey.sentinel.master to be"
|
||||
+ " set (the monitored primary name, e.g. mymaster).");
|
||||
}
|
||||
if (sentinel.getNodes().isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.mode=sentinel requires cluster.valkey.sentinel.nodes to list"
|
||||
+ " at least one sentinel (e.g."
|
||||
+ " sentinel-1:26379,sentinel-2:26379,sentinel-3:26379).");
|
||||
}
|
||||
for (String entry : sentinel.getNodes()) {
|
||||
HostPort.parse(entry, "cluster.valkey.sentinel.nodes", "sentinel-1:26379");
|
||||
}
|
||||
// Sentinel AUTH is separate from data-node AUTH; only warn, some sentinels are open.
|
||||
if ((sentinel.getPassword() == null || sentinel.getPassword().isBlank())
|
||||
&& valkey.getPassword() != null
|
||||
&& !valkey.getPassword().isBlank()) {
|
||||
log.warn(
|
||||
"cluster.valkey.password is set but cluster.valkey.sentinel.password is not."
|
||||
+ " Sentinel connections authenticate separately; if your sentinels"
|
||||
+ " require AUTH, set cluster.valkey.sentinel.password too.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateCluster(ApplicationProperties.Cluster.Valkey valkey) {
|
||||
if (valkey.getNodes().isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.mode=cluster requires cluster.valkey.nodes to list at least"
|
||||
+ " one seed node (e.g. valkey-1:6379,valkey-2:6379,valkey-3:6379).");
|
||||
}
|
||||
for (String entry : valkey.getNodes()) {
|
||||
HostPort.parse(entry, "cluster.valkey.nodes", "valkey-1:6379");
|
||||
}
|
||||
if (valkey.getMaxRedirects() < 1) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.maxRedirects must be >= 1 in cluster mode; got "
|
||||
+ valkey.getMaxRedirects()
|
||||
+ ".");
|
||||
}
|
||||
// Lettuce rejects a non-positive refresh period with an opaque assertion at boot.
|
||||
if (valkey.getTopologyRefreshMs() <= 0) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.topologyRefreshMs must be > 0 in cluster mode; got "
|
||||
+ valkey.getTopologyRefreshMs()
|
||||
+ ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateCommon(
|
||||
ApplicationProperties.Cluster.Valkey valkey,
|
||||
ApplicationProperties.Cluster.Valkey.ValkeyMode mode) {
|
||||
var pool = valkey.getPool();
|
||||
if (pool.isEnabled() && pool.getMaxActive() < 2) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.pool.maxActive must be >= 2 when pooling is enabled (one"
|
||||
+ " connection is permanently held by the shared native connection);"
|
||||
+ " got "
|
||||
+ pool.getMaxActive()
|
||||
+ ".");
|
||||
}
|
||||
if (pool.isEnabled() && pool.getMaxWaitMillis() <= 0) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.pool.maxWaitMillis must be > 0 (a negative value blocks"
|
||||
+ " forever, which defeats cluster.valkey.commandTimeoutMs, and 0"
|
||||
+ " fails the borrow instantly once the pool is exhausted); got "
|
||||
+ pool.getMaxWaitMillis()
|
||||
+ ".");
|
||||
}
|
||||
if (valkey.getCommandTimeoutMs() <= 0) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.commandTimeoutMs must be > 0; got "
|
||||
+ valkey.getCommandTimeoutMs()
|
||||
+ ".");
|
||||
}
|
||||
if (mode != ApplicationProperties.Cluster.Valkey.ValkeyMode.STANDALONE
|
||||
&& valkey.getUrl() != null
|
||||
&& !valkey.getUrl().isBlank()) {
|
||||
log.info(
|
||||
"cluster.valkey.url is ignored in mode={} (endpoints come from {}).",
|
||||
mode,
|
||||
mode == ApplicationProperties.Cluster.Valkey.ValkeyMode.SENTINEL
|
||||
? "cluster.valkey.sentinel.nodes"
|
||||
: "cluster.valkey.nodes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
/**
|
||||
* One {@code host:port} entry from a Valkey cluster or sentinel node list. Shared by config
|
||||
* validation and by the connection builder so the two can never disagree on what a valid entry is.
|
||||
*/
|
||||
public record HostPort(String host, int port) {
|
||||
|
||||
/**
|
||||
* Port is always explicit: a bare host would silently connect somewhere the operator never
|
||||
* named. IPv6 literals must be bracketed ({@code [::1]:6379}).
|
||||
*
|
||||
* @throws IllegalStateException naming {@code propertyName} and echoing {@code entry}
|
||||
*/
|
||||
public static HostPort parse(String entry, String propertyName, String example) {
|
||||
String trimmed = entry == null ? "" : entry.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
propertyName
|
||||
+ " contains a blank entry (expected host:port, e.g. "
|
||||
+ example
|
||||
+ ").");
|
||||
}
|
||||
if (trimmed.charAt(0) == '[') {
|
||||
return parseBracketed(trimmed, entry, propertyName, example);
|
||||
}
|
||||
int colon = trimmed.lastIndexOf(':');
|
||||
if (colon < 0) {
|
||||
throw reject(entry, propertyName, example, "is not host:port");
|
||||
}
|
||||
if (trimmed.indexOf(':') != colon) {
|
||||
throw reject(
|
||||
entry,
|
||||
propertyName,
|
||||
example,
|
||||
"has more than one ':' - bracket IPv6 literals as [::1]:6379");
|
||||
}
|
||||
return build(
|
||||
trimmed.substring(0, colon),
|
||||
trimmed.substring(colon + 1),
|
||||
entry,
|
||||
propertyName,
|
||||
example);
|
||||
}
|
||||
|
||||
/** Handles {@code [::1]:6379}; the returned host keeps no brackets. */
|
||||
private static HostPort parseBracketed(
|
||||
String trimmed, String entry, String propertyName, String example) {
|
||||
int close = trimmed.indexOf(']');
|
||||
if (close < 0) {
|
||||
throw reject(entry, propertyName, example, "has an unclosed '['");
|
||||
}
|
||||
String rest = trimmed.substring(close + 1);
|
||||
if (rest.isEmpty() || rest.charAt(0) != ':') {
|
||||
throw reject(entry, propertyName, example, "is not host:port");
|
||||
}
|
||||
return build(trimmed.substring(1, close), rest.substring(1), entry, propertyName, example);
|
||||
}
|
||||
|
||||
private static HostPort build(
|
||||
String host, String rawPort, String entry, String propertyName, String example) {
|
||||
int port;
|
||||
try {
|
||||
port = Integer.parseInt(rawPort);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new IllegalStateException(
|
||||
message(entry, propertyName, example, "is not host:port"), ex);
|
||||
}
|
||||
if (host.isBlank() || port < 1 || port > 65535) {
|
||||
throw reject(entry, propertyName, example, "is not host:port");
|
||||
}
|
||||
return new HostPort(host, port);
|
||||
}
|
||||
|
||||
private static IllegalStateException reject(
|
||||
String entry, String propertyName, String example, String problem) {
|
||||
return new IllegalStateException(message(entry, propertyName, example, problem));
|
||||
}
|
||||
|
||||
private static String message(
|
||||
String entry, String propertyName, String example, String problem) {
|
||||
return propertyName + " entry '" + entry + "' " + problem + " (e.g. " + example + ").";
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -230,12 +230,14 @@ public class RuntimePathConfig {
|
||||
// Check if one path is a parent of the other
|
||||
if (path1.startsWith(path2)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause"
|
||||
+ " duplicate processing",
|
||||
path1,
|
||||
path2);
|
||||
} else if (path2.startsWith(path1)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause"
|
||||
+ " duplicate processing",
|
||||
path2,
|
||||
path1);
|
||||
}
|
||||
@@ -253,21 +255,24 @@ public class RuntimePathConfig {
|
||||
// Check if watched folder is same as finished folder
|
||||
if (watchedPath.equals(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!",
|
||||
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' -"
|
||||
+ " this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
// Check if watched folder contains finished folder
|
||||
else if (finishedPath.startsWith(watchedPath)) {
|
||||
log.warn(
|
||||
"Finished folder '{}' is nested inside watched folder '{}' - this may cause issues",
|
||||
"Finished folder '{}' is nested inside watched folder '{}' - this may"
|
||||
+ " cause issues",
|
||||
finishedPath,
|
||||
watchedPath);
|
||||
}
|
||||
// Check if finished folder contains watched folder
|
||||
else if (watchedPath.startsWith(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!",
|
||||
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' -"
|
||||
+ " this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
@@ -295,15 +300,17 @@ public class RuntimePathConfig {
|
||||
// Warn if manual endpoint count doesn't match sessionLimit
|
||||
if (configured.size() != sessionLimit) {
|
||||
log.warn(
|
||||
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit ({}). "
|
||||
+ "Concurrency will be limited by endpoint count, not sessionLimit.",
|
||||
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit"
|
||||
+ " ({}). Concurrency will be limited by endpoint count, not"
|
||||
+ " sessionLimit.",
|
||||
configured.size(),
|
||||
sessionLimit);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
log.warn(
|
||||
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to 127.0.0.1:2003.");
|
||||
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to"
|
||||
+ " 127.0.0.1:2003.");
|
||||
return Collections.singletonList(
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
|
||||
}
|
||||
|
||||
+17
-228
@@ -5,7 +5,6 @@ import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
@@ -145,7 +144,8 @@ public class ApplicationProperties {
|
||||
sizeInMB);
|
||||
} else {
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
|
||||
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999),"
|
||||
+ " ignoring",
|
||||
sizeInMB);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
@@ -538,7 +538,11 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cluster backplane config, bound under the top-level {@code cluster.*} prefix. */
|
||||
/**
|
||||
* Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix
|
||||
* (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to
|
||||
* off; when off the in-process backplane is wired and no other cluster keys are required.
|
||||
*/
|
||||
@Data
|
||||
public static class Cluster {
|
||||
|
||||
@@ -549,24 +553,20 @@ public class ApplicationProperties {
|
||||
private String backplane = "inprocess";
|
||||
|
||||
/**
|
||||
* {@code local} | {@code s3}. Distinct from {@code storage.provider} (persistent uploads);
|
||||
* shares the {@code storage.s3.*} credentials when set to {@code s3}.
|
||||
* Transient cluster job-artifact store selector. Valid values: {@code local} | {@code s3}.
|
||||
*
|
||||
* <p>This is distinct from {@code storage.provider}, which selects the backend for
|
||||
* persistent user-uploaded files. The two switches exist because the user-facing storage
|
||||
* feature is optional ({@code storage.enabled=false} is common) but every multi-node
|
||||
* cluster still needs a shared artifact store to serve cross-node downloads. Both
|
||||
* implementations share credentials from {@code storage.s3.*} when set to {@code s3}.
|
||||
*/
|
||||
private String artifactStore = "local";
|
||||
|
||||
private Valkey valkey = new Valkey();
|
||||
private Node node = new Node();
|
||||
|
||||
// A bare 'valkey:' key in settings.yml binds null; re-seed rather than hand one back.
|
||||
public Valkey getValkey() {
|
||||
if (valkey == null) {
|
||||
valkey = new Valkey();
|
||||
}
|
||||
return valkey;
|
||||
}
|
||||
|
||||
private transient String cachedNodeId;
|
||||
private transient String cachedNodeName;
|
||||
|
||||
public NodeRole resolvedRole() {
|
||||
if (node == null || node.getRole() == null) {
|
||||
@@ -590,37 +590,6 @@ public class ApplicationProperties {
|
||||
return cachedNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-node label for CLIENT SETNAME: {@code cluster.node.id}, else hostname, else
|
||||
* {@link #resolvedNodeId()}. The first two survive a restart; the UUID fallback does not.
|
||||
*/
|
||||
public synchronized String resolvedNodeName() {
|
||||
if (node != null && node.getId() != null && !node.getId().isBlank()) {
|
||||
return node.getId();
|
||||
}
|
||||
if (cachedNodeName == null) {
|
||||
cachedNodeName = localHostname();
|
||||
}
|
||||
return cachedNodeName != null ? cachedNodeName : resolvedNodeId();
|
||||
}
|
||||
|
||||
// Hostname resolution depends on DNS and can throw; a missing name must never fail startup.
|
||||
private static String localHostname() {
|
||||
String env = java.lang.System.getenv("HOSTNAME");
|
||||
if (env == null || env.isBlank()) {
|
||||
env = java.lang.System.getenv("COMPUTERNAME");
|
||||
}
|
||||
if (env != null && !env.isBlank()) {
|
||||
return env.trim();
|
||||
}
|
||||
try {
|
||||
String host = InetAddress.getLocalHost().getHostName();
|
||||
return host != null && !host.isBlank() ? host.trim() : null;
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum NodeRole {
|
||||
WEB,
|
||||
WORKER,
|
||||
@@ -630,201 +599,21 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Valkey {
|
||||
/**
|
||||
* {@code redis://} or {@code rediss://} URL; read ONLY in standalone mode. Excluded
|
||||
* from toString because it can carry userinfo credentials.
|
||||
* {@code redis://host:6379} or {@code rediss://...} for TLS. Required when cluster mode
|
||||
* is on and backplane is valkey.
|
||||
*/
|
||||
@ToString.Exclude private String url = "";
|
||||
private String url = "";
|
||||
|
||||
/**
|
||||
* {@code standalone} | {@code sentinel} | {@code cluster}; blank auto-resolves (see
|
||||
* {@link #resolvedMode()}). {@code url} is read only in standalone mode.
|
||||
*/
|
||||
private String mode = "";
|
||||
|
||||
/** Data-node username; overrides any userinfo in {@link #url}. */
|
||||
private String username = "";
|
||||
|
||||
/** Data-node password; overrides any userinfo in {@link #url}. */
|
||||
@ToString.Exclude private String password = "";
|
||||
|
||||
/** Valkey Cluster seed nodes as {@code host:port}. Read only when mode is cluster. */
|
||||
private List<String> nodes = new ArrayList<>();
|
||||
|
||||
/** Max MOVED/ASK redirects the cluster client follows before failing a command. */
|
||||
private int maxRedirects = 3;
|
||||
|
||||
/**
|
||||
* Periodic cluster topology refresh interval in milliseconds. Adaptive refresh on
|
||||
* MOVED/ASK/reconnect is always on; this is the backstop when no redirect is seen.
|
||||
*/
|
||||
private long topologyRefreshMs = 30000;
|
||||
|
||||
/**
|
||||
* CLIENT SETNAME applied to every connection so Valkey monitoring can attribute load to
|
||||
* a node. Blank (default) = {@code stirling-} + {@code Cluster.resolvedNodeName()}.
|
||||
*/
|
||||
private String clientName = "";
|
||||
|
||||
/**
|
||||
* Per-command timeout in milliseconds. Bounds every backplane call so a slow or
|
||||
* partitioned Valkey cannot stall request threads.
|
||||
*/
|
||||
private long commandTimeoutMs = 2000;
|
||||
|
||||
private Sentinel sentinel = new Sentinel();
|
||||
private Tls tls = new Tls();
|
||||
private Pool pool = new Pool();
|
||||
|
||||
// A bare 'sentinel:'/'tls:'/'pool:'/'nodes:' key in settings.yml binds null. Re-seed
|
||||
// the default here so no call site has to guard, and none can forget to.
|
||||
public Sentinel getSentinel() {
|
||||
if (sentinel == null) {
|
||||
sentinel = new Sentinel();
|
||||
}
|
||||
return sentinel;
|
||||
}
|
||||
|
||||
public Tls getTls() {
|
||||
if (tls == null) {
|
||||
tls = new Tls();
|
||||
}
|
||||
return tls;
|
||||
}
|
||||
|
||||
public Pool getPool() {
|
||||
if (pool == null) {
|
||||
pool = new Pool();
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
public List<String> getNodes() {
|
||||
if (nodes == null) {
|
||||
nodes = new ArrayList<>();
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit {@link #mode} wins; blank infers SENTINEL from sentinel.master, CLUSTER from
|
||||
* nodes, else STANDALONE, and throws when both are set (ambiguous).
|
||||
*/
|
||||
public ValkeyMode resolvedMode() {
|
||||
if (mode != null && !mode.isBlank()) {
|
||||
try {
|
||||
return ValkeyMode.valueOf(mode.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.mode has unknown value '"
|
||||
+ mode
|
||||
+ "'. Valid values: standalone | sentinel | cluster.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
String master = getSentinel().getMaster();
|
||||
boolean sentinelConfigured = master != null && !master.isBlank();
|
||||
boolean clusterConfigured = !getNodes().isEmpty();
|
||||
if (sentinelConfigured && clusterConfigured) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.valkey.mode is not set but both"
|
||||
+ " cluster.valkey.sentinel.master and cluster.valkey.nodes are"
|
||||
+ " configured. Set cluster.valkey.mode explicitly to"
|
||||
+ " 'sentinel' or 'cluster'.");
|
||||
}
|
||||
if (sentinelConfigured) {
|
||||
return ValkeyMode.SENTINEL;
|
||||
}
|
||||
if (clusterConfigured) {
|
||||
return ValkeyMode.CLUSTER;
|
||||
}
|
||||
return ValkeyMode.STANDALONE;
|
||||
}
|
||||
|
||||
public enum ValkeyMode {
|
||||
STANDALONE,
|
||||
SENTINEL,
|
||||
CLUSTER
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Sentinel {
|
||||
/** Monitored primary name, i.e. the name in {@code sentinel monitor <name> ...}. */
|
||||
private String master = "";
|
||||
|
||||
/** Sentinel endpoints as {@code host:port}; sentinel's default port is 26379. */
|
||||
private List<String> nodes = new ArrayList<>();
|
||||
|
||||
/** Username for the SENTINEL connections. Separate from the data-node username. */
|
||||
private String username = "";
|
||||
|
||||
/**
|
||||
* Password for the SENTINEL connections. Separate from the data-node password -
|
||||
* setting only {@code cluster.valkey.password} does NOT authenticate to sentinels.
|
||||
*/
|
||||
@ToString.Exclude private String password = "";
|
||||
|
||||
// A bare 'nodes:' key binds null.
|
||||
public List<String> getNodes() {
|
||||
if (nodes == null) {
|
||||
nodes = new ArrayList<>();
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Tls {
|
||||
/**
|
||||
* Force TLS. Required in sentinel/cluster mode, which have no {@code rediss://} URL
|
||||
* to carry the scheme; in standalone it is OR-ed with the scheme, never overridden.
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* When {@code true}, skip Valkey/Redis TLS certificate verification (dev/test
|
||||
* only). Leave {@code false} in production.
|
||||
*/
|
||||
private boolean skipCertVerification = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Pool {
|
||||
/**
|
||||
* Pooling for dedicated connections. Backplane traffic multiplexes over the shared
|
||||
* native connection, so the pool backs only that one connection today.
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Max pooled connections; at least 2, one is held by the shared native connection.
|
||||
* Headroom for a future dedicated path - raising it changes no current throughput.
|
||||
*/
|
||||
private int maxActive = 16;
|
||||
|
||||
/** Max idle connections kept in the pool. Keep equal to maxActive. */
|
||||
private int maxIdle = 16;
|
||||
|
||||
/**
|
||||
* Connections kept warm. Default 0: backplane traffic runs on the shared native
|
||||
* connection, so warm pooled sockets would idle unused on every node.
|
||||
*/
|
||||
private int minIdle = 0;
|
||||
|
||||
/**
|
||||
* Max wait for a pooled connection. Never 0/negative: negative blocks forever and
|
||||
* defeats commandTimeoutMs, 0 fails the borrow instantly once the pool is drained.
|
||||
*/
|
||||
private long maxWaitMillis = 2000;
|
||||
|
||||
/** Idle-evictor interval. minIdle is only honoured while the evictor runs. */
|
||||
private long timeBetweenEvictionRunsMillis = 30000;
|
||||
|
||||
/**
|
||||
* Validate on borrow. Cheap (no round trip - Lettuce checks isOpen) but it only
|
||||
* rejects explicitly closed connections; a disconnected, reconnecting one is open.
|
||||
*/
|
||||
private boolean testOnBorrow = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -24,7 +24,8 @@ public class PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
"File ID for server-side files (can be used instead of fileInput if job was"
|
||||
+ " previously done on file in async mode)")
|
||||
private String fileId;
|
||||
|
||||
@AssertTrue(message = "Either fileInput or fileId must be provided")
|
||||
|
||||
@@ -209,7 +209,8 @@ public class ResourceMonitor {
|
||||
return (double) m.invoke(osMXBean);
|
||||
} catch (Exception e2) {
|
||||
log.trace(
|
||||
"Could not get CPU load through reflection, assuming moderate load (0.5)");
|
||||
"Could not get CPU load through reflection, assuming moderate load"
|
||||
+ " (0.5)");
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -167,7 +167,8 @@ public class TempFileCleanupService {
|
||||
|| unregisteredDeletedCount > 0
|
||||
|| directoriesDeletedCount > 0) {
|
||||
log.info(
|
||||
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
|
||||
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered"
|
||||
+ " files, {} directories",
|
||||
registeredDeletedCount,
|
||||
unregisteredDeletedCount,
|
||||
directoriesDeletedCount);
|
||||
@@ -252,7 +253,8 @@ public class TempFileCleanupService {
|
||||
dirDeletedCount.incrementAndGet();
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"Deleted temp file during {} cleanup: {}",
|
||||
"Deleted temp file during {} cleanup:"
|
||||
+ " {}",
|
||||
phase,
|
||||
path);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ public class AttachmentUtils {
|
||||
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
|
||||
|
||||
log.info(
|
||||
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
|
||||
"Set PDF PageMode to UseAttachments to automatically show attachments"
|
||||
+ " pane");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to set catalog viewer preferences for attachments", e);
|
||||
|
||||
@@ -342,26 +342,26 @@ public class EmlProcessingUtils {
|
||||
|
||||
private String getFallbackStyles() {
|
||||
return """
|
||||
/* Minimal fallback - main CSS resource failed to load */
|
||||
body {
|
||||
font-family: var(--font-family, Helvetica, sans-serif);
|
||||
font-size: var(--font-size, 12px);
|
||||
line-height: var(--line-height, 1.4);
|
||||
color: var(--text-color, #202124);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.email-container { max-width: 100%; }
|
||||
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
|
||||
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
|
||||
.email-meta { font-size: 12px; color: #666; }
|
||||
.email-body { line-height: 1.6; }
|
||||
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
|
||||
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
||||
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
""";
|
||||
/* Minimal fallback - main CSS resource failed to load */
|
||||
body {
|
||||
font-family: var(--font-family, Helvetica, sans-serif);
|
||||
font-size: var(--font-size, 12px);
|
||||
line-height: var(--line-height, 1.4);
|
||||
color: var(--text-color, #202124);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.email-container { max-width: 100%; }
|
||||
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
|
||||
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
|
||||
.email-meta { font-size: 12px; color: #666; }
|
||||
.email-body { line-height: 1.6; }
|
||||
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
|
||||
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
||||
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
""";
|
||||
}
|
||||
|
||||
private void appendAttachmentsSection(
|
||||
|
||||
@@ -290,7 +290,8 @@ public class ExceptionUtils {
|
||||
// Additional safety check: warn about very large images (> 1GB estimated)
|
||||
if (estimatedBytes > 1024L * 1024 * 1024) {
|
||||
log.warn(
|
||||
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This may cause memory issues.",
|
||||
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This"
|
||||
+ " may cause memory issues.",
|
||||
pageNumber,
|
||||
widthInPixels,
|
||||
heightInPixels,
|
||||
@@ -394,7 +395,8 @@ public class ExceptionUtils {
|
||||
message = getMessage(contextKey, defaultMsg, context);
|
||||
} else {
|
||||
message =
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
|
||||
+ " feature first to fix the file before proceeding with this operation.";
|
||||
}
|
||||
|
||||
return new PdfCorruptedException(message, cause, ErrorCode.PDF_CORRUPTED.getCode());
|
||||
@@ -1119,19 +1121,25 @@ public class ExceptionUtils {
|
||||
PDF_CORRUPTED(
|
||||
"E001",
|
||||
"error.pdfCorrupted",
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation."),
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
|
||||
+ " feature first to fix the file before proceeding with this operation."),
|
||||
PDF_MULTIPLE_CORRUPTED(
|
||||
"E002",
|
||||
"error.multiplePdfCorrupted",
|
||||
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them."),
|
||||
"One or more PDF files appear to be corrupted or damaged. Please try using the"
|
||||
+ " 'Repair PDF' feature on each file first before attempting to merge them."),
|
||||
PDF_ENCRYPTION(
|
||||
"E003",
|
||||
"error.pdfEncryption",
|
||||
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy."),
|
||||
"The PDF appears to have corrupted encryption data. This can happen when the PDF"
|
||||
+ " was created with incompatible encryption methods. Please try using the"
|
||||
+ " 'Repair PDF' feature first, or contact the document creator for a new"
|
||||
+ " copy."),
|
||||
PDF_PASSWORD(
|
||||
"E004",
|
||||
"error.pdfPassword",
|
||||
"The PDF Document is passworded and either the password was not provided or was incorrect"),
|
||||
"The PDF Document is passworded and either the password was not provided or was"
|
||||
+ " incorrect"),
|
||||
PDF_NO_PAGES("E005", "error.pdfNoPages", "PDF file contains no pages"),
|
||||
PDF_NOT_PDF("E006", "error.notPdfFile", "File must be in PDF format"),
|
||||
|
||||
@@ -1139,20 +1147,25 @@ public class ExceptionUtils {
|
||||
CBR_INVALID_FORMAT(
|
||||
"E010",
|
||||
"error.cbrInvalidFormat",
|
||||
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR archive."),
|
||||
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an"
|
||||
+ " unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR"
|
||||
+ " archive."),
|
||||
CBR_NO_IMAGES(
|
||||
"E012",
|
||||
"error.cbrNoImages",
|
||||
"No valid images found in the CBR file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
|
||||
"No valid images found in the CBR file. The archive may be empty, or all images may"
|
||||
+ " be corrupted or in unsupported formats."),
|
||||
CBR_NOT_CBR("E014", "error.notCbrFile", "File must be a CBR or RAR archive"),
|
||||
CBZ_INVALID_FORMAT(
|
||||
"E015",
|
||||
"error.cbzInvalidFormat",
|
||||
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not be a valid ZIP archive."),
|
||||
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not"
|
||||
+ " be a valid ZIP archive."),
|
||||
CBZ_NO_IMAGES(
|
||||
"E016",
|
||||
"error.cbzNoImages",
|
||||
"No valid images found in the CBZ file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
|
||||
"No valid images found in the CBZ file. The archive may be empty, or all images may"
|
||||
+ " be corrupted or in unsupported formats."),
|
||||
CBZ_NOT_CBZ("E018", "error.notCbzFile", "File must be a CBZ or ZIP archive"),
|
||||
|
||||
// EML errors
|
||||
@@ -1205,7 +1218,8 @@ public class ExceptionUtils {
|
||||
FFMPEG_REQUIRED(
|
||||
"E063",
|
||||
"error.ffmpegRequired",
|
||||
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is available on the system PATH."),
|
||||
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is"
|
||||
+ " available on the system PATH."),
|
||||
|
||||
// Validation errors
|
||||
INVALID_ARGUMENT("E070", "error.invalidArgument", "Invalid argument ''{0}'': {1}"),
|
||||
@@ -1221,7 +1235,10 @@ public class ExceptionUtils {
|
||||
OUT_OF_MEMORY_DPI(
|
||||
"E081",
|
||||
"error.outOfMemoryDpi",
|
||||
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.");
|
||||
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI."
|
||||
+ " This can occur when the resulting image exceeds Java's array/memory limits"
|
||||
+ " (e.g., NegativeArraySizeException). Please use a lower DPI value"
|
||||
+ " (recommended: 150 or less) or process the document in smaller chunks.");
|
||||
|
||||
private final String code;
|
||||
private final String messageKey;
|
||||
|
||||
@@ -456,7 +456,8 @@ public class FormUtils {
|
||||
|| !Float.isFinite(finalW)
|
||||
|| !Float.isFinite(finalH)) {
|
||||
log.warn(
|
||||
"Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}",
|
||||
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={},"
|
||||
+ " h={}",
|
||||
field.getFullyQualifiedName(),
|
||||
pageIndex,
|
||||
finalX,
|
||||
|
||||
@@ -392,9 +392,9 @@ public class PdfUtils {
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting"
|
||||
+ " image would exceed Java's maximum array size. Please use a"
|
||||
+ " lower DPI value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -253,7 +253,8 @@ public class ProcessExecutor {
|
||||
}
|
||||
} catch (InterruptedIOException e) {
|
||||
log.warn(
|
||||
"Error reader thread was interrupted due to timeout.");
|
||||
"Error reader thread was interrupted due to"
|
||||
+ " timeout.");
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
}
|
||||
@@ -278,7 +279,8 @@ public class ProcessExecutor {
|
||||
}
|
||||
} catch (InterruptedIOException e) {
|
||||
log.warn(
|
||||
"Error reader thread was interrupted due to timeout.");
|
||||
"Error reader thread was interrupted due to"
|
||||
+ " timeout.");
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
}
|
||||
|
||||
+4
-467
@@ -1,43 +1,21 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster.Valkey;
|
||||
|
||||
class ClusterConfigValidationTest {
|
||||
|
||||
/** Stand-ins for the shared AutomaticallyGenerated values every node must be given. */
|
||||
private static final String SHARED_KEY = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private static final String SHARED_UUID = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static ClusterConfig config(ApplicationProperties props) {
|
||||
return new ClusterConfig(props, SHARED_KEY, SHARED_UUID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationPassesWhenDisabled() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
ClusterConfig config = config(props);
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
@@ -47,19 +25,18 @@ class ClusterConfigValidationTest {
|
||||
Cluster cluster = props.getCluster();
|
||||
cluster.setEnabled(true);
|
||||
cluster.setBackplane("valkey");
|
||||
ClusterConfig config = config(props);
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertThrows(IllegalStateException.class, () -> invokeValidate(config));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backward compatibility: url only, no new keys, still validates")
|
||||
void validationPassesWhenValkeyEnabledWithUrl() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
Cluster cluster = props.getCluster();
|
||||
cluster.setEnabled(true);
|
||||
cluster.setBackplane("valkey");
|
||||
cluster.getValkey().setUrl("redis://localhost:6379");
|
||||
ClusterConfig config = config(props);
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
@@ -69,450 +46,10 @@ class ClusterConfigValidationTest {
|
||||
Cluster cluster = props.getCluster();
|
||||
cluster.setEnabled(true);
|
||||
cluster.setBackplane("inprocess");
|
||||
ClusterConfig config = config(props);
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
/** Rules V1-V13 of the topology spec. Each asserts the exact operator-facing wording. */
|
||||
@Nested
|
||||
@DisplayName("valkey topology + pool validation")
|
||||
class TopologyValidation {
|
||||
|
||||
private ApplicationProperties props;
|
||||
|
||||
@Test
|
||||
@DisplayName("V1: unknown mode names the bad value and the valid set")
|
||||
void unknownModeRejected() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("clustr");
|
||||
assertMessage("clustr", "standalone | sentinel | cluster");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V2: blank mode with both sentinel.master and nodes is ambiguous")
|
||||
void ambiguousModeRejected() {
|
||||
Valkey v = valkeyProps();
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
v.setNodes(List.of("valkey-1:6379"));
|
||||
assertMessage("Set cluster.valkey.mode explicitly");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V3: standalone without a url keeps the original message (test contract)")
|
||||
void standaloneWithoutUrlRejected() {
|
||||
valkeyProps();
|
||||
assertMessage(
|
||||
"cluster.enabled=true with backplane=valkey requires",
|
||||
"cluster.valkey.url to be set",
|
||||
"redis://valkey:6379");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V4: sentinel mode without a master name is rejected")
|
||||
void sentinelWithoutMasterRejected() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("sentinel");
|
||||
v.getSentinel().setNodes(List.of("sentinel-1:26379"));
|
||||
assertMessage("cluster.valkey.sentinel.master to be set", "mymaster");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V5: sentinel mode without any sentinel endpoints is rejected")
|
||||
void sentinelWithoutNodesRejected() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("sentinel");
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
assertMessage("cluster.valkey.sentinel.nodes to list at least one sentinel");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V6: cluster mode without any seed nodes is rejected")
|
||||
void clusterWithoutNodesRejected() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("cluster");
|
||||
assertMessage("cluster.valkey.nodes to list at least one seed node");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V7: a nodes entry that is not host:port is rejected, echoing the entry")
|
||||
void clusterNodeEntryMustBeHostPort() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("cluster");
|
||||
v.setNodes(List.of("valkey-1:6379", "valkey-2"));
|
||||
assertMessage("cluster.valkey.nodes entry", "valkey-2", "is not host:port");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"valkey-1:notaport", "valkey-1:70000", ":6379", "valkey-1:"})
|
||||
@DisplayName("V7: a non-numeric or out-of-range port is rejected")
|
||||
void clusterNodePortMustBeNumericAndInRange(String entry) {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("cluster");
|
||||
v.setNodes(List.of(entry));
|
||||
assertMessage(entry, "is not host:port");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V7: a sentinel.nodes entry that is not host:port is rejected")
|
||||
void sentinelNodeEntryMustBeHostPort() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("sentinel");
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
v.getSentinel().setNodes(List.of("sentinel-1"));
|
||||
assertMessage("cluster.valkey.sentinel.nodes entry", "sentinel-1", "is not host:port");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V8: pool.maxActive < 2 is rejected (the shared connection holds one)")
|
||||
void poolMaxActiveMustLeaveRoomForSharedConnection() {
|
||||
Valkey v = validStandalone();
|
||||
v.getPool().setMaxActive(1);
|
||||
assertMessage("cluster.valkey.pool.maxActive must be >= 2", "got 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V8: maxActive < 2 is allowed when pooling is off (the check is pool-scoped)")
|
||||
void poolMaxActiveIgnoredWhenPoolingDisabled() {
|
||||
Valkey v = validStandalone();
|
||||
v.getPool().setEnabled(false);
|
||||
v.getPool().setMaxActive(1);
|
||||
assertPasses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V9: pool.maxWaitMillis <= 0 is rejected (0 fails borrows, negative blocks)")
|
||||
void poolMaxWaitMustBePositive() {
|
||||
Valkey v = validStandalone();
|
||||
v.getPool().setMaxWaitMillis(0);
|
||||
assertMessage("cluster.valkey.pool.maxWaitMillis must be > 0", "got 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V10: commandTimeoutMs <= 0 is rejected")
|
||||
void commandTimeoutMustBePositive() {
|
||||
Valkey v = validStandalone();
|
||||
v.setCommandTimeoutMs(0);
|
||||
assertMessage("cluster.valkey.commandTimeoutMs must be > 0", "got 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V11: maxRedirects < 1 is rejected in cluster mode")
|
||||
void maxRedirectsMustBeAtLeastOneInClusterMode() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("cluster");
|
||||
v.setNodes(List.of("valkey-1:6379"));
|
||||
v.setMaxRedirects(0);
|
||||
assertMessage("cluster.valkey.maxRedirects must be >= 1 in cluster mode", "got 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V11: maxRedirects is not checked outside cluster mode")
|
||||
void maxRedirectsIgnoredOutsideClusterMode() {
|
||||
Valkey v = validStandalone();
|
||||
v.setMaxRedirects(0);
|
||||
assertPasses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V12: a url set alongside sentinel mode is accepted (ignored, not fatal)")
|
||||
void urlAlongsideSentinelIsNotFatal() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setUrl("redis://valkey:6379");
|
||||
v.setMode("sentinel");
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
v.getSentinel().setNodes(List.of("sentinel-1:26379", "sentinel-2:26379"));
|
||||
assertPasses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V13: data password without a sentinel password warns but still boots")
|
||||
void sentinelPasswordMismatchIsOnlyAWarning() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("sentinel");
|
||||
v.setPassword("data-pw");
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
v.getSentinel().setNodes(List.of("sentinel-1:26379"));
|
||||
assertPasses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a fully configured sentinel topology validates")
|
||||
void validSentinelTopologyPasses() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("sentinel");
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
v.getSentinel()
|
||||
.setNodes(List.of("sentinel-1:26379", "sentinel-2:26379", "sentinel-3:26379"));
|
||||
v.getSentinel().setPassword("sentinel-pw");
|
||||
assertPasses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a fully configured cluster topology validates")
|
||||
void validClusterTopologyPasses() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setMode("cluster");
|
||||
v.setNodes(List.of("valkey-1:6379", "valkey-2:6379", "valkey-3:6379"));
|
||||
assertPasses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cluster.enabled=false skips every new check, garbage mode included")
|
||||
void disabledClusterSkipsTopologyChecks() {
|
||||
valkeyProps().setMode("not-a-mode");
|
||||
props.getCluster().setEnabled(false);
|
||||
assertPasses();
|
||||
}
|
||||
|
||||
/** enabled + backplane=valkey with no topology keys set yet. */
|
||||
private Valkey valkeyProps() {
|
||||
props = new ApplicationProperties();
|
||||
props.getCluster().setEnabled(true);
|
||||
props.getCluster().setBackplane("valkey");
|
||||
return props.getCluster().getValkey();
|
||||
}
|
||||
|
||||
/** The minimal legacy configuration: standalone via url only. */
|
||||
private Valkey validStandalone() {
|
||||
Valkey v = valkeyProps();
|
||||
v.setUrl("redis://valkey:6379");
|
||||
return v;
|
||||
}
|
||||
|
||||
private void assertPasses() {
|
||||
ClusterConfig config = config(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
private void assertMessage(String... expectedSubstrings) {
|
||||
ClusterConfig config = config(props);
|
||||
IllegalStateException ex =
|
||||
assertThrows(IllegalStateException.class, () -> invokeValidate(config));
|
||||
for (String expected : expectedSubstrings) {
|
||||
assertTrue(
|
||||
ex.getMessage().contains(expected),
|
||||
"message must contain '" + expected + "'; got: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bare 'valkey:'/'sentinel:'/'pool:' key binds null; validation must not NPE on it. */
|
||||
@Nested
|
||||
@DisplayName("null config blocks give the operator message, never an NPE")
|
||||
class BareYamlKeys {
|
||||
|
||||
private ApplicationProperties props;
|
||||
|
||||
@Test
|
||||
void nullValkeyBlockReportsTheMissingUrl() {
|
||||
enabled();
|
||||
props.getCluster().setValkey(null);
|
||||
assertMessage("cluster.valkey.url");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullSentinelBlockReportsTheMissingMaster() {
|
||||
Valkey v = enabled();
|
||||
v.setMode("sentinel");
|
||||
v.setSentinel(null);
|
||||
assertMessage("cluster.valkey.sentinel.master");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullSentinelNodesReportsTheMissingNodeList() {
|
||||
Valkey v = enabled();
|
||||
v.setMode("sentinel");
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
v.getSentinel().setNodes(null);
|
||||
assertMessage("cluster.valkey.sentinel.nodes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullNodesBlockReportsTheMissingSeedList() {
|
||||
Valkey v = enabled();
|
||||
v.setMode("cluster");
|
||||
v.setNodes(null);
|
||||
assertMessage("cluster.valkey.nodes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null pool and tls blocks fall back to defaults and validate")
|
||||
void nullPoolAndTlsBlocksValidate() {
|
||||
Valkey v = enabled();
|
||||
v.setUrl("redis://valkey:6379");
|
||||
v.setPool(null);
|
||||
v.setTls(null);
|
||||
ClusterConfig config = config(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
private Valkey enabled() {
|
||||
props = new ApplicationProperties();
|
||||
props.getCluster().setEnabled(true);
|
||||
props.getCluster().setBackplane("valkey");
|
||||
return props.getCluster().getValkey();
|
||||
}
|
||||
|
||||
private void assertMessage(String expected) {
|
||||
ClusterConfig config = config(props);
|
||||
IllegalStateException ex =
|
||||
assertThrows(IllegalStateException.class, () -> invokeValidate(config));
|
||||
assertTrue(
|
||||
ex.getMessage().contains(expected),
|
||||
"message must contain '" + expected + "'; got: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** AutomaticallyGenerated.key/.UUID feed metadata encryption and licence seat HMACs. */
|
||||
@Nested
|
||||
@DisplayName("shared AutomaticallyGenerated key/UUID guard")
|
||||
class SharedCryptoMaterial {
|
||||
|
||||
private ApplicationProperties props;
|
||||
|
||||
@Test
|
||||
@DisplayName("an unset key is rejected and names the env var to set")
|
||||
void missingKeyRejected() {
|
||||
enabled("valkey");
|
||||
assertMessage(
|
||||
new ClusterConfig(props, "", SHARED_UUID),
|
||||
"AutomaticallyGenerated.key",
|
||||
"same UUID on every node",
|
||||
"AUTOMATICALLYGENERATED_KEY");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unset UUID is rejected even when the key is set")
|
||||
void missingUuidRejected() {
|
||||
enabled("valkey");
|
||||
assertMessage(
|
||||
new ClusterConfig(props, SHARED_KEY, null),
|
||||
"AutomaticallyGenerated.UUID",
|
||||
"AUTOMATICALLYGENERATED_UUID");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the settings.yml.template placeholder is rejected: InitialSetup replaces it")
|
||||
void nonUuidPlaceholderRejected() {
|
||||
enabled("valkey");
|
||||
assertMessage(
|
||||
new ClusterConfig(props, "example", "example"),
|
||||
"AutomaticallyGenerated.key",
|
||||
"must be a UUID");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the guard also applies to backplane=inprocess")
|
||||
void inProcessBackplaneAlsoGuarded() {
|
||||
enabled("inprocess");
|
||||
assertMessage(new ClusterConfig(props, "", ""), "AutomaticallyGenerated.key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two explicit UUIDs pass")
|
||||
void explicitSharedValuesPass() {
|
||||
enabled("valkey");
|
||||
props.getCluster().getValkey().setUrl("redis://valkey:6379");
|
||||
assertDoesNotThrow(() -> invokeValidate(config(props)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cluster.enabled=false never requires the shared values")
|
||||
void disabledClusterSkipsGuard() {
|
||||
enabled("valkey");
|
||||
props.getCluster().setEnabled(false);
|
||||
assertDoesNotThrow(() -> invokeValidate(new ClusterConfig(props, "", "")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the pre-bean guard reads the same rule as the @PostConstruct one")
|
||||
void staticGuardMatchesPostConstructGuard() {
|
||||
assertDoesNotThrow(() -> ClusterConfig.validateSharedCryptoMaterial(false, "", ""));
|
||||
assertDoesNotThrow(
|
||||
() ->
|
||||
ClusterConfig.validateSharedCryptoMaterial(
|
||||
true, SHARED_KEY, SHARED_UUID));
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> ClusterConfig.validateSharedCryptoMaterial(true, SHARED_KEY, ""));
|
||||
}
|
||||
|
||||
private void enabled(String backplane) {
|
||||
props = new ApplicationProperties();
|
||||
props.getCluster().setEnabled(true);
|
||||
props.getCluster().setBackplane(backplane);
|
||||
}
|
||||
|
||||
private void assertMessage(ClusterConfig config, String... expectedSubstrings) {
|
||||
IllegalStateException ex =
|
||||
assertThrows(IllegalStateException.class, () -> invokeValidate(config));
|
||||
for (String expected : expectedSubstrings) {
|
||||
assertTrue(
|
||||
ex.getMessage().contains(expected),
|
||||
"message must contain '" + expected + "'; got: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The guard must abort the context before InitialSetup's @PostConstruct can generate one. */
|
||||
@Nested
|
||||
@DisplayName("pre-bean shared key/UUID guard")
|
||||
class SharedCryptoMaterialBootGuard {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(
|
||||
AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class))
|
||||
.withUserConfiguration(TestAppPropertiesConfig.class, ClusterConfig.class)
|
||||
.withPropertyValues("cluster.enabled=true", "cluster.backplane=inprocess");
|
||||
|
||||
@Test
|
||||
@DisplayName("boot fails before any bean is created when the shared values are missing")
|
||||
void bootFailsWhenSharedValuesMissing() {
|
||||
runner.withUserConfiguration(StubInitialSetupConfig.class)
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.getFailure()
|
||||
.hasMessageContaining("AutomaticallyGenerated.key")
|
||||
.hasMessageContaining("AUTOMATICALLYGENERATED_KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("boot succeeds once both values are configured")
|
||||
void bootSucceedsWhenSharedValuesConfigured() {
|
||||
runner.withUserConfiguration(StubInitialSetupConfig.class)
|
||||
.withPropertyValues(
|
||||
"AutomaticallyGenerated.key=" + SHARED_KEY,
|
||||
"AutomaticallyGenerated.UUID=" + SHARED_UUID)
|
||||
.run(context -> assertThat(context).hasNotFailed());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no InitialSetup in the context means nothing mints a per-node UUID")
|
||||
void guardSkippedWithoutInitialSetup() {
|
||||
runner.run(context -> assertThat(context).hasNotFailed());
|
||||
}
|
||||
}
|
||||
|
||||
/** Defaults-only bean: the production class loads YAML in {@code @PostConstruct}. */
|
||||
@Configuration
|
||||
static class TestAppPropertiesConfig {
|
||||
@Bean
|
||||
ApplicationProperties applicationProperties() {
|
||||
return new ApplicationProperties();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stands in for :core's InitialSetup, which the guard keys off by bean name. */
|
||||
@Configuration
|
||||
static class StubInitialSetupConfig {
|
||||
@Bean(name = "initialSetup")
|
||||
Object initialSetup() {
|
||||
return new Object();
|
||||
}
|
||||
}
|
||||
|
||||
private void invokeValidate(ClusterConfig config) throws Exception {
|
||||
Method m = ClusterConfig.class.getDeclaredMethod("validate");
|
||||
m.setAccessible(true);
|
||||
|
||||
@@ -3,19 +3,11 @@ package stirling.software.common.cluster;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster.Valkey;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster.Valkey.ValkeyMode;
|
||||
|
||||
class ClusterPropertiesTest {
|
||||
|
||||
@@ -33,39 +25,6 @@ class ClusterPropertiesTest {
|
||||
assertEquals(5000L, props.getNode().getHeartbeatIntervalMs());
|
||||
}
|
||||
|
||||
// A drifted default here is a silent breaking change for existing url-only installs.
|
||||
@Test
|
||||
@DisplayName("new valkey topology + pool keys default to the backward-compatible values")
|
||||
void valkeyTopologyAndPoolDefaults() {
|
||||
Valkey valkey = new ApplicationProperties().getCluster().getValkey();
|
||||
|
||||
assertEquals("", valkey.getMode(), "blank mode must auto-resolve, not force a topology");
|
||||
assertEquals("", valkey.getUsername());
|
||||
assertEquals("", valkey.getPassword());
|
||||
assertTrue(valkey.getNodes().isEmpty());
|
||||
assertEquals(3, valkey.getMaxRedirects());
|
||||
assertEquals(30000L, valkey.getTopologyRefreshMs());
|
||||
assertEquals("", valkey.getClientName());
|
||||
assertEquals(2000L, valkey.getCommandTimeoutMs());
|
||||
|
||||
assertEquals("", valkey.getSentinel().getMaster());
|
||||
assertTrue(valkey.getSentinel().getNodes().isEmpty());
|
||||
assertEquals("", valkey.getSentinel().getUsername());
|
||||
assertEquals("", valkey.getSentinel().getPassword());
|
||||
|
||||
assertFalse(valkey.getTls().isEnabled());
|
||||
assertFalse(valkey.getTls().isSkipCertVerification());
|
||||
|
||||
assertTrue(valkey.getPool().isEnabled(), "pooling is on by default");
|
||||
assertEquals(16, valkey.getPool().getMaxActive());
|
||||
assertEquals(16, valkey.getPool().getMaxIdle());
|
||||
// 0, not a warm floor: backplane traffic runs on the shared native connection.
|
||||
assertEquals(0, valkey.getPool().getMinIdle());
|
||||
assertEquals(2000L, valkey.getPool().getMaxWaitMillis());
|
||||
assertEquals(30000L, valkey.getPool().getTimeBetweenEvictionRunsMillis());
|
||||
assertTrue(valkey.getPool().isTestOnBorrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedRoleParsesCaseInsensitively() {
|
||||
Cluster props = new ApplicationProperties().getCluster();
|
||||
@@ -100,121 +59,4 @@ class ClusterPropertiesTest {
|
||||
props.getNode().setId("abc");
|
||||
assertEquals("abc", props.resolvedNodeId());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Valkey.resolvedMode()")
|
||||
class ResolvedMode {
|
||||
|
||||
private Valkey valkey() {
|
||||
return new ApplicationProperties().getCluster().getValkey();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank mode with nothing else configured is STANDALONE (url-only upgrade)")
|
||||
void blankDefaultsToStandalone() {
|
||||
Valkey v = valkey();
|
||||
v.setUrl("redis://valkey:6379");
|
||||
assertEquals(ValkeyMode.STANDALONE, v.resolvedMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank mode + sentinel.master infers SENTINEL")
|
||||
void blankWithSentinelMasterInfersSentinel() {
|
||||
Valkey v = valkey();
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
assertEquals(ValkeyMode.SENTINEL, v.resolvedMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank mode + nodes infers CLUSTER")
|
||||
void blankWithNodesInfersCluster() {
|
||||
Valkey v = valkey();
|
||||
v.setNodes(List.of("valkey-1:6379"));
|
||||
assertEquals(ValkeyMode.CLUSTER, v.resolvedMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank mode + BOTH sentinel.master and nodes is ambiguous and throws")
|
||||
void blankWithBothIsAmbiguous() {
|
||||
Valkey v = valkey();
|
||||
v.getSentinel().setMaster("mymaster");
|
||||
v.setNodes(List.of("valkey-1:6379"));
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, v::resolvedMode);
|
||||
assertTrue(
|
||||
ex.getMessage().contains("Set cluster.valkey.mode explicitly"),
|
||||
"message must tell the operator how to disambiguate; got: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicit mode parses case-insensitively and trims surrounding whitespace")
|
||||
void explicitModeParsingIsLenient() {
|
||||
Valkey v = valkey();
|
||||
v.setMode("SENTINEL");
|
||||
assertEquals(ValkeyMode.SENTINEL, v.resolvedMode());
|
||||
v.setMode(" sentinel ");
|
||||
assertEquals(ValkeyMode.SENTINEL, v.resolvedMode());
|
||||
v.setMode("Cluster");
|
||||
assertEquals(ValkeyMode.CLUSTER, v.resolvedMode());
|
||||
v.setMode("standalone");
|
||||
assertEquals(ValkeyMode.STANDALONE, v.resolvedMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown mode names the bad value and lists the valid ones")
|
||||
void unknownModeThrows() {
|
||||
Valkey v = valkey();
|
||||
v.setMode("clustr");
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, v::resolvedMode);
|
||||
assertTrue(ex.getMessage().contains("clustr"), "must echo the bad value");
|
||||
assertTrue(
|
||||
ex.getMessage().contains("standalone | sentinel | cluster"),
|
||||
"must list valid values; got: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicit mode wins over inference (standalone even when nodes are set)")
|
||||
void explicitModeWinsOverInference() {
|
||||
Valkey v = valkey();
|
||||
v.setNodes(List.of("valkey-1:6379"));
|
||||
v.setMode("standalone");
|
||||
assertEquals(ValkeyMode.STANDALONE, v.resolvedMode());
|
||||
}
|
||||
}
|
||||
|
||||
/** A bare 'valkey:'/'sentinel:'/'tls:'/'pool:'/'nodes:' key in settings.yml binds null. */
|
||||
@Nested
|
||||
@DisplayName("bare yaml keys bind null")
|
||||
class BareYamlKeys {
|
||||
|
||||
@Test
|
||||
@DisplayName("a null nested block is re-seeded with its defaults, never handed back")
|
||||
void nullNestedBlocksAreReSeeded() {
|
||||
Cluster cluster = new ApplicationProperties().getCluster();
|
||||
cluster.setValkey(null);
|
||||
Valkey valkey = cluster.getValkey();
|
||||
assertNotNull(valkey);
|
||||
|
||||
valkey.setSentinel(null);
|
||||
valkey.setTls(null);
|
||||
valkey.setPool(null);
|
||||
valkey.setNodes(null);
|
||||
assertNotNull(valkey.getSentinel());
|
||||
assertNotNull(valkey.getTls());
|
||||
assertNotNull(valkey.getPool());
|
||||
assertEquals(16, valkey.getPool().getMaxActive());
|
||||
assertTrue(valkey.getNodes().isEmpty());
|
||||
|
||||
valkey.getSentinel().setNodes(null);
|
||||
assertTrue(valkey.getSentinel().getNodes().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolvedMode() survives null sentinel and nodes blocks")
|
||||
void resolvedModeSurvivesNullBlocks() {
|
||||
Valkey valkey = new ApplicationProperties().getCluster().getValkey();
|
||||
valkey.setSentinel(null);
|
||||
valkey.setNodes(null);
|
||||
assertEquals(ValkeyMode.STANDALONE, valkey.resolvedMode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
@DisplayName("HostPort.parse()")
|
||||
class HostPortTest {
|
||||
|
||||
private static final String PROP = "cluster.valkey.nodes";
|
||||
private static final String EXAMPLE = "valkey-1:6379";
|
||||
|
||||
@Test
|
||||
@DisplayName("host:port splits into host and port")
|
||||
void hostAndPort() {
|
||||
HostPort e = HostPort.parse("valkey-1:6379", PROP, EXAMPLE);
|
||||
assertEquals("valkey-1", e.host());
|
||||
assertEquals(6379, e.port());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("surrounding whitespace is trimmed (comma-separated env vars keep spaces)")
|
||||
void trimsWhitespace() {
|
||||
HostPort e = HostPort.parse(" valkey-2:6380 ", PROP, EXAMPLE);
|
||||
assertEquals("valkey-2", e.host());
|
||||
assertEquals(6380, e.port());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bracketed IPv6 literal keeps no brackets in the host")
|
||||
void bracketedIpv6() {
|
||||
HostPort e = HostPort.parse("[::1]:6379", PROP, EXAMPLE);
|
||||
assertEquals("::1", e.host());
|
||||
assertEquals(6379, e.port());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"valkey-1:abc",
|
||||
"valkey-1:0",
|
||||
"valkey-1:70000",
|
||||
"valkey-1:",
|
||||
":6379",
|
||||
"[::1",
|
||||
"[::1]",
|
||||
"[::1]6379"
|
||||
})
|
||||
@DisplayName("a bad port, a missing host or a malformed bracket throws")
|
||||
void badEntriesThrow(String entry) {
|
||||
assertRejected(entry);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bare host is rejected rather than silently taking a default port")
|
||||
void bareHostIsRejected() {
|
||||
assertRejected("valkey-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unbracketed IPv6 literal is rejected, not read as host ':' port")
|
||||
void unbracketedIpv6IsRejected() {
|
||||
IllegalStateException ex = assertRejected("::1");
|
||||
assertTrue(
|
||||
ex.getMessage().contains("[::1]:6379"),
|
||||
"message must show the bracketed form; got: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank entry throws with a host:port example")
|
||||
void blankEntryThrows() {
|
||||
IllegalStateException ex =
|
||||
assertThrows(
|
||||
IllegalStateException.class, () -> HostPort.parse(" ", PROP, EXAMPLE));
|
||||
assertTrue(ex.getMessage().contains(PROP));
|
||||
assertTrue(ex.getMessage().contains("host:port"));
|
||||
}
|
||||
|
||||
private IllegalStateException assertRejected(String entry) {
|
||||
IllegalStateException ex =
|
||||
assertThrows(
|
||||
IllegalStateException.class, () -> HostPort.parse(entry, PROP, EXAMPLE));
|
||||
assertTrue(
|
||||
ex.getMessage().contains(PROP),
|
||||
"message must name the property; got: " + ex.getMessage());
|
||||
assertTrue(
|
||||
ex.getMessage().contains(entry),
|
||||
"message must echo the offending entry; got: " + ex.getMessage());
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -11,6 +11,11 @@ import org.springframework.context.annotation.Configuration;
|
||||
import stirling.software.common.cluster.inprocess.InProcessClusterConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Verifies the {@link InProcessClusterConfiguration} conditional wiring: in-process beans wire when
|
||||
* cluster mode is off or {@code backplane=inprocess}, and are skipped when {@code
|
||||
* backplane=valkey}.
|
||||
*/
|
||||
class InProcessConfigurationConditionalTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
@@ -71,8 +76,9 @@ class InProcessConfigurationConditionalTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults-only bean: the production class loads YAML in {@code @PostConstruct}, which the
|
||||
* slice runner cannot do.
|
||||
* Hand-rolled {@link ApplicationProperties} bean: the production class loads YAML at startup
|
||||
* via a {@code @PostConstruct} hook that isn't appropriate for the slice runner, so we wire a
|
||||
* defaults-only instance here.
|
||||
*/
|
||||
@Configuration
|
||||
static class TestAppPropertiesConfig {
|
||||
|
||||
+2
-1
@@ -237,6 +237,7 @@ class ApplicationPropertiesLogicTest {
|
||||
|
||||
assertTrue(
|
||||
oauth2.isValid(oneBlank, "scopes"),
|
||||
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
|
||||
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn"
|
||||
+ " Element leer/blank ist");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,8 @@ class PdfMarkdownConverterTest {
|
||||
if (similarity < THRESHOLD) {
|
||||
fail(
|
||||
String.format(
|
||||
"Markdown output differs from golden file '%s' by %.1f%% (threshold %.0f%%):%n%s",
|
||||
"Markdown output differs from golden file '%s' by %.1f%% (threshold"
|
||||
+ " %.0f%%):%n%s",
|
||||
mdName,
|
||||
(1.0 - similarity) * 100,
|
||||
(1.0 - THRESHOLD) * 100,
|
||||
|
||||
+11
-11
@@ -60,10 +60,10 @@ class CustomHtmlSanitizerTest {
|
||||
new String[] {"<p>", "<strong>", "<em>"}),
|
||||
Arguments.of(
|
||||
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>,"
|
||||
+ " <em>emphasis</em>, <strong>strong</strong>,"
|
||||
+ " <strike>strikethrough</strike>, <s>strike</s>,"
|
||||
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
|
||||
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
|
||||
+ " <em>emphasis</em>, <strong>strong</strong>,"
|
||||
+ " <strike>strikethrough</strike>, <s>strike</s>,"
|
||||
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
|
||||
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
|
||||
new String[] {
|
||||
"<b>bold</b>",
|
||||
"<i>italic</i>",
|
||||
@@ -271,8 +271,8 @@ class CustomHtmlSanitizerTest {
|
||||
// Arrange
|
||||
String htmlWithObjects =
|
||||
"<p>Safe content</p><object data=\"data.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\">";
|
||||
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\">";
|
||||
|
||||
// Act
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects);
|
||||
@@ -309,11 +309,11 @@ class CustomHtmlSanitizerTest {
|
||||
// Arrange
|
||||
String complexHtml =
|
||||
"<div class=\"container\"> <h1 style=\"color: blue;\">Welcome</h1> <p>This is a"
|
||||
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
|
||||
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
|
||||
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
|
||||
+ " image\"> <script>alert('XSS');</script> <iframe"
|
||||
+ " src=\"https://evil.com\"></iframe></div>";
|
||||
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
|
||||
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
|
||||
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
|
||||
+ " image\"> <script>alert('XSS');</script> <iframe"
|
||||
+ " src=\"https://evil.com\"></iframe></div>";
|
||||
|
||||
// Act
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml);
|
||||
|
||||
@@ -120,10 +120,10 @@ class EmlToPdfTest {
|
||||
void parseHtmlEmailWithStyling() throws IOException {
|
||||
String htmlBody =
|
||||
"<html><head><style>.header{color:blue;font-weight:bold;}"
|
||||
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head>"
|
||||
+ "<body><div class=\"header\">Important Notice</div>"
|
||||
+ "<div class=\"content\">This is <strong>HTML content</strong> with styling.</div>"
|
||||
+ "<div class=\"footer\">Best regards</div></body></html>";
|
||||
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head><body><div"
|
||||
+ " class=\"header\">Important Notice</div><div class=\"content\">This is"
|
||||
+ " <strong>HTML content</strong> with styling.</div><div"
|
||||
+ " class=\"footer\">Best regards</div></body></html>";
|
||||
|
||||
String emlContent =
|
||||
createHtmlEmail(
|
||||
@@ -286,11 +286,13 @@ class EmlToPdfTest {
|
||||
@DisplayName("Should handle complex nested HTML structures")
|
||||
void handleComplexNestedHtml() throws IOException {
|
||||
String complexHtml =
|
||||
"<html><head><title>Complex Email</title></head><body>"
|
||||
+ "<div class=\"container\"><header><h1>Email Header</h1></header><main><section>"
|
||||
+ "<p>Paragraph with <a href=\"https://example.com\">link</a></p><ul>"
|
||||
+ "<li>List item 1</li><li>List item 2 with <em>emphasis</em></li></ul><table>"
|
||||
+ "<tr><td>Cell 1</td><td>Cell 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
|
||||
"<html><head><title>Complex Email</title></head><body><div"
|
||||
+ " class=\"container\"><header><h1>Email"
|
||||
+ " Header</h1></header><main><section><p>Paragraph with <a"
|
||||
+ " href=\"https://example.com\">link</a></p><ul><li>List item"
|
||||
+ " 1</li><li>List item 2 with"
|
||||
+ " <em>emphasis</em></li></ul><table><tr><td>Cell 1</td><td>Cell"
|
||||
+ " 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
|
||||
+ "</table></section></main></div></body></html>";
|
||||
|
||||
String emlContent =
|
||||
@@ -346,7 +348,8 @@ class EmlToPdfTest {
|
||||
This line breaks header format
|
||||
Content-Type: text/plain
|
||||
|
||||
Body content""";
|
||||
Body content\
|
||||
""";
|
||||
|
||||
byte[] emlBytes = malformedEml.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
@@ -781,7 +784,13 @@ class EmlToPdfTest {
|
||||
String from, String to, String subject, String body, String charset) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "Content-Type: text/plain; charset=%s\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
@@ -793,7 +802,11 @@ class EmlToPdfTest {
|
||||
private String createEmailWithCustomHeaders() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
"From: sender@example.com\n"
|
||||
+ "Date: %s\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "%s",
|
||||
getTimestamp(),
|
||||
"This is an email body with some headers missing.");
|
||||
}
|
||||
@@ -801,7 +814,13 @@ class EmlToPdfTest {
|
||||
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "Content-Type: text/html; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
@@ -823,26 +842,27 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
--%s--\
|
||||
""",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
@@ -863,26 +883,27 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: message/rfc822; name="%s"
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
--%s
|
||||
Content-Type: message/rfc822; name="%s"
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
--%s--\
|
||||
""",
|
||||
"outer@example.com",
|
||||
"outer_recipient@example.com",
|
||||
"Fwd: Inner Email Subject",
|
||||
@@ -902,26 +923,27 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
--%s--\
|
||||
""",
|
||||
"sender@example.com",
|
||||
"receiver@example.com",
|
||||
"Multipart/Alternative Test",
|
||||
@@ -937,7 +959,14 @@ class EmlToPdfTest {
|
||||
private String createQuotedPrintableEmail() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "MIME-Version: 1.0\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: quoted-printable\n\n"
|
||||
+ "%s",
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Quoted-Printable Test",
|
||||
@@ -950,7 +979,14 @@ class EmlToPdfTest {
|
||||
Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "MIME-Version: 1.0\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: base64\n\n"
|
||||
+ "%s",
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Base64 Test",
|
||||
@@ -963,27 +999,28 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/related; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/related; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
--%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
--%s--\
|
||||
""",
|
||||
"sender@example.com",
|
||||
"receiver@example.com",
|
||||
"Inline Image Test",
|
||||
@@ -1008,39 +1045,40 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: multipart/related; boundary="related-%s"
|
||||
--%s
|
||||
Content-Type: multipart/related; boundary="related-%s"
|
||||
|
||||
--related-%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--related-%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--related-%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
--related-%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--related-%s--
|
||||
--related-%s--
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
--%s--\
|
||||
""",
|
||||
"sender@example.com",
|
||||
"receiver@example.com",
|
||||
"Mixed Attachments Test",
|
||||
|
||||
+27
-24
@@ -31,21 +31,22 @@ class OfficeDocumentSanitizerTest {
|
||||
private static final String INTERNAL_TARGET = "media/image1.png";
|
||||
|
||||
private static final String DOCX_RELS =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ "\" TargetMode=\"External\"/><Relationship Id=\"rId2\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ INTERNAL_TARGET
|
||||
+ "\"/>"
|
||||
+ "</Relationships>";
|
||||
|
||||
private static final String DOCX_DOCUMENT =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><w:document"
|
||||
+ " xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
|
||||
+ "<w:body><w:p/></w:body></w:document>";
|
||||
|
||||
private static final String ODF_CONTENT_EXTERNAL =
|
||||
@@ -57,8 +58,8 @@ class OfficeDocumentSanitizerTest {
|
||||
+ "<office:body><office:text>"
|
||||
+ "<draw:frame><draw:image xlink:href=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "\" xlink:type=\"simple\"/></draw:frame><draw:frame><draw:image"
|
||||
+ " xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "</office:text></office:body></office:document-content>";
|
||||
|
||||
private SsrfProtectionService ssrfProtectionService;
|
||||
@@ -113,10 +114,11 @@ class OfficeDocumentSanitizerTest {
|
||||
@Test
|
||||
void sanitize_pptxExternalImageRelStripped() throws IOException {
|
||||
String pptxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
@@ -135,10 +137,11 @@ class OfficeDocumentSanitizerTest {
|
||||
@Test
|
||||
void sanitize_xlsxExternalImageRelStripped() throws IOException {
|
||||
String xlsxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
@@ -162,7 +165,7 @@ class OfficeDocumentSanitizerTest {
|
||||
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
|
||||
String manifestXml =
|
||||
"<?xml version=\"1.0\"?><manifest:manifest"
|
||||
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
|
||||
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
|
||||
entries.put("META-INF/manifest.xml", manifestXml.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odt = zip(entries);
|
||||
|
||||
@@ -294,11 +297,11 @@ class OfficeDocumentSanitizerTest {
|
||||
@Test
|
||||
void sanitize_internalLinksKeptWhenNoExternalPresent() throws IOException {
|
||||
String internalOnlyRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\"media/image1.png\"/>"
|
||||
+ "</Relationships>";
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\"media/image1.png\"/></Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"word/_rels/document.xml.rels", internalOnlyRels.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
@@ -249,7 +249,8 @@ class ProcessExecutorGapTest {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"injects --host/--port after the executable, defaults omit host-location and protocol")
|
||||
"injects --host/--port after the executable, defaults omit host-location and"
|
||||
+ " protocol")
|
||||
void injectsHostAndPortWithDefaults() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx", "out.pdf");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
|
||||
@@ -38,7 +38,8 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesScriptElement() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle r=\"10\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle"
|
||||
+ " r=\"10\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("script"));
|
||||
@@ -48,7 +49,8 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesEventHandler() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\" onclick=\"alert('xss')\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\""
|
||||
+ " onclick=\"alert('xss')\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("onclick"));
|
||||
@@ -57,7 +59,8 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesJavascriptUrl() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a"
|
||||
+ " href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("javascript"));
|
||||
@@ -86,7 +89,8 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesForeignObject() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect width=\"10\" height=\"10\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect"
|
||||
+ " width=\"10\" height=\"10\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.toLowerCase().contains("foreignobject"));
|
||||
@@ -113,8 +117,8 @@ class SvgSanitizerTest {
|
||||
void testSanitize_removesRelativeLocalPath() throws IOException {
|
||||
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
|
||||
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"../../assets/image.png\""
|
||||
+ " width=\"10\" height=\"10\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ public class ReplaceAndInvertColorFactory {
|
||||
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
|
||||
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
|
||||
throw new IllegalStateException(
|
||||
"CMYK color space conversion requires Ghostscript, which is not available on this system");
|
||||
"CMYK color space conversion requires Ghostscript, which is not available on"
|
||||
+ " this system");
|
||||
}
|
||||
|
||||
return switch (replaceAndInvertOption) {
|
||||
|
||||
+18
-9
@@ -74,7 +74,8 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
private ApiResponse create400Response() {
|
||||
return new ApiResponse()
|
||||
.description(
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted file")
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted"
|
||||
+ " file")
|
||||
.content(
|
||||
new Content()
|
||||
.addMediaType(
|
||||
@@ -83,12 +84,14 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
400,
|
||||
"Invalid input parameters or corrupted file",
|
||||
"Invalid input parameters or"
|
||||
+ " corrupted file",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
400,
|
||||
"Invalid input parameters or corrupted file",
|
||||
"Invalid input parameters or"
|
||||
+ " corrupted file",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
@@ -103,12 +106,14 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
413,
|
||||
"File size exceeds maximum allowed limit",
|
||||
"File size exceeds maximum allowed"
|
||||
+ " limit",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
413,
|
||||
"File size exceeds maximum allowed limit",
|
||||
"File size exceeds maximum allowed"
|
||||
+ " limit",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
@@ -123,12 +128,14 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
422,
|
||||
"File is valid but cannot be processed",
|
||||
"File is valid but cannot be"
|
||||
+ " processed",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
422,
|
||||
"File is valid but cannot be processed",
|
||||
"File is valid but cannot be"
|
||||
+ " processed",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
@@ -143,12 +150,14 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
500,
|
||||
"Unexpected error during processing",
|
||||
"Unexpected error during"
|
||||
+ " processing",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
500,
|
||||
"Unexpected error during processing",
|
||||
"Unexpected error during"
|
||||
+ " processing",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ public class LocaleConfiguration implements WebMvcConfigurer {
|
||||
defaultLocale = tempLocale;
|
||||
} else {
|
||||
System.err.println(
|
||||
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-US.");
|
||||
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back"
|
||||
+ " to default en-US.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,12 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Processing API")
|
||||
.description(
|
||||
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
|
||||
"APIs for converting, editing, securing, and"
|
||||
+ " analysing PDF documents. Use these"
|
||||
+ " endpoints to automate common PDF tasks"
|
||||
+ " (like split, merge, convert, OCR) and"
|
||||
+ " plug them into your own apps and"
|
||||
+ " backend jobs."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -79,7 +84,9 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Management API")
|
||||
.description(
|
||||
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
|
||||
"Endpoints for authentication, user management,"
|
||||
+ " invitations, audit logging, and system"
|
||||
+ " configuration."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -102,7 +109,8 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - System API")
|
||||
.description(
|
||||
"System information, UI metadata, job status, and file management endpoints."));
|
||||
"System information, UI metadata, job status,"
|
||||
+ " and file management endpoints."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ public class TauriProcessMonitor {
|
||||
startMonitoring();
|
||||
} else {
|
||||
logger.warn(
|
||||
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring disabled.");
|
||||
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring"
|
||||
+ " disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +75,8 @@ public class TauriProcessMonitor {
|
||||
try {
|
||||
if (!isProcessAlive(parentProcessId)) {
|
||||
logger.warn(
|
||||
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful shutdown...",
|
||||
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful"
|
||||
+ " shutdown...",
|
||||
parentProcessId);
|
||||
initiateGracefulShutdown();
|
||||
}
|
||||
@@ -118,7 +120,8 @@ public class TauriProcessMonitor {
|
||||
} else {
|
||||
// Fallback to system exit
|
||||
logger.warn(
|
||||
"Unable to shutdown Spring context gracefully, using System.exit");
|
||||
"Unable to shutdown Spring context gracefully, using"
|
||||
+ " System.exit");
|
||||
System.exit(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
+5
-2
@@ -29,7 +29,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"CSV file containing extracted table data")),
|
||||
"CSV file containing extracted table"
|
||||
+ " data")),
|
||||
@Content(
|
||||
mediaType = "application/zip",
|
||||
schema =
|
||||
@@ -37,7 +38,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"ZIP archive containing multiple CSV files when multiple tables are extracted"))
|
||||
"ZIP archive containing multiple CSV files"
|
||||
+ " when multiple tables are"
|
||||
+ " extracted"))
|
||||
}),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
|
||||
@@ -51,7 +51,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "422",
|
||||
description =
|
||||
"Unprocessable entity - PDF is valid but cannot be analyzed for filtering",
|
||||
"Unprocessable entity - PDF is valid but cannot be analyzed for"
|
||||
+ " filtering",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
|
||||
@@ -28,7 +28,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@Schema(
|
||||
type = "object",
|
||||
description =
|
||||
"JSON object containing the requested data or analysis results"))),
|
||||
"JSON object containing the requested"
|
||||
+ " data or analysis results"))),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Invalid PDF file or request parameters",
|
||||
|
||||
@@ -21,7 +21,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description =
|
||||
"Files processed successfully. Returns single file or ZIP archive containing multiple files.",
|
||||
"Files processed successfully. Returns single file or ZIP archive"
|
||||
+ " containing multiple files.",
|
||||
content = {
|
||||
@Content(
|
||||
mediaType = "application/pdf",
|
||||
@@ -37,7 +38,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"ZIP archive containing multiple output files")),
|
||||
"ZIP archive containing multiple output"
|
||||
+ " files")),
|
||||
@Content(
|
||||
mediaType = "image/png",
|
||||
schema =
|
||||
|
||||
+6
-3
@@ -30,11 +30,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"Microsoft PowerPoint presentation (PPTX)"))),
|
||||
"Microsoft PowerPoint presentation"
|
||||
+ " (PPTX)"))),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description =
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
|
||||
"Bad request - Invalid input parameters, unsupported format, or"
|
||||
+ " corrupted PDF",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
@@ -49,7 +51,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "422",
|
||||
description =
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to PowerPoint format",
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to"
|
||||
+ " PowerPoint format",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
|
||||
+4
-2
@@ -41,7 +41,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description =
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
|
||||
"Bad request - Invalid input parameters, unsupported format, or"
|
||||
+ " corrupted PDF",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
@@ -56,7 +57,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "422",
|
||||
description =
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to Word format",
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to Word"
|
||||
+ " format",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
|
||||
+11
-11
@@ -39,18 +39,18 @@ public class AdditionalLanguageJsController {
|
||||
// Generiere die `getDetailedLanguageCode`-Funktion
|
||||
writer.println(
|
||||
"""
|
||||
function getDetailedLanguageCode() {
|
||||
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
|
||||
for (let lang of userLanguages) {
|
||||
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
|
||||
if (matchedLang) {
|
||||
return matchedLang;
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
return "en_US";
|
||||
function getDetailedLanguageCode() {
|
||||
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
|
||||
for (let lang of userLanguages) {
|
||||
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
|
||||
if (matchedLang) {
|
||||
return matchedLang;
|
||||
}
|
||||
""");
|
||||
}
|
||||
// Fallback
|
||||
return "en_US";
|
||||
}
|
||||
""");
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -54,8 +54,9 @@ public class BookletImpositionController {
|
||||
summary = "Create a booklet with proper page imposition",
|
||||
description =
|
||||
"This operation combines page reordering for booklet printing with multi-page"
|
||||
+ " layout. It rearranges pages in the correct order for booklet printing and"
|
||||
+ " places multiple pages on each sheet for proper folding and binding.")
|
||||
+ " layout. It rearranges pages in the correct order for booklet printing"
|
||||
+ " and places multiple pages on each sheet for proper folding and"
|
||||
+ " binding.")
|
||||
public ResponseEntity<Resource> createBookletImposition(
|
||||
@ModelAttribute BookletImpositionRequest request) throws IOException {
|
||||
|
||||
@@ -73,7 +74,8 @@ public class BookletImpositionController {
|
||||
// Validate pages per sheet for booklet - only 2-up landscape is proper booklet
|
||||
if (pagesPerSheet != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up"
|
||||
+ " feature.");
|
||||
}
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
|
||||
@@ -150,7 +150,8 @@ public class CropController {
|
||||
|| request.getWidth() == null
|
||||
|| request.getHeight() == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not"
|
||||
+ " enabled");
|
||||
}
|
||||
|
||||
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
|
||||
|
||||
+8
-7
@@ -90,13 +90,14 @@ public class EditTextController {
|
||||
summary = "Edit text in a PDF via find and replace",
|
||||
description =
|
||||
"Applies an ordered list of find/replace operations to the text in a PDF and"
|
||||
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
|
||||
+ " updating a company name throughout a document), and copy editing where the AI"
|
||||
+ " agent has identified specific replacements. Matching is performed against the"
|
||||
+ " joined text of each page, so find strings can span multiple visual runs"
|
||||
+ " (titles split per word, kerning-broken phrases). Cross-element matches are"
|
||||
+ " written as a single replacement run anchored at the leftmost matched position;"
|
||||
+ " centered or tracked text may shift left when its content changes.")
|
||||
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
|
||||
+ " updating a company name throughout a document), and copy editing where"
|
||||
+ " the AI agent has identified specific replacements. Matching is"
|
||||
+ " performed against the joined text of each page, so find strings can"
|
||||
+ " span multiple visual runs (titles split per word, kerning-broken"
|
||||
+ " phrases). Cross-element matches are written as a single replacement run"
|
||||
+ " anchored at the leftmost matched position; centered or tracked text may"
|
||||
+ " shift left when its content changes.")
|
||||
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
@@ -246,8 +246,8 @@ public class MergeController {
|
||||
summary = "Merge multiple PDF files into one",
|
||||
description =
|
||||
"This endpoint merges multiple PDF files into a single PDF file. The merged"
|
||||
+ " file will contain all pages from the input files in the order they were"
|
||||
+ " provided.")
|
||||
+ " file will contain all pages from the input files in the order they were"
|
||||
+ " provided.")
|
||||
public ResponseEntity<Resource> mergePdfs(
|
||||
@ModelAttribute MergePdfsRequest request,
|
||||
@RequestParam(value = "fileOrder", required = false) String fileOrder)
|
||||
|
||||
+3
-2
@@ -220,8 +220,9 @@ public class MultiPageLayoutController {
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"margin/layout configuration",
|
||||
"Invalid margin or layout configuration: resulting cell size is non-positive. "
|
||||
+ "Please reduce outer margins or adjust rows/columns.");
|
||||
"Invalid margin or layout configuration: resulting cell size is"
|
||||
+ " non-positive. Please reduce outer margins or adjust"
|
||||
+ " rows/columns.");
|
||||
}
|
||||
|
||||
float innerWidth = cellWidth - 2 * innerMargin;
|
||||
|
||||
+4
-3
@@ -57,8 +57,8 @@ public class PosterPdfController {
|
||||
summary = "Split large PDF pages into smaller printable chunks",
|
||||
description =
|
||||
"This endpoint splits large or oddly-sized PDF pages into smaller chunks"
|
||||
+ " suitable for printing on standard paper sizes (e.g., A4, Letter). Divides each"
|
||||
+ " page into a grid of smaller pages using Apache PDFBox.")
|
||||
+ " suitable for printing on standard paper sizes (e.g., A4, Letter)."
|
||||
+ " Divides each page into a grid of smaller pages using Apache PDFBox.")
|
||||
public ResponseEntity<Resource> posterPdf(@ModelAttribute PosterPdfRequest request)
|
||||
throws Exception {
|
||||
|
||||
@@ -214,7 +214,8 @@ public class PosterPdfController {
|
||||
}
|
||||
|
||||
log.trace(
|
||||
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
|
||||
"Created output page for grid cell [{},{}] of page {}:"
|
||||
+ " cropX={}, cropY={}, translate=({}, {})",
|
||||
row,
|
||||
actualCol,
|
||||
pageIndex,
|
||||
|
||||
+2
-2
@@ -241,8 +241,8 @@ public class RearrangePagesPDFController {
|
||||
summary = "Rearrange pages in a PDF file",
|
||||
description =
|
||||
"This endpoint rearranges pages in a given PDF file based on the specified page"
|
||||
+ " order or custom mode. Users can provide a page order as a comma-separated list"
|
||||
+ " of page numbers or page ranges, or a custom mode.")
|
||||
+ " order or custom mode. Users can provide a page order as a"
|
||||
+ " comma-separated list of page numbers or page ranges, or a custom mode.")
|
||||
public ResponseEntity<Resource> rearrangePages(@ModelAttribute RearrangePagesRequest request)
|
||||
throws IOException {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -60,8 +60,8 @@ public class SplitPDFController {
|
||||
summary = "Split a PDF file into separate documents",
|
||||
description =
|
||||
"This endpoint splits a given PDF file into separate documents based on the"
|
||||
+ " specified page numbers or ranges. Users can specify pages using individual"
|
||||
+ " numbers, ranges, or 'all' for every page.")
|
||||
+ " specified page numbers or ranges. Users can specify pages using"
|
||||
+ " individual numbers, ranges, or 'all' for every page.")
|
||||
public ResponseEntity<Resource> splitPdf(@ModelAttribute SplitPagesRequest request)
|
||||
throws IOException {
|
||||
|
||||
|
||||
+2
-2
@@ -62,8 +62,8 @@ public class SplitPdfBySectionsController {
|
||||
summary = "Split PDF pages into smaller sections",
|
||||
description =
|
||||
"Split each page of a PDF into smaller sections based on the user's choice"
|
||||
+ " which page to split, and how to split ( halves, thirds, quarters, etc.), both"
|
||||
+ " vertically and horizontally.")
|
||||
+ " which page to split, and how to split ( halves, thirds, quarters,"
|
||||
+ " etc.), both vertically and horizontally.")
|
||||
public ResponseEntity<Resource> splitPdf(
|
||||
@Valid @ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
+3
-3
@@ -60,9 +60,9 @@ public class SplitPdfBySizeController {
|
||||
summary = "Auto split PDF pages into separate documents based on size or count",
|
||||
description =
|
||||
"split PDF into multiple paged documents based on size/count, ie if 20 pages"
|
||||
+ " and split into 5, it does 5 documents each 4 pages\r\n if 10MB and each page"
|
||||
+ " is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts"
|
||||
+ " 1.9MB but not 2.1MB)")
|
||||
+ " and split into 5, it does 5 documents each 4 pages\r\n"
|
||||
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
|
||||
+ " (rounded so that it accepts 1.9MB but not 2.1MB)")
|
||||
public ResponseEntity<Resource> autoSplitPdf(
|
||||
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
|
||||
|
||||
|
||||
+2
-2
@@ -46,8 +46,8 @@ public class ToSinglePageController {
|
||||
summary = "Convert a multi-page PDF into a single long page PDF",
|
||||
description =
|
||||
"This endpoint converts a multi-page PDF document into a single paged PDF"
|
||||
+ " document. The width of the single page will be same as the input's width, but"
|
||||
+ " the height will be the sum of all the pages' heights.")
|
||||
+ " document. The width of the single page will be same as the input's"
|
||||
+ " width, but the height will be the sum of all the pages' heights.")
|
||||
public ResponseEntity<Resource> pdfToSinglePage(@ModelAttribute PDFFile request)
|
||||
throws IOException {
|
||||
|
||||
|
||||
+3
-3
@@ -56,9 +56,9 @@ public class ConvertEmlToPDF {
|
||||
summary = "Convert EML/MSG to PDF",
|
||||
description =
|
||||
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format with"
|
||||
+ " extensive customization options. Features include font settings, image"
|
||||
+ " constraints, display modes, attachment handling, and HTML debug output. or MSG"
|
||||
+ " file, or HTML file.")
|
||||
+ " extensive customization options. Features include font settings, image"
|
||||
+ " constraints, display modes, attachment handling, and HTML debug output."
|
||||
+ " or MSG file, or HTML file.")
|
||||
public ResponseEntity<Resource> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
+2
-1
@@ -48,7 +48,8 @@ public class ConvertHtmlToPDF {
|
||||
@Operation(
|
||||
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
|
||||
description =
|
||||
"This endpoint takes an HTML or ZIP file input and converts it to a PDF format.")
|
||||
"This endpoint takes an HTML or ZIP file input and converts it to a PDF"
|
||||
+ " format.")
|
||||
public ResponseEntity<Resource> HtmlToPdf(@ModelAttribute HTMLToPdfRequest request)
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -95,8 +95,8 @@ public class ConvertImgPDFController {
|
||||
summary = "Convert PDF to image(s)",
|
||||
description =
|
||||
"This endpoint converts a PDF file to image(s) with the specified image format,"
|
||||
+ " color type, and DPI. Users can choose to get a single image or multiple"
|
||||
+ " images.")
|
||||
+ " color type, and DPI. Users can choose to get a single image or multiple"
|
||||
+ " images.")
|
||||
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
|
||||
throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ public class ConvertPDFToEpubController {
|
||||
|
||||
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
|
||||
throw new IllegalStateException(
|
||||
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
|
||||
+ " this feature.");
|
||||
"Calibre support is disabled. Enable the Calibre group or install Calibre to"
|
||||
+ " use this feature.");
|
||||
}
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
+36
-30
@@ -453,32 +453,32 @@ public class ConvertPDFToPDFA {
|
||||
String pdfaDefContent =
|
||||
String.format(
|
||||
"""
|
||||
%% This is a sample prefix file for creating a PDF/A document.
|
||||
%% Feel free to modify entries marked with "Customize".
|
||||
%% This is a sample prefix file for creating a PDF/A document.
|
||||
%% Feel free to modify entries marked with "Customize".
|
||||
|
||||
%% Define entries in the document Info dictionary.
|
||||
[/Title (%s)
|
||||
/DOCINFO pdfmark
|
||||
%% Define entries in the document Info dictionary.
|
||||
[/Title (%s)
|
||||
/DOCINFO pdfmark
|
||||
|
||||
%% Define an ICC profile.
|
||||
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
||||
[{icc_PDFA} <<
|
||||
/N 3
|
||||
>> /PUT pdfmark
|
||||
[{icc_PDFA} (%s) (r) file /PUT pdfmark
|
||||
%% Define an ICC profile.
|
||||
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
||||
[{icc_PDFA} <<
|
||||
/N 3
|
||||
>> /PUT pdfmark
|
||||
[{icc_PDFA} (%s) (r) file /PUT pdfmark
|
||||
|
||||
%% Define the output intent dictionary.
|
||||
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
|
||||
[{OutputIntent_PDFA} <<
|
||||
/Type /OutputIntent
|
||||
/S /GTS_PDFA1
|
||||
/DestOutputProfile {icc_PDFA}
|
||||
/OutputConditionIdentifier (sRGB IEC61966-2.1)
|
||||
/Info (sRGB IEC61966-2.1)
|
||||
/RegistryName (http://www.color.org)
|
||||
>> /PUT pdfmark
|
||||
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
|
||||
""",
|
||||
%% Define the output intent dictionary.
|
||||
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
|
||||
[{OutputIntent_PDFA} <<
|
||||
/Type /OutputIntent
|
||||
/S /GTS_PDFA1
|
||||
/DestOutputProfile {icc_PDFA}
|
||||
/OutputConditionIdentifier (sRGB IEC61966-2.1)
|
||||
/Info (sRGB IEC61966-2.1)
|
||||
/RegistryName (http://www.color.org)
|
||||
>> /PUT pdfmark
|
||||
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
|
||||
""",
|
||||
title, rgbProfilePath);
|
||||
|
||||
Files.writeString(pdfaDefFile, pdfaDefContent);
|
||||
@@ -598,8 +598,9 @@ public class ConvertPDFToPDFA {
|
||||
summary = "Convert a PDF to a PDF/A or PDF/X",
|
||||
description =
|
||||
"This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript"
|
||||
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for"
|
||||
+ " long-term archiving, while PDF/X is optimized for print production.")
|
||||
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format"
|
||||
+ " designed for long-term archiving, while PDF/X is optimized for print"
|
||||
+ " production.")
|
||||
public ResponseEntity<Resource> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -661,7 +662,8 @@ public class ConvertPDFToPDFA {
|
||||
if (!isGhostscriptAvailable()) {
|
||||
log.error("Ghostscript is required for PDF/X conversion");
|
||||
throw new IOException(
|
||||
"Ghostscript is required for PDF/X conversion but is not available on the system");
|
||||
"Ghostscript is required for PDF/X conversion but is not available on the"
|
||||
+ " system");
|
||||
}
|
||||
|
||||
log.info("Using Ghostscript for PDF/X conversion to {}", profile.getDisplayName());
|
||||
@@ -743,7 +745,8 @@ public class ConvertPDFToPDFA {
|
||||
if (fontNameStr.contains("+") || fontNameStr.contains("Subset")) {
|
||||
descDict.removeItem(COSName.CHAR_SET);
|
||||
log.debug(
|
||||
"Removed potentially invalid CharSet from subsetted Type1 font: {}",
|
||||
"Removed potentially invalid CharSet from subsetted Type1"
|
||||
+ " font: {}",
|
||||
fontNameStr);
|
||||
} else if (!hasFontFile && fontEmbedded) {
|
||||
// Font is embedded but we can't verify CharSet, remove it
|
||||
@@ -761,7 +764,8 @@ public class ConvertPDFToPDFA {
|
||||
if (!glyphSet.isEmpty()) {
|
||||
descDict.setString(COSName.CHAR_SET, glyphSet);
|
||||
log.debug(
|
||||
"Added missing CharSet for Type1 font {} with {} glyphs",
|
||||
"Added missing CharSet for Type1 font {} with {}"
|
||||
+ " glyphs",
|
||||
fontNameStr,
|
||||
countGlyphs(glyphSet));
|
||||
}
|
||||
@@ -1935,7 +1939,8 @@ public class ConvertPDFToPDFA {
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempOut, outputFilename);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
log.warn(
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
|
||||
+ " method",
|
||||
e);
|
||||
}
|
||||
} else {
|
||||
@@ -2536,7 +2541,8 @@ public class ConvertPDFToPDFA {
|
||||
return converted;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
log.warn(
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
|
||||
+ " method",
|
||||
e);
|
||||
}
|
||||
} else {
|
||||
|
||||
+20
-17
@@ -62,7 +62,8 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Convert PDF to Text Editor Format",
|
||||
description =
|
||||
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool.")
|
||||
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the"
|
||||
+ " text editor tool.")
|
||||
public ResponseEntity<Resource> convertPdfToJson(
|
||||
@ModelAttribute PDFFile request,
|
||||
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
|
||||
@@ -104,7 +105,8 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Convert Text Editor Format to PDF",
|
||||
description =
|
||||
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool.")
|
||||
"Rebuilds a PDF from the editable JSON structure generated by the text editor"
|
||||
+ " tool.")
|
||||
public ResponseEntity<Resource> convertJsonToPdf(@ModelAttribute GeneralFile request)
|
||||
throws Exception {
|
||||
MultipartFile jsonFile = request.getFileInput();
|
||||
@@ -137,9 +139,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Extract PDF metadata for text editor lazy loading",
|
||||
description =
|
||||
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
|
||||
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
|
||||
+ " authenticated user.")
|
||||
"Extracts document metadata, fonts, and page dimensions for the text editor"
|
||||
+ " tool. Caches the document for subsequent page requests. Returns a"
|
||||
+ " server-generated jobId scoped to the authenticated user.")
|
||||
public ResponseEntity<Resource> extractPdfMetadata(@ModelAttribute PDFFile request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -181,9 +183,10 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Apply incremental edits from text editor to a cached PDF",
|
||||
description =
|
||||
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
|
||||
+ " Requires the PDF to have been previously cached via the text editor metadata endpoint."
|
||||
+ " The jobId must be obtained from the metadata extraction endpoint.")
|
||||
"Applies edits for the specified pages of a cached PDF and returns an updated"
|
||||
+ " PDF. Requires the PDF to have been previously cached via the text"
|
||||
+ " editor metadata endpoint. The jobId must be obtained from the metadata"
|
||||
+ " extraction endpoint.")
|
||||
public ResponseEntity<Resource> exportPartialPdf(
|
||||
@PathVariable String jobId,
|
||||
@RequestBody PdfJsonDocument document,
|
||||
@@ -224,9 +227,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Extract single page from cached PDF for text editor",
|
||||
description =
|
||||
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
|
||||
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
"Retrieves a single page's content from a previously cached PDF document for"
|
||||
+ " the text editor tool. Requires prior call to /pdf/text-editor/metadata."
|
||||
+ " The jobId must belong to the authenticated user.")
|
||||
public ResponseEntity<Resource> extractSinglePage(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
@@ -253,9 +256,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Extract fonts used by a single cached page for text editor",
|
||||
description =
|
||||
"Retrieves the font payloads used by a single page from a previously cached PDF document."
|
||||
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
"Retrieves the font payloads used by a single page from a previously cached PDF"
|
||||
+ " document. Requires prior call to /pdf/text-editor/metadata. The jobId"
|
||||
+ " must belong to the authenticated user.")
|
||||
public ResponseEntity<Resource> extractPageFonts(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
@@ -285,9 +288,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
"Manually clears a cached PDF document used by the text editor to free up server resources."
|
||||
+ " Called automatically after 30 minutes. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
"Manually clears a cached PDF document used by the text editor to free up"
|
||||
+ " server resources. Called automatically after 30 minutes. The jobId must"
|
||||
+ " belong to the authenticated user.")
|
||||
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
|
||||
|
||||
validateJobAccess(jobId);
|
||||
|
||||
+5
-4
@@ -68,10 +68,11 @@ public class ConvertSvgToPDF {
|
||||
summary = "Convert SVG to PDF",
|
||||
description =
|
||||
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF"
|
||||
+ " format. Each SVG is converted to a separate PDF file. The conversion preserves"
|
||||
+ " vector graphics for crisp output at any resolution - no rasterization occurs."
|
||||
+ " SVG dimensions (width/height) determine the PDF page size; defaults to A4 if"
|
||||
+ " not specified. SVG content is sanitized to prevent XSS attacks.")
|
||||
+ " format. Each SVG is converted to a separate PDF file. The conversion"
|
||||
+ " preserves vector graphics for crisp output at any resolution - no"
|
||||
+ " rasterization occurs. SVG dimensions (width/height) determine the PDF"
|
||||
+ " page size; defaults to A4 if not specified. SVG content is sanitized to"
|
||||
+ " prevent XSS attacks.")
|
||||
public ResponseEntity<Resource> convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) {
|
||||
|
||||
MultipartFile[] inputFiles = request.getFileInput();
|
||||
|
||||
+6
-3
@@ -221,7 +221,8 @@ public class PdfVectorExportController {
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}",
|
||||
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command:"
|
||||
+ " {}",
|
||||
outputFormat.toUpperCase(),
|
||||
result.getRc(),
|
||||
result.getMessages(),
|
||||
@@ -261,7 +262,8 @@ public class PdfVectorExportController {
|
||||
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
|
||||
if (criticalError != null) {
|
||||
log.error(
|
||||
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}",
|
||||
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command:"
|
||||
+ " {}",
|
||||
criticalError.getMessage(),
|
||||
String.join(" ", command));
|
||||
throw criticalError;
|
||||
@@ -269,7 +271,8 @@ public class PdfVectorExportController {
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}",
|
||||
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}."
|
||||
+ " Command: {}",
|
||||
result.getRc(),
|
||||
result.getMessages(),
|
||||
String.join(" ", command));
|
||||
|
||||
+4
-3
@@ -295,7 +295,8 @@ public class FormFillController {
|
||||
@Operation(
|
||||
summary = "Extract form fields as XLSX",
|
||||
description =
|
||||
"Returns an Excel (XLSX) file containing all form field names and their current values")
|
||||
"Returns an Excel (XLSX) file containing all form field names and their current"
|
||||
+ " values")
|
||||
public ResponseEntity<byte[]> extractXlsx(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
@@ -427,8 +428,8 @@ public class FormFillController {
|
||||
@Parameter(
|
||||
description =
|
||||
"Return a ZIP holding the updated PDF plus the field list it"
|
||||
+ " produced, instead of the bare PDF. Saves re-uploading"
|
||||
+ " the result just to read its fields back.")
|
||||
+ " produced, instead of the bare PDF. Saves re-uploading"
|
||||
+ " the result just to read its fields back.")
|
||||
@RequestParam(value = "includeFields", defaultValue = "false")
|
||||
boolean includeFields)
|
||||
throws IOException {
|
||||
|
||||
+4
-3
@@ -79,9 +79,10 @@ public class AddCommentsController {
|
||||
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
|
||||
description =
|
||||
"Attaches PDF Text (sticky-note) annotations to the document. Each CommentSpec"
|
||||
+ " can either supply absolute coordinates or an `anchorText` hint; when provided,"
|
||||
+ " the tool locates the first matching line on the target page and anchors the"
|
||||
+ " icon there (falling back to the coordinates if no match).")
|
||||
+ " can either supply absolute coordinates or an `anchorText` hint; when"
|
||||
+ " provided, the tool locates the first matching line on the target page"
|
||||
+ " and anchors the icon there (falling back to the coordinates if no"
|
||||
+ " match).")
|
||||
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
|
||||
throws IOException {
|
||||
|
||||
|
||||
+2
-1
@@ -149,7 +149,8 @@ public class AttachmentController {
|
||||
@Operation(
|
||||
summary = "Extract attachments from PDF",
|
||||
description =
|
||||
"This endpoint extracts all embedded attachments from a PDF into a ZIP archive.")
|
||||
"This endpoint extracts all embedded attachments from a PDF into a ZIP"
|
||||
+ " archive.")
|
||||
public ResponseEntity<Resource> extractAttachments(
|
||||
@ModelAttribute ExtractAttachmentsRequest request) throws IOException {
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
|
||||
|
||||
+2
-2
@@ -282,8 +282,8 @@ public class AutoSplitPdfController {
|
||||
summary = "Auto split PDF pages into separate documents",
|
||||
description =
|
||||
"This endpoint accepts a PDF file, scans each page for a specific QR code, and"
|
||||
+ " splits the document at the QR code boundaries. The output is a zip file"
|
||||
+ " containing each separate PDF document.")
|
||||
+ " splits the document at the QR code boundaries. The output is a zip file"
|
||||
+ " containing each separate PDF document.")
|
||||
public ResponseEntity<Resource> autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request)
|
||||
throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ public class BlankPageController {
|
||||
summary = "Remove blank pages from a PDF file",
|
||||
description =
|
||||
"This endpoint removes blank pages from a given PDF file. Users can specify the"
|
||||
+ " threshold and white percentage to tune the detection of blank pages.")
|
||||
+ " threshold and white percentage to tune the detection of blank pages.")
|
||||
public ResponseEntity<Resource> removeBlankPages(
|
||||
@ModelAttribute RemoveBlankPagesRequest request)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
+2
-2
@@ -68,8 +68,8 @@ public class ExtractImageScansController {
|
||||
summary = "Extract image scans from an input file",
|
||||
description =
|
||||
"This endpoint extracts image scans from a given file based on certain"
|
||||
+ " parameters. Users can specify angle threshold, tolerance, minimum area,"
|
||||
+ " minimum contour area, and border size.")
|
||||
+ " parameters. Users can specify angle threshold, tolerance, minimum area,"
|
||||
+ " minimum contour area, and border size.")
|
||||
public ResponseEntity<Resource> extractImageScans(
|
||||
@ModelAttribute ExtractImageScansRequest request)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
+4
-3
@@ -45,8 +45,8 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
@Tag(
|
||||
name = "Mobile Scanner",
|
||||
description =
|
||||
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
|
||||
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
|
||||
"Endpoints for mobile-to-desktop file transfer via QR code scanning. Files are"
|
||||
+ " temporarily stored and automatically cleaned up after 10 minutes.")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class MobileScannerController {
|
||||
@@ -271,7 +271,8 @@ public class MobileScannerController {
|
||||
@Operation(
|
||||
summary = "Download a specific file",
|
||||
description =
|
||||
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
||||
"Download a file that was uploaded to a session. File is automatically deleted"
|
||||
+ " after download.")
|
||||
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "404", description = "File or session not found")
|
||||
|
||||
+5
-4
@@ -104,9 +104,9 @@ public class OCRController {
|
||||
summary = "Process a PDF file with OCR",
|
||||
description =
|
||||
"This endpoint processes a PDF file using OCR (Optical Character Recognition)."
|
||||
+ " Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType,"
|
||||
+ " ocrRenderType, and removeImagesAfter options. Uses OCRmyPDF if available,"
|
||||
+ " falls back to Tesseract.")
|
||||
+ " Users can specify languages, sidecar, deskew, clean, cleanFinal,"
|
||||
+ " ocrType, ocrRenderType, and removeImagesAfter options. Uses OCRmyPDF if"
|
||||
+ " available, falls back to Tesseract.")
|
||||
public ResponseEntity<Resource> processPdfWithOCR(
|
||||
@ModelAttribute ProcessPdfWithOcrRequest request)
|
||||
throws IOException, InterruptedException {
|
||||
@@ -442,7 +442,8 @@ public class OCRController {
|
||||
// Verify the OCR'd PDF was created
|
||||
if (!pageOutputPath.exists()) {
|
||||
log.warn(
|
||||
"Tesseract did not create expected output file: {}. Page may be blank or unreadable.",
|
||||
"Tesseract did not create expected output file: {}. Page may be"
|
||||
+ " blank or unreadable.",
|
||||
pageOutputPath.getAbsolutePath());
|
||||
// Save original page without OCR as fallback
|
||||
try (PDDocument pageDoc = new PDDocument()) {
|
||||
|
||||
+4
-3
@@ -50,9 +50,10 @@ public class OverlayImageController {
|
||||
summary = "Overlay image onto a PDF file",
|
||||
description =
|
||||
"This endpoint overlays an image onto a PDF file at the specified coordinates."
|
||||
+ " Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG). SVG"
|
||||
+ " files are rendered as vector graphics for crisp output at any resolution. The"
|
||||
+ " image can be overlaid on every page of the PDF if specified.")
|
||||
+ " Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG)."
|
||||
+ " SVG files are rendered as vector graphics for crisp output at any"
|
||||
+ " resolution. The image can be overlaid on every page of the PDF if"
|
||||
+ " specified.")
|
||||
public ResponseEntity<Resource> overlayImage(@ModelAttribute OverlayImageRequest request) {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
MultipartFile imageFile = request.getImageFile();
|
||||
|
||||
+3
-2
@@ -59,8 +59,9 @@ public class RepairController {
|
||||
summary = "Repair a PDF file",
|
||||
description =
|
||||
"This endpoint repairs a given PDF file by running Ghostscript (primary), qpdf"
|
||||
+ " (fallback), or PDFBox (if no external tools available). The PDF is first saved"
|
||||
+ " to a temporary location, repaired, read back, and then returned as a response.")
|
||||
+ " (fallback), or PDFBox (if no external tools available). The PDF is"
|
||||
+ " first saved to a temporary location, repaired, read back, and then"
|
||||
+ " returned as a response.")
|
||||
public ResponseEntity<Resource> repairPdf(@ModelAttribute PDFFile file)
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = file.getFileInput();
|
||||
|
||||
+2
-2
@@ -43,8 +43,8 @@ public class ReplaceAndInvertColorController {
|
||||
summary = "Replace-Invert Color PDF",
|
||||
description =
|
||||
"This endpoint accepts a PDF file and provides options to invert all colors,"
|
||||
+ " replace text and background colors, or convert to CMYK color space for"
|
||||
+ " printing.")
|
||||
+ " replace text and background colors, or convert to CMYK color space for"
|
||||
+ " printing.")
|
||||
public ResponseEntity<Resource> replaceAndInvertColor(
|
||||
@ModelAttribute ReplaceAndInvertColorRequest request) throws IOException {
|
||||
|
||||
|
||||
+2
-1
@@ -98,7 +98,8 @@ public class StampController {
|
||||
summary = "Add stamp to a PDF file",
|
||||
description =
|
||||
"This endpoint adds a stamp to a given PDF file. Users can specify the stamp"
|
||||
+ " type (text or image), rotation, opacity, width spacer, and height spacer.")
|
||||
+ " type (text or image), rotation, opacity, width spacer, and height"
|
||||
+ " spacer.")
|
||||
public ResponseEntity<Resource> addStamp(@ModelAttribute AddStampRequest request)
|
||||
throws IOException, Exception {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
|
||||
+3
-2
@@ -58,8 +58,9 @@ public class PipelineController {
|
||||
@Operation(
|
||||
summary = "Execute automated PDF processing pipeline",
|
||||
description =
|
||||
"This endpoint processes multiple PDF files through a configurable pipeline of operations. "
|
||||
+ "Users provide files and a JSON configuration defining the sequence of operations to perform.")
|
||||
"This endpoint processes multiple PDF files through a configurable pipeline of"
|
||||
+ " operations. Users provide files and a JSON configuration defining the"
|
||||
+ " sequence of operations to perform.")
|
||||
public ResponseEntity<Resource> handleData(@ModelAttribute HandleDataRequest request)
|
||||
throws DatabindException, JacksonException {
|
||||
MultipartFile[] files = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -177,8 +177,8 @@ public class CertSignController {
|
||||
summary = "Sign PDF with a Digital Certificate",
|
||||
description =
|
||||
"This endpoint accepts a PDF file, a digital certificate and related"
|
||||
+ " information to sign the PDF. It then returns the digitally signed PDF"
|
||||
+ " file.")
|
||||
+ " information to sign the PDF. It then returns the digitally signed PDF"
|
||||
+ " file.")
|
||||
public ResponseEntity<Resource> signPDFWithCert(
|
||||
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
|
||||
throws Exception {
|
||||
|
||||
+5
-5
@@ -99,8 +99,8 @@ public class RedactController {
|
||||
summary = "Redacts areas and pages in a PDF document",
|
||||
description =
|
||||
"This endpoint redacts content from a PDF file based on manually specified"
|
||||
+ " areas. Users can specify areas to redact and optionally convert the PDF to an"
|
||||
+ " image.")
|
||||
+ " areas. Users can specify areas to redact and optionally convert the PDF"
|
||||
+ " to an image.")
|
||||
public ResponseEntity<Resource> redactPDF(@ModelAttribute ManualRedactPdfRequest request)
|
||||
throws IOException {
|
||||
|
||||
@@ -146,8 +146,8 @@ public class RedactController {
|
||||
operationId = "redactPdfAuto",
|
||||
description =
|
||||
"This endpoint automatically redacts text from a PDF file based on specified"
|
||||
+ " patterns. Users can provide text patterns to redact, with options for regex"
|
||||
+ " and whole word matching.")
|
||||
+ " patterns. Users can provide text patterns to redact, with options for"
|
||||
+ " regex and whole word matching.")
|
||||
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
|
||||
if (request.getFileInput() == null || request.getFileInput().isEmpty()) {
|
||||
log.error("File input is null or empty");
|
||||
@@ -299,7 +299,7 @@ public class RedactController {
|
||||
summary = "Execute a unified redaction plan on a PDF",
|
||||
description =
|
||||
"Unified redaction endpoint that accepts exact strings, regex patterns, and"
|
||||
+ " page numbers in a single request. Supports execution strategy hints.")
|
||||
+ " page numbers in a single request. Supports execution strategy hints.")
|
||||
public ResponseEntity<Resource> executeRedaction(@ModelAttribute RedactExecuteRequest request)
|
||||
throws IOException {
|
||||
|
||||
|
||||
+6
-3
@@ -73,7 +73,8 @@ class RedactExecuteService {
|
||||
boolean hasTextOps = !textValues.isEmpty() || !regexPatterns.isEmpty();
|
||||
|
||||
log.info(
|
||||
"[redact/execute] strategy={} textValues={} regexPatterns={} wipePages={} ranges={} imageBoxes={} imagePages={}",
|
||||
"[redact/execute] strategy={} textValues={} regexPatterns={} wipePages={} ranges={}"
|
||||
+ " imageBoxes={} imagePages={}",
|
||||
style.getStrategy(),
|
||||
textValues.size(),
|
||||
regexPatterns.size(),
|
||||
@@ -107,7 +108,8 @@ class RedactExecuteService {
|
||||
needsOverlayOnly = applyTextRemoval(document, request);
|
||||
} else if (overlayOnly) {
|
||||
log.info(
|
||||
"[redact/execute] overlay-only mode requested — skipping content-stream rewriting");
|
||||
"[redact/execute] overlay-only mode requested — skipping content-stream"
|
||||
+ " rewriting");
|
||||
}
|
||||
|
||||
// Reload fresh document on fallback so we overlay onto clean content.
|
||||
@@ -458,7 +460,8 @@ class RedactExecuteService {
|
||||
}
|
||||
if (end == null) {
|
||||
log.warn(
|
||||
"[redact/execute] no end anchor after start at (page={}, col={}, y={}) — skipping",
|
||||
"[redact/execute] no end anchor after start at (page={}, col={}, y={})"
|
||||
+ " — skipping",
|
||||
start.page + 1,
|
||||
start.col,
|
||||
start.y);
|
||||
|
||||
+4
-2
@@ -129,7 +129,8 @@ class TextRedactionService {
|
||||
result != null ? result.totalMatches() : -1);
|
||||
if (result == null) {
|
||||
log.warn(
|
||||
"JPDFium PdfRedactor.redact returned null result, falling back to box-only redaction mode");
|
||||
"JPDFium PdfRedactor.redact returned null result, falling back to box-only"
|
||||
+ " redaction mode");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,7 +154,8 @@ class TextRedactionService {
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"JPDFium native text replacement failed, falling back to box-only redaction mode: {}",
|
||||
"JPDFium native text replacement failed, falling back to box-only redaction"
|
||||
+ " mode: {}",
|
||||
e.getMessage());
|
||||
return true;
|
||||
} finally {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user