diff --git a/.dockerignore b/.dockerignore index 492480e3a0..12326e5bc5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -96,6 +96,14 @@ configs/ __pycache__/ **/__pycache__/ +# Python virtualenvs. Large, platform-specific, and their symlinks break the build. +.venv/ +**/.venv/ +venv/ +**/venv/ +*.egg-info/ +**/*.egg-info/ + # Local env .env .env.* diff --git a/.editorconfig b/.editorconfig index 665a74a09a..e6bda814c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,26 +22,15 @@ indent_size = 4 [*.html] indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false -[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}] indent_size = 2 [*.css] -# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards. indent_size = 2 [*.{yml,yaml}] -# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices. -indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false - -[*.json] -# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting. indent_size = 2 -[*.jsonc] -# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces. +[*.{json,jsonc}] indent_size = 2 diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index fb6a99cfca..c47c8e3ee7 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.2 +pkgver=2.14.3 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index 70bcee0423..d6159ddce3 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.2 +pkgver=2.14.3 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index e6e4f08230..7936145677 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -1,13 +1,15 @@ -# CI routing infra. Editing the top-level router (build.yml) or this filter -# config re-runs every area's jobs, so every job-gating filter below includes -# *ci. That makes a change to how jobs are dispatched actually exercise those -# jobs (self-testing), instead of a router edit only matching the project filter. +# CI routing infrastructure. Changes to the top-level router (build.yml) or +# this filter configuration rerun every area's jobs. Every job-gating filter +# therefore includes *ci, so routing changes exercise the jobs they affect +# instead of matching only the project filter. ci: &ci - .github/workflows/build.yml + - .github/workflows/gradle-cache-prime.yml - .github/config/.files.yaml build: &build - *ci + - buildSrc/** - build.gradle - gradle/spotless.gradle - app/(common|core|proprietary|saas)/build.gradle @@ -15,6 +17,22 @@ build: &build - .taskfiles/backend.yml - .github/workflows/check-licence.yml +# Backend build inputs. This is intentionally broader than `build`: Java and +# backend resource changes must exercise the backend matrix even when Gradle +# build scripts themselves are unchanged. +backend: &backend + - *ci + - *build + - gradle/** + - gradle.properties + - gradlew + - gradlew.bat + - settings.gradle + - app/(common|core|proprietary|saas)/src/(main|test)/java/** + - "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" + - scripts/db-migration/** + - .github/workflows/backend-build.yml + openapi: &openapi - *ci - *build @@ -24,9 +42,9 @@ openapi: &openapi docker-base: &docker-base - docker/base/Dockerfile -# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch -# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a -# Dockerfile itself changes, not on every code PR. +# Dockerfiles only (base, embedded, and unoserver). The slow multi-architecture +# (arm64) leg of the PR Docker test build runs only when a Dockerfile changes, +# rather than for every code PR. dockerfiles: &dockerfiles - docker/**/Dockerfile* @@ -68,7 +86,6 @@ project: &project frontend: &frontend - *ci - frontend/** - - .github/workflows/testdriver.yml - testing/** - docker/** - scripts/translations/*.py @@ -88,8 +105,8 @@ frontend: &frontend - .github/workflows/e2e-stubbed.yml - .github/workflows/e2e-live.yml -# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build -# job on changes to any of these. +# Files that affect the Tauri desktop bundle. Changes to any of these files +# trigger the multi-OS Tauri build job. tauri: &tauri - *ci - frontend/editor/src-tauri/** @@ -102,9 +119,9 @@ tauri: &tauri - Taskfile.yml - .taskfiles/desktop.yml -# Files that affect the AI engine (Python tool models, fixers, tests). Gate -# the engine validation job on changes to engine sources or to the Java -# tool surfaces it generates models from. +# Files that affect the AI engine, including its Python tool models, fixers, +# and tests. The engine validation job also runs when the Java tool surfaces +# used to generate those models change. engine: &engine - *ci - engine/** @@ -114,10 +131,10 @@ engine: &engine - .taskfiles/engine.yml # Files that can make the committed generated API models (frontend tool API -# types + engine tool models) go stale: the Java tool surfaces they derive from, -# the generators, the generated files themselves (to catch a hand-edit), and the -# tasks that drive generation. Deliberately excludes the broad frontend/docker/ -# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec. +# types and engine tool models) stale: their Java sources, generators, +# generated outputs (to catch hand edits), and generation tasks. Broad +# frontend, Docker, and testing globs are intentionally excluded, so a CSS-only +# PR does not start the backend to rebuild the specification. generated-models: &generated-models - *ci - *openapi @@ -141,8 +158,8 @@ licenses-backend: &licenses-backend - ".github/workflows/frontend-backend-licenses-update.yml" - *build -# Files that can affect premium / enterprise behaviour. Gate the enterprise -# Playwright job on changes to any of these on PRs. +# Files that can affect premium or enterprise behaviour. Changes to any of +# these files trigger the enterprise Playwright job for pull requests. proprietary: &proprietary - *ci - app/proprietary/** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 92de1d9503..883c4f7f46 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -65,6 +65,7 @@ updates: directories: - /devTools - /frontend + - /testing/compose/mcp-client-check schedule: interval: "weekly" cooldown: @@ -93,6 +94,13 @@ updates: - "react-dom" - "@types/react" - "@types/react-dom" + tanstack: + patterns: + - "@tanstack/*" + typescript: + patterns: + - "typescript" + - "@typescript/*" vite: patterns: - "vite" @@ -171,14 +179,6 @@ updates: - "tokio" - "tokio-*" - - package-ecosystem: pip - directory: /testing/cucumber - schedule: - interval: "weekly" - cooldown: - default-days: 7 - rebase-strategy: "auto" - - package-ecosystem: "uv" directory: "/engine" schedule: diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 50be9fe4cf..6420712640 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -26,6 +26,10 @@ jobs: check-pr: if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + # Only reads the PR via pulls.get with the default GITHUB_TOKEN. + permissions: + contents: read + pull-requests: read outputs: should_deploy: ${{ steps.decide.outputs.should_deploy }} is_fork: ${{ steps.resolve.outputs.is_fork }} @@ -35,7 +39,7 @@ jobs: pr_ref: ${{ steps.resolve.outputs.ref }} 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 @@ -97,6 +101,7 @@ jobs: echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT deploy-v2-pr: + environment: pr-preview needs: check-pr runs-on: ubuntu-latest if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true') @@ -107,6 +112,7 @@ jobs: permissions: contents: read issues: write + packages: write pull-requests: write env: # Single source of truth for whether this preview embeds the admin portal: @@ -115,7 +121,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 @@ -125,20 +131,11 @@ jobs: repository: ${{ github.repository }} ref: main - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Add deployment started comment id: deployment-started uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ needs.check-pr.outputs.pr_number }}; @@ -180,7 +177,8 @@ jobs: with: repository: ${{ needs.check-pr.outputs.pr_repository }} ref: ${{ needs.check-pr.outputs.pr_ref }} - token: ${{ secrets.GITHUB_TOKEN }} + # untrusted tree is built below - never leave credentials in .git/config + persist-credentials: false fetch-depth: 0 # Fetch full history for commit hash detection - name: Set up Docker Buildx @@ -192,11 +190,16 @@ jobs: VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - - name: Login to Docker Hub + - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - name: Get commit hash for app id: commit-hash @@ -220,7 +223,7 @@ jobs: - name: Check if image exists id: check-image run: | - if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then + if docker manifest inspect ${IMAGE_BASE}:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT echo "Image already exists, skipping build" else @@ -228,6 +231,8 @@ jobs: echo "Image needs to be built" fi + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - name: Build and push V2 image if: steps.check-image.outputs.exists == 'false' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 @@ -237,7 +242,7 @@ jobs: push: true cache-from: type=gha,scope=stirling-pdf-latest cache-to: type=gha,mode=max,scope=stirling-pdf-latest - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} + tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-${{ steps.commit-hash.outputs.app_short }} build-args: | VERSION_TAG=v2-alpha BUILD_PORTAL=${{ env.BUILD_PORTAL }} @@ -246,9 +251,11 @@ jobs: - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Deploy V2 to VPS id: deploy run: | @@ -261,7 +268,7 @@ jobs: services: stirling-pdf-v2: container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }} - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} + image: ${IMAGE_BASE}:v2-${{ steps.commit-hash.outputs.app_short }} ports: - "${V2_PORT}:8080" volumes: @@ -273,8 +280,8 @@ jobs: DISABLE_ADDITIONAL_FEATURES: "false" STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" SECURITY_ENABLELOGIN: "true" - SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" - SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" + SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}" + SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}" SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}" UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture" @@ -288,9 +295,9 @@ jobs: EOF # Deploy to VPS - scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml + scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose-v2.yml - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH # Create V2 PR-specific directories mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage} @@ -315,12 +322,19 @@ jobs: # Set port for output echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + TEST_LOGIN_USERNAME: ${{ secrets.TEST_LOGIN_USERNAME }} + TEST_LOGIN_PASSWORD: ${{ secrets.TEST_LOGIN_PASSWORD }} + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + # ---- Storybook preview (only when this PR touches stories/.storybook) ---- # Runs inside the same approved-contributor-gated deploy job, so it deploys # under the exact same access rules as the app preview. - name: Detect Storybook changes id: sb-changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: list-files: json filters: | @@ -379,8 +393,9 @@ jobs: env: SB_URL: ${{ steps.storybook.outputs.url }} SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ needs.check-pr.outputs.pr_number }}; @@ -401,7 +416,7 @@ jobs: } } - const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`; + const deploymentUrl = `http://${process.env.NEW_VPS_HOST}:${v2Port}`; // Only mention the portal when this image actually embeds it. // Use the direct IP URL - the SSL hostname isn't supported yet. @@ -447,6 +462,10 @@ jobs: }); cleanup-v2-deployment: + # Tearing a preview down is not a deployment - no deployment object. + environment: + name: pr-preview + deployment: false if: github.event.action == 'closed' runs-on: ubuntu-latest permissions: @@ -456,26 +475,17 @@ 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Clean up V2 deployment comments uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ github.event.pull_request.number }}; @@ -504,12 +514,14 @@ jobs: - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Cleanup V2 deployment run: | - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH' + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << 'ENDSSH' if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then echo "Found V2 PR directory, proceeding with cleanup..." @@ -542,8 +554,11 @@ jobs: # Only remove PR-specific containers and directories ENDSSH + env: + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - name: Cleanup temporary files if: always() run: | - rm -f ../private.key + rm -f ../private.key docker-compose.yml storybook.tgz continue-on-error: true diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index c804489836..111dba441f 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -37,7 +37,8 @@ jobs: check-comment: runs-on: ubuntu-latest permissions: - issues: write + contents: read # actions/checkout + issues: write # add reaction to the triggering issue comment if: | vars.CI_PROFILE != 'lite' && ( github.event_name == 'workflow_dispatch' || @@ -69,22 +70,13 @@ jobs: enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }} 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 - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Get PR data id: get-pr uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -155,7 +147,7 @@ jobs: id: add-eyes-reaction uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`); try { @@ -174,35 +166,38 @@ jobs: } deploy-pr: + environment: pr-preview needs: check-comment runs-on: ubuntu-latest permissions: - issues: write + contents: read # actions/checkout, incl. the PR merge ref + issues: write # reactions, 'pr-deployed' label, deployment URL comment pull-requests: write + packages: write # push PR image to ghcr.io 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 - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge - token: ${{ steps.setup-bot.outputs.token }} + # untrusted tree gets built below - never leave credentials in .git/config + persist-credentials: false + + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 @@ -210,17 +205,6 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Run Gradle Command @@ -240,11 +224,16 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Login to Docker Hub + - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - name: Build and push PR-specific image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 @@ -254,7 +243,7 @@ jobs: push: true cache-from: type=gha,scope=stirling-pdf-latest cache-to: type=gha,mode=max,scope=stirling-pdf-latest - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }} + tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:pr-${{ needs.check-comment.outputs.pr_number }} build-args: | VERSION_TAG=alpha PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }} @@ -269,15 +258,17 @@ jobs: push: true cache-from: type=gha,scope=stirling-pdf-engine cache-to: type=gha,mode=max,scope=stirling-pdf-engine - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }} + tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:engine-pr-${{ needs.check-comment.outputs.pr_number }} platforms: linux/amd64 - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Deploy to VPS id: deploy run: | @@ -295,11 +286,11 @@ jobs: # Set pro/enterprise settings (enterprise implies pro) if [ "${{ needs.check-comment.outputs.enable_enterprise }}" == "true" ]; then PREMIUM_ENABLED="true" - PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}" + PREMIUM_KEY="${ENTERPRISE_KEY}" PREMIUM_PROFEATURES_AUDIT_ENABLED="true" elif [ "${{ needs.check-comment.outputs.enable_pro }}" == "true" ]; then PREMIUM_ENABLED="true" - PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}" + PREMIUM_KEY="${PRO_KEY}" PREMIUM_PROFEATURES_AUDIT_ENABLED="true" else PREMIUM_ENABLED="false" @@ -309,7 +300,6 @@ jobs: ENABLE_PROTOTYPES="${{ needs.check-comment.outputs.enable_prototypes }}" PR_NUMBER="${{ needs.check-comment.outputs.pr_number }}" - DOCKER_USER="${{ secrets.DOCKER_HUB_USERNAME }}" # Build engine env vars for backend (only set when prototypes enabled) if [ "$ENABLE_PROTOTYPES" == "true" ]; then @@ -319,9 +309,9 @@ jobs: ENGINE_SERVICE=" stirling-pdf-engine: container_name: stirling-pdf-engine-pr-${PR_NUMBER} - image: ${DOCKER_USER}/test:engine-pr-${PR_NUMBER} + image: ${IMAGE_BASE}:engine-pr-${PR_NUMBER} environment: - ANTHROPIC_API_KEY: \"${{ secrets.ANTHROPIC_API_KEY }}\" + ANTHROPIC_API_KEY: \"${ANTHROPIC_API_KEY}\" networks: - pr-network restart: on-failure:5" @@ -344,7 +334,7 @@ jobs: services: stirling-pdf: container_name: stirling-pdf-pr-${PR_NUMBER} - image: ${DOCKER_USER}/test:pr-${PR_NUMBER} + image: ${IMAGE_BASE}:pr-${PR_NUMBER} ports: - "${PR_NUMBER}:8080" volumes: @@ -368,9 +358,9 @@ jobs: EOF # Then copy the file and execute commands - scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml + scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH # Create PR-specific directories mkdir -p /stirling/PR-${PR_NUMBER}/{data,config,logs} @@ -386,11 +376,19 @@ jobs: # Set output for use in PR comment echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV + env: + ENTERPRISE_KEY: ${{ secrets.ENTERPRISE_KEY }} + # named PRO_KEY, not PREMIUM_KEY, so the shell var it feeds is not self-referential + PRO_KEY: ${{ secrets.PREMIUM_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - name: Add success reaction to comment if: success() && github.event_name == 'issue_comment' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`); try { @@ -425,7 +423,7 @@ jobs: if: failure() && github.event_name == 'issue_comment' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`); try { @@ -444,15 +442,17 @@ jobs: - name: Post deployment URL to PR if: success() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { GITHUB_REPOSITORY } = process.env; const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/'); const prNumber = ${{ needs.check-comment.outputs.pr_number }}; const securityStatus = process.env.security_status || "Security Disabled"; - const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`; + const deploymentUrl = `http://${process.env.NEW_VPS_HOST}:${prNumber}`; const commentBody = `## šŸš€ PR Test Deployment\n\n` + `Your PR has been deployed for testing!\n\n` + `šŸ”— **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` + @@ -477,26 +477,22 @@ jobs: handle-label-commands: if: ${{ github.event.issue.pull_request != null }} runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout, reads repo_devs.json and labels.yml + issues: write # add/remove labels, delete the command comment 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 - name: Check out the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Apply label commands uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const fs = require('fs'); const path = require('path'); diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 146f5c7f78..f2912dcedb 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -7,41 +7,33 @@ on: permissions: contents: read -env: - SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets - CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred - jobs: cleanup: + # Tearing a preview down is not a deployment - no deployment object. + environment: + name: pr-preview + deployment: false if: github.event.action == 'closed' runs-on: ubuntu-latest permissions: + contents: read # actions/checkout pull-requests: write - issues: write + issues: write # list/remove labels, list/delete comments 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 - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Remove 'pr-deployed' label if present id: remove-label-comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const prNumber = ${{ github.event.pull_request.number }}; const owner = context.repo.owner; @@ -100,14 +92,22 @@ jobs: if: steps.remove-label-comment.outputs.present == 'true' run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - name: Cleanup PR deployment if: steps.remove-label-comment.outputs.present == 'true' id: cleanup + # ENDSSH heredoc is quoted, so its body is sent literally: secrets inside it + # must stay as GitHub expressions, a shell var would be empty on the remote host. run: | - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH' + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << 'ENDSSH' if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then echo "Found PR directory, proceeding with cleanup..." @@ -122,8 +122,8 @@ jobs: rm -rf /stirling/PR-${{ github.event.pull_request.number }} # Remove the Docker images - docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true - docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ github.event.pull_request.number }} || true + docker rmi --no-prune ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:pr-${{ github.event.pull_request.number }} || true + docker rmi --no-prune ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:engine-pr-${{ github.event.pull_request.number }} || true echo "PERFORMED_CLEANUP" else @@ -131,6 +131,9 @@ jobs: echo "NO_CLEANUP_NEEDED" fi ENDSSH + env: + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - name: Cleanup temporary files if: always() diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index 0b3f76a8cd..ca2337960a 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -38,7 +38,7 @@ jobs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 056dc35ca3..caa5af1acb 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml deleted file mode 100644 index 563e94c9b3..0000000000 --- a/.github/workflows/ai_pr_title_review.yml +++ /dev/null @@ -1,228 +0,0 @@ -name: AI - PR Title Review - -on: - pull_request: - types: [opened, edited] - branches: [main] - -permissions: # required for secure-repo hardening - contents: read - -jobs: - ai-title-review: - permissions: - contents: read - pull-requests: write - models: read - - runs-on: ubuntu-latest - - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Configure Git to suppress detached HEAD warning - run: git config --global advice.detachedHead false - - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - - name: Check if actor is repo developer - id: actor - run: | - if [[ "${{ github.actor }}" == *"[bot]" ]]; then - echo "PR opened by a bot – skipping AI title review." - echo "is_repo_dev=false" >> $GITHUB_OUTPUT - exit 0 - fi - if [ ! -f .github/config/repo_devs.json ]; then - echo "Error: .github/config/repo_devs.json not found" >&2 - exit 1 - fi - # Validate JSON and extract repo_devs - REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; }) - # Convert developer list into Bash array - mapfile -t DEVS_ARRAY <<< "$REPO_DEVS" - if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then - echo "is_repo_dev=true" >> $GITHUB_OUTPUT - else - echo "is_repo_dev=false" >> $GITHUB_OUTPUT - fi - - - name: Get PR diff - if: steps.actor.outputs.is_repo_dev == 'true' - id: get_diff - run: | - git fetch origin ${{ github.base_ref }} - git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff - echo "diff<> $GITHUB_OUTPUT - cat pr.diff >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Check and sanitize PR title - if: steps.actor.outputs.is_repo_dev == 'true' - id: sanitize_pr_title - env: - PR_TITLE_RAW: ${{ github.event.pull_request.title }} - run: | - # Sanitize PR title: max 72 characters, only printable characters - PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g') - if [[ ${#PR_TITLE} -lt 5 ]]; then - echo "PR title is too short. Must be at least 5 characters." >&2 - fi - echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT - - - name: AI PR Title Analysis - if: steps.actor.outputs.is_repo_dev == 'true' - id: ai-title-analysis - uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1 - with: - model: openai/gpt-4o - system-prompt-file: ".github/config/system-prompt.txt" - prompt: | - Based on the following input data: - - { - "diff": "${{ steps.get_diff.outputs.diff }}", - "pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}" - } - - Respond ONLY with valid JSON in the format: - { - "improved_rating": <0-10>, - "improved_ai_title_rating": <0-10>, - "improved_title": "" - } - - - name: Validate and set SCRIPT_OUTPUT - if: steps.actor.outputs.is_repo_dev == 'true' - run: | - cat < ai_response.json - ${{ steps.ai-title-analysis.outputs.response }} - EOF - - # Validate JSON structure - jq -e ' - (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and - (.improved_rating | type == "number" and . >= 0 and . <= 10) and - (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and - (.improved_title | type == "string") - ' ai_response.json - if [ $? -ne 0 ]; then - echo "Invalid AI response format" >&2 - cat ai_response.json >&2 - exit 1 - fi - # Parse JSON fields - IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json) - IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json) - # Limit comment length to 1000 characters - COMMENT=$(cat < /tmp/ai-title-comment.md - # Log input and output to the GitHub Step Summary - echo "### šŸ¤– AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY - echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY - echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY - echo '```json' >> $GITHUB_STEP_SUMMARY - cat ai_response.json >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - - name: Post comment on PR if needed - if: steps.actor.outputs.is_repo_dev == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - continue-on-error: true - with: - github-token: ${{ steps.setup-bot.outputs.token }} - script: | - const fs = require('fs'); - const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8'); - const { GITHUB_REPOSITORY } = process.env; - const [owner, repo] = GITHUB_REPOSITORY.split('/'); - const issue_number = context.issue.number; - - const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/); - const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null; - - const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]"; - const comments = await github.rest.issues.listComments({ owner, repo, issue_number }); - - const existing = comments.data.find(c => - c.user?.login === expectedActor && - c.body.includes("## šŸ¤– AI PR Title Suggestion") - ); - - if (rating === null) { - console.log("No rating found in AI response – skipping."); - return; - } - - if (rating <= 5) { - if (existing) { - await github.rest.issues.updateComment({ - owner, repo, - comment_id: existing.id, - body - }); - console.log("Updated existing suggestion comment."); - } else { - await github.rest.issues.createComment({ - owner, repo, issue_number, - body - }); - console.log("Created new suggestion comment."); - } - } else { - const praise = `## šŸ¤– AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\nāœ… No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`; - - if (existing) { - await github.rest.issues.updateComment({ - owner, repo, - comment_id: existing.id, - body: praise - }); - console.log("Replaced suggestion with praise."); - } else { - console.log("Rating > 5 and no existing comment – skipping comment."); - } - } - - - name: is not repo dev - if: steps.actor.outputs.is_repo_dev != 'true' - run: | - exit 0 # Skip the AI title review for non-repo developers - - - name: Clean up - if: always() - run: | - rm -f pr.diff ai_response.json /tmp/ai-title-comment.md - echo "Cleaned up temporary files." - continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index f5af23da07..8c658ec69e 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -26,7 +26,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} 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 @@ -66,11 +66,12 @@ jobs: echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT" publish-aur: + environment: package-publish needs: get-release-info runs-on: ubuntu-latest 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 diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index 6039c0e7df..a5ce864d2b 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -13,26 +13,21 @@ jobs: labeler: runs-on: ubuntu-latest permissions: - pull-requests: write + contents: read # checkout + labeler fetching its config from the repo + pull-requests: write # read changed files, apply labels to the PR + issues: write # labels are applied through the issues API 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 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0 with: config_path: .github/labeler-config-srvaroa.yml use_local_config: false fail_on_error: true env: - GITHUB_TOKEN: "${{ steps.setup-bot.outputs.token }}" + GITHUB_TOKEN: "${{ github.token }}" diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 3cba3c8d39..09c6bbc9a8 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -20,6 +20,9 @@ permissions: jobs: build: + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest strategy: fail-fast: false @@ -28,29 +31,26 @@ jobs: flavor: [core, proprietary, saas] 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK ${{ matrix.jdk-version }} uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: ${{ matrix.jdk-version }} distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Check Java formatting (Spotless) @@ -155,7 +155,7 @@ jobs: STIRLING_FLAVOR: ${{ matrix.flavor }} # Configure the Gradle daemon explicitly; GRADLE_OPTS alone only # configures the Gradle client JVM. - GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC' + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC" - name: Check Test Reports Exist if: always() @@ -197,7 +197,7 @@ jobs: - name: Install uv if: always() && matrix.flavor == 'saas' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index f1301b6fd2..a5a346db88 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright) on: workflow_call: + inputs: + use_shared_cache: + required: false + type: boolean + default: false push: branches: ["main"] schedule: @@ -37,6 +42,9 @@ jobs: uses: ./.github/workflows/_runner-pick.yml playwright-e2e-enterprise: + environment: + name: ci-unsigned + deployment: false needs: pick # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE, # so the suite can't boot premium and would fail. See the header comment. @@ -50,26 +58,36 @@ jobs: SYSTEM_ENABLEANALYTICS: "false" 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - java-version: "25" - distribution: "temurin" - - name: Cache Gradle User Home + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Restore cache Gradle + if: inputs.use_shared_cache == false uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -306,9 +324,18 @@ jobs: path: frontend/playwright-report/ retention-days: 7 + - name: Cleanup temporary files + if: always() + run: | + rm -f /tmp/helpers.sh /tmp/backend.log /tmp/backend.pid + continue-on-error: true + # 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 + deployment: false needs: [pick, playwright-e2e-enterprise] # Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret. if: >- @@ -324,13 +351,13 @@ jobs: MN_COMPOSE: docker-compose-multinode.yml 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69013f5554..16279c67f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,7 @@ jobs: timeout-minutes: 3 outputs: build: ${{ steps.changes.outputs.build }} + backend: ${{ steps.changes.outputs.backend }} project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} frontend: ${{ steps.changes.outputs.frontend }} @@ -48,54 +49,25 @@ jobs: proprietary: ${{ steps.changes.outputs.proprietary }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes with: filters: .github/config/.files.yaml gradle-cache-prime: - name: Prime shared Gradle cache needs: [files-changed] - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Resolve backend dependencies - run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon - env: - STIRLING_FLAVOR: saas - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + uses: ./.github/workflows/gradle-cache-prime.yml + secrets: inherit build: + if: needs.files-changed.outputs.backend == 'true' needs: [files-changed, gradle-cache-prime] permissions: actions: read @@ -134,9 +106,10 @@ jobs: uses: ./.github/workflows/frontend-validation.yml secrets: inherit - # Advisory: deliberately NOT in all-checks-passed. It reports on the stories a - # branch touches so a regression is visible in review, but a browser scan is - # too new here to block merges on. Promote it once its pass/fail proves stable. + # Required (in all-checks-passed). Scans the stories a branch touches in both + # light and dark; an axe violation in either theme blocks the merge. The + # whole-suite sweep (nightly.yml) still covers stories a change affects without + # touching them directly. frontend-a11y: if: needs.files-changed.outputs.frontend == 'true' needs: [files-changed] @@ -168,10 +141,12 @@ jobs: contents: read uses: ./.github/workflows/build-enterprise.yml secrets: inherit + with: + use_shared_cache: true check-licence: if: needs.files-changed.outputs.build == 'true' - needs: [files-changed, build, gradle-cache-prime] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-licence.yml @@ -190,8 +165,22 @@ jobs: docker-base-changed: ${{ needs.files-changed.outputs.docker-base }} test-build-docker-images: - if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' - needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime] + if: | + always() && + github.event_name == 'pull_request' && + needs.files-changed.outputs.project == 'true' && + contains(fromJSON('["success", "skipped"]'), needs.gradle-cache-prime.result) && + contains(fromJSON('["success", "skipped"]'), needs.build.result) && + contains(fromJSON('["success", "skipped"]'), needs.check-generateOpenApiDocs.result) && + contains(fromJSON('["success", "skipped"]'), needs.check-licence.result) + needs: + [ + files-changed, + build, + check-generateOpenApiDocs, + check-licence, + gradle-cache-prime, + ] permissions: contents: read packages: read @@ -203,7 +192,7 @@ jobs: tauri-build: if: needs.files-changed.outputs.tauri == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read pull-requests: write @@ -217,6 +206,7 @@ jobs: with: platform: windows-macos sign: true + use_shared_cache: true ai-engine: if: needs.files-changed.outputs.engine == 'true' @@ -240,6 +230,8 @@ jobs: pull-requests: write uses: ./.github/workflows/check-generated-models.yml secrets: inherit + with: + use_shared_cache: true pre-commit: needs: [files-changed] @@ -286,10 +278,12 @@ jobs: if: always() needs: - files-changed + - gradle-cache-prime - build - db-migration-test - check-generateOpenApiDocs - frontend-validation + - frontend-a11y - playwright-e2e - playwright-e2e-live - playwright-e2e-enterprise @@ -304,7 +298,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -312,10 +306,12 @@ jobs: env: RESULTS: | files-changed=${{ needs.files-changed.result }} + gradle-cache-prime=${{ needs.gradle-cache-prime.result }} build=${{ needs.build.result }} db-migration-test=${{ needs.db-migration-test.result }} check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }} frontend-validation=${{ needs.frontend-validation.result }} + frontend-a11y=${{ needs.frontend-a11y.result }} playwright-e2e=${{ needs.playwright-e2e.result }} playwright-e2e-live=${{ needs.playwright-e2e-live.result }} playwright-e2e-enterprise=${{ needs.playwright-e2e-enterprise.result }} diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 833b9c8e47..476f9d21a7 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -9,6 +9,11 @@ name: Check generated models # post-merge safety net. on: workflow_call: + inputs: + use_shared_cache: + required: false + type: boolean + default: false push: branches: [main] @@ -23,7 +28,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -31,7 +36,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | @@ -39,22 +44,29 @@ jobs: engine/uv.lock cache-suffix: generated-models - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - java-version: "25" - distribution: "temurin" + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - - name: Cache Gradle User Home + - name: Restore cache Gradle + if: inputs.use_shared_cache == false uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index b1692c904d..7626122884 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -10,33 +10,33 @@ permissions: jobs: check-licence: + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Check licenses for compatibility diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index c26bf60aba..47341834a3 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -11,33 +11,33 @@ permissions: jobs: check-generate-openapi-docs: + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Generate OpenAPI documentation diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index d134c80a9b..84347defe2 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -23,29 +23,23 @@ jobs: if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest permissions: + contents: read # Checkout, and read translation files via the contents API issues: write # Allow posting comments on issues/PRs pull-requests: write # Allow writing to pull requests 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 - name: Checkout main branch first uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Get PR data id: get-pr-data uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const prNumber = context.payload.pull_request.number; const repoOwner = context.payload.repository.owner.login; @@ -66,17 +60,18 @@ jobs: - name: Fetch PR changed files id: fetch-pr-changes env: - GH_TOKEN: ${{ steps.setup-bot.outputs.token }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.get-pr-data.outputs.pr_number }} run: | echo "Fetching PR changed files..." echo "Getting list of changed files from PR..." # Check if PR number exists - if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then + if [ -z "${PR_NUMBER}" ]; then echo "Error: PR number is empty" exit 1 fi # Get changed files and filter for TOML translation files - gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR" + gh pr view "${PR_NUMBER}" --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR" # Check if any files were found if [ ! -s changed_files.txt ]; then echo "No TOML translation files changed in this PR" @@ -88,32 +83,36 @@ jobs: - name: Determine reference file id: determine-file uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + # Untrusted, fork-controlled values are passed via env, never interpolated into the script + PR_NUMBER: ${{ steps.get-pr-data.outputs.pr_number }} + REPO_OWNER: ${{ steps.get-pr-data.outputs.repo_owner }} + REPO_NAME: ${{ steps.get-pr-data.outputs.repo_name }} + PR_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + PR_REPO_NAME: ${{ github.event.pull_request.head.repo.name }} + PR_BRANCH: ${{ steps.get-pr-data.outputs.branch }} with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const fs = require("fs"); const path = require("path"); - const prNumber = ${{ steps.get-pr-data.outputs.pr_number }}; - const repoOwner = "${{ steps.get-pr-data.outputs.repo_owner }}"; - const repoName = "${{ steps.get-pr-data.outputs.repo_name }}"; - - const prRepoOwner = "${{ github.event.pull_request.head.repo.owner.login }}"; - const prRepoName = "${{ github.event.pull_request.head.repo.name }}"; - const branch = "${{ steps.get-pr-data.outputs.branch }}"; - - console.log(`Determining reference file for PR #${prNumber}`); - - // Validate inputs + // Validate inputs before any use const validateInput = (input, regex, name) => { - if (!regex.test(input)) { + if (typeof input !== "string" || !regex.test(input)) { throw new Error(`Invalid ${name}: ${input}`); } + return input; }; - validateInput(repoOwner, /^[a-zA-Z0-9_-]+$/, "repository owner"); - validateInput(repoName, /^[a-zA-Z0-9._-]+$/, "repository name"); - validateInput(branch, /^[a-zA-Z0-9._/-]+$/, "branch name"); + const repoOwner = validateInput(process.env.REPO_OWNER, /^[a-zA-Z0-9_-]+$/, "repository owner"); + const repoName = validateInput(process.env.REPO_NAME, /^[a-zA-Z0-9._-]+$/, "repository name"); + const prRepoOwner = validateInput(process.env.PR_REPO_OWNER, /^[a-zA-Z0-9_-]+$/, "PR repository owner"); + const prRepoName = validateInput(process.env.PR_REPO_NAME, /^[a-zA-Z0-9._-]+$/, "PR repository name"); + const branch = validateInput(process.env.PR_BRANCH, /^[a-zA-Z0-9._/-]+$/, "branch name"); + const prNumber = Number(validateInput(process.env.PR_NUMBER, /^[0-9]+$/, "PR number")); + + console.log(`Determining reference file for PR #${prNumber}`); // Get the list of changed files in the PR const { data: files } = await github.rest.pulls.listFiles({ @@ -126,7 +125,7 @@ jobs: const changedFiles = files .filter(file => file.status !== "removed" && - /^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename) + /^frontend\/editor\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename) ) .map(file => file.filename); @@ -196,7 +195,7 @@ jobs: core.exportVariable("REFERENCE_FILE", referenceFilePath); - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | @@ -209,10 +208,12 @@ jobs: - name: Run Python script to check files id: run-check + env: + PR_ACTOR: ${{ github.event.pull_request.user.login }} run: | echo "Running Python script to check TOML files..." uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py \ - --actor ${{ github.event.pull_request.user.login }} \ + --actor "${PR_ACTOR}" \ --reference-file "${REFERENCE_FILE}" \ --branch "pr-branch" \ --files "${FILES_LIST[@]}" > result.txt @@ -245,7 +246,7 @@ jobs: if: env.SCRIPT_OUTPUT != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env; const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/'); @@ -261,7 +262,7 @@ jobs: const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary")); // Only update or create comments by the action user - const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]"; + const expectedActor = "github-actions[bot]"; if (comment && comment.user.login === expectedActor) { // Update existing comment diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 507525f254..899bed7f9d 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -34,31 +34,28 @@ jobs: timeout-minutes: 15 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 377d2daf98..3841ea4410 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -13,34 +13,34 @@ permissions: jobs: migration-test: + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest timeout-minutes: 30 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: 25 distribution: temurin - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # Keep the normal formatting path here so this smoke test exercises the # same Gradle configuration as the backend build. - name: Build Stirling-PDF JAR @@ -81,3 +81,8 @@ jobs: path: /tmp/stirling-migration-failed-*/app.log retention-days: 7 if-no-files-found: warn + + - name: Cleanup temporary files + if: always() + run: rm -rf /tmp/stirling-migration-failed-* + continue-on-error: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 07f1f8ce1a..527dca9703 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest 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 diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml deleted file mode 100644 index c98ec8641c..0000000000 --- a/.github/workflows/deploy-on-v2-commit.yml +++ /dev/null @@ -1,189 +0,0 @@ -name: Auto V2 Deploy on Push - -on: - push: - branches: - - V2 - - deploy-on-v2-commit - -permissions: - contents: read - -jobs: - deploy-v2-on-push: - runs-on: ubuntu-latest - concurrency: - group: deploy-v2-push-V2 - cancel-in-progress: true - - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Get commit hashes for frontend and backend - id: commit-hashes - run: | - # Get last commit that touched the frontend folder, docker/frontend, or docker/compose - FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "") - if [ -z "$FRONTEND_HASH" ]; then - FRONTEND_HASH="no-frontend-changes" - fi - - # Get last commit that touched backend code, docker/backend, or docker/compose - BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "") - if [ -z "$BACKEND_HASH" ]; then - BACKEND_HASH="no-backend-changes" - fi - - echo "Frontend hash: $FRONTEND_HASH" - echo "Backend hash: $BACKEND_HASH" - - echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT - echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT - - # Short hashes for tags - if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then - echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT - else - echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT - fi - - if [ "$BACKEND_HASH" = "no-backend-changes" ]; then - echo "backend_short=no-backend" >> $GITHUB_OUTPUT - else - echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT - fi - - - name: Check if frontend image exists - id: check-frontend - run: | - if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Frontend image already exists, skipping build" - else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Frontend image needs to be built" - fi - - - name: Check if backend image exists - id: check-backend - run: | - if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Backend image already exists, skipping build" - else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Backend image needs to be built" - fi - - - name: Login to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} - - - name: Build and push frontend image - if: steps.check-frontend.outputs.exists == 'false' - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ./docker/frontend/Dockerfile - push: true - cache-from: type=gha,scope=stirling-v2-frontend - cache-to: type=gha,mode=max,scope=stirling-v2-frontend - tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest - build-args: VERSION_TAG=v2-alpha - platforms: linux/amd64 - - - name: Build and push backend image - if: steps.check-backend.outputs.exists == 'false' - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ./docker/backend/Dockerfile - push: true - cache-from: type=gha,scope=stirling-v2-backend - cache-to: type=gha,mode=max,scope=stirling-v2-backend - tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest - build-args: VERSION_TAG=v2-alpha - platforms: linux/amd64 - - - name: Set up SSH - run: | - mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key - chmod 600 ../private.key - - - name: Deploy to VPS on port 3000 - run: | - export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml - - cat > $UNIQUE_NAME << EOF - version: '3.3' - services: - backend: - container_name: stirling-v2-backend - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} - ports: - - "13000:8080" - volumes: - - /stirling/V2/data:/usr/share/tessdata:rw - - /stirling/V2/config:/configs:rw - - /stirling/V2/logs:/logs:rw - environment: - DISABLE_ADDITIONAL_FEATURES: "true" - SECURITY_ENABLELOGIN: "false" - SYSTEM_DEFAULTLOCALE: en-US - UI_APPNAME: "Stirling-PDF V2" - UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split" - UI_APPNAMENAVBAR: "V2 Deployment" - SYSTEM_MAXFILESIZE: "100" - METRICS_ENABLED: "true" - SYSTEM_GOOGLEVISIBILITY: "false" - SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud" - baseUrl: "https://demo.stirlingpdf.cloud" - restart: on-failure:5 - - frontend: - container_name: stirling-v2-frontend - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ports: - - "3000:80" - environment: - VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000" - depends_on: - - backend - restart: on-failure:5 - EOF - - # Copy to remote with unique name - scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME - - # SSH and rename/move atomically to avoid interference - ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH - mkdir -p /stirling/V2/{data,config,logs} - mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml - cd /stirling/V2 - docker-compose down || true - docker-compose pull - docker-compose up -d - docker system prune -af --volumes || true - docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true - ENDSSH - - - name: Cleanup temporary files - if: always() - run: | - rm -f ../private.key diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 05b66ba34b..cf5887a205 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -17,6 +17,9 @@ permissions: jobs: docker-compose-tests: + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest permissions: actions: write @@ -25,30 +28,27 @@ 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 - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # When the PR changes the base image, test.sh builds it locally # (stirling-pdf-base:local) into the daemon image store. A buildx # container builder can't see that store, so skip it here and let @@ -66,11 +66,11 @@ jobs: - name: Install Docker Compose run: | - sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo curl -SL "https://github.com/docker/compose/releases/download/v5.4.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 3c5c12d6e7..b04f5022cc 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -11,48 +11,33 @@ permissions: jobs: playwright-e2e-live: + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest timeout-minutes: 30 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 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # Gradle does not retry 429s, and a cold cache resolving the buildscript - # classpath is exactly where Maven Central rate-limits us. Retry it here, - # where a failure is cheap, instead of inside the backgrounded bootRun. - - name: Prime Gradle dependencies - env: - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - run: | - for attempt in 1 2 3; do - if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then - exit 0 - fi - echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)" - sleep $((attempt * 30)) - done - echo "::error::Gradle could not resolve dependencies after 3 attempts" - exit 1 + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -121,7 +106,7 @@ jobs: fi - name: Install uv if: always() - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index df3c5fa82a..5038a7585a 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -14,6 +14,11 @@ jobs: playwright-e2e: name: playwright-e2e (${{ matrix.browser }}) runs-on: ubuntu-latest + # The image already contains the Playwright browsers and all Linux + # dependencies. This keeps the matrix for per-browser reporting while + # avoiding three concurrent `playwright install --with-deps` runs. + container: + image: mcr.microsoft.com/playwright:v1.58.2-noble@sha256:6446946a1d9fd62d9ae501312a2d76a43ee688542b21622056a372959b65d63d strategy: # One browser breaking must not mask a failure in another - report all. fail-fast: false @@ -27,7 +32,7 @@ jobs: project: stubbed-webkit 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 - name: Checkout repository @@ -40,15 +45,23 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - - name: Install Playwright (${{ matrix.browser }}) - run: task e2e:install -- ${{ matrix.browser }} - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" run: task frontend:build - name: Run stubbed E2E tests (${{ matrix.browser }}) env: + # The official Playwright image expects its browser runtime under + # the root home directory. Keep this scoped to Playwright and use a + # neutral Docker config path so Docker does not read /root/.docker. + HOME: /root + DOCKER_CONFIG: /tmp/playwright-docker-config PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json + NPM_CONFIG_PREFER_OFFLINE: "true" + NPM_CONFIG_FETCH_RETRIES: "5" + NPM_CONFIG_FETCH_RETRY_FACTOR: "2" + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000" + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000" run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3 - name: Flag flaky tests # Runs regardless of the test outcome: a flaky test (passed on retry) diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index e9f259f2ab..247d8375fc 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -3,17 +3,12 @@ name: Frontend a11y regression gate # Reusable workflow called from build.yml when frontend sources change. # # Scans the stories this branch touches in real Chromium and runs axe against -# each. Existing violations are grandfathered in .storybook/a11y-baseline.json; -# the check fails on a NEW violation — a story breaking a rule it wasn't already -# breaking — or on a story that fails to render at all. +# each; the check fails on any axe violation, or on a story that fails to render +# at all. # # Only changed stories, because a full sweep is ~30 minutes: far too slow to sit # in front of every merge. The whole suite is scanned nightly instead # (nightly.yml), which catches anything a branch didn't touch. -# -# Advisory for now: this is not in build.yml's all-checks-passed list, so a -# failure reports without blocking. Promote it once a few weeks of runs show the -# pass/fail is stable. on: workflow_call: @@ -26,7 +21,7 @@ jobs: timeout-minutes: 25 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 - name: Checkout repository diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index f2cca9cee4..96e0edd8ac 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -28,7 +28,7 @@ jobs: licenses-backend: ${{ steps.changes.outputs.licenses-backend }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -36,12 +36,16 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes with: filters: .github/config/.files.yaml generate-frontend-license-report: + # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only. + environment: + name: ci-bot + deployment: false if: needs.files-changed.outputs.licenses-frontend == 'true' name: Generate Frontend License Report needs: files-changed @@ -52,7 +56,7 @@ jobs: repository-projects: write # Required for enabling automerge 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 @@ -316,6 +320,10 @@ jobs: GH_TOKEN: ${{ steps.setup-bot.outputs.token }} generate-backend-license-report: + # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only. + environment: + name: ci-bot + deployment: false if: needs.files-changed.outputs.licenses-backend == 'true' needs: files-changed name: Generate Backend License Report @@ -326,7 +334,7 @@ jobs: repository-projects: write # Required for enabling automerge 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 @@ -344,22 +352,19 @@ jobs: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 7e2602c311..2650a945d6 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest 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 - name: Checkout repository @@ -107,7 +107,7 @@ jobs: } - name: Install uv if: always() - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/gradle-cache-prime.yml b/.github/workflows/gradle-cache-prime.yml new file mode 100644 index 0000000000..1c79ee4d12 --- /dev/null +++ b/.github/workflows/gradle-cache-prime.yml @@ -0,0 +1,66 @@ +name: Prime Gradle Cache + +on: + workflow_call: + push: + branches: ["main"] + +permissions: + contents: read + +jobs: + gradle-cache-prime: + environment: + name: ci-unsigned + deployment: false + name: Prime shared Gradle cache + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Calculate Gradle cache key + id: gradle-cache-key + shell: bash + run: | + echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT" + + - name: Cache Gradle (lookup-only) + id: cache-gradle-restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} + lookup-only: true + + - name: Set up JDK 25 + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + + - name: Resolve backend dependencies + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + run: ./gradlew :stirling-pdf:classes --no-daemon + env: + STIRLING_FLAVOR: saas + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + - name: Save cache Gradle User Home + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 571479b030..cc27e45946 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f02e6c612c..9071997ad7 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -38,6 +38,9 @@ permissions: jobs: determine-matrix: + environment: + name: ci-unsigned + deployment: false if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest outputs: @@ -45,29 +48,26 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} 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 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Get version number @@ -93,7 +93,7 @@ jobs: ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX" if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - case "${{ github.event.inputs.platform }}" in + case "${INPUT_PLATFORM}" in "windows") echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT ;; @@ -115,7 +115,12 @@ jobs: echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT fi + env: + INPUT_PLATFORM: ${{ github.event.inputs.platform }} build-jars: + environment: + name: ci-unsigned + deployment: false needs: determine-matrix runs-on: ubuntu-latest strategy: @@ -135,29 +140,26 @@ jobs: file_suffix: "-server" 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 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Setup Node.js if: matrix.variant.build_frontend == true uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -194,6 +196,7 @@ jobs: retention-days: 1 build: + environment: release-signing needs: determine-matrix strategy: fail-fast: false @@ -201,11 +204,10 @@ jobs: runs-on: ${{ matrix.platform }} env: SM_API_KEY: ${{ secrets.SM_API_KEY }} - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} 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 allowed-endpoints: > @@ -234,6 +236,14 @@ jobs: toolchain: stable targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + # x86_64 JDK is set up first so the aarch64 step below can leave its # JAVA_HOME as the active one. The macOS universal JRE build needs # jmods from both arches; the x64 path is captured into the env @@ -257,17 +267,6 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -292,7 +291,7 @@ jobs: # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 env: SM_API_KEY: ${{ secrets.SM_API_KEY }} @@ -302,22 +301,22 @@ jobs: SM_HOST: ${{ secrets.SM_HOST }} - name: Setup DigiCert KeyLocker Certificate - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} shell: pwsh run: | Write-Host "Setting up DigiCert KeyLocker environment..." # Decode client certificate - $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") + $certBytes = [Convert]::FromBase64String("$env:SM_CLIENT_CERT_FILE_B64") $certPath = "D:\Certificate_pkcs12.p12" [IO.File]::WriteAllBytes($certPath, $certBytes) # Set environment variables echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV - echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV - echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV + echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV + echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV + echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV + echo "SM_KEYPAIR_ALIAS=$env:SM_KEYPAIR_ALIAS" >> $env:GITHUB_ENV # Get PKCS11 config path from DigiCert action $pkcs11Config = $env:PKCS11_CONFIG @@ -335,40 +334,14 @@ jobs: } } - # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - - name: Import Windows Code Signing Certificate - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - shell: powershell - run: | - if ($env:WINDOWS_CERTIFICATE) { - Write-Host "Importing Windows Code Signing Certificate..." - - # Decode base64 certificate and save to file - $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx" - [IO.File]::WriteAllBytes($certPath, $certBytes) - - # Import certificate to CurrentUser\My store - $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) - - # Extract and set thumbprint as environment variable - $thumbprint = $cert.Thumbprint - Write-Host "Certificate imported with thumbprint: $thumbprint" - echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV - - # Clean up certificate file - Remove-Item $certPath - - Write-Host "Windows certificate import completed." - } else { - Write-Host "āš ļø WINDOWS_CERTIFICATE secret not set - building unsigned binary" - } - + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + SM_HOST: ${{ secrets.SM_HOST }} + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} - name: Import Apple Developer Certificate - if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -389,7 +362,7 @@ jobs: rm certificate.p12 - name: Verify Certificate - if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') run: | echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db @@ -405,7 +378,7 @@ jobs: # Without this, signCommand failures are opaque (Tauri captures but drops # smctl's stderr) - running these loudly surfaces auth/env/keypair issues. - name: Preflight smctl - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} shell: pwsh env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -436,7 +409,7 @@ jobs: # smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD # from env (set by prior DigiCert setup step). No --config-file needed. - name: Configure Windows code signing - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} shell: bash env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -457,7 +430,7 @@ jobs: sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json - name: Import release GPG signing key (Linux) - if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') run: | echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import gpg --list-secret-keys --keyid-format=long @@ -489,8 +462,8 @@ jobs: # APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively # SIGN_KEY appimagetool picks the key matching this fingerprint # Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present. - # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present. - SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }} + # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present. + SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -516,7 +489,7 @@ jobs: env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }} + GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} run: | @@ -555,7 +528,7 @@ jobs: echo "Stripped bundled libwayland from $(basename "$AI")" - name: Clear release GPG key from runner keyring (Linux) - if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') env: RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }} run: | @@ -570,7 +543,7 @@ jobs: # artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw # cargo output unsigned, so checking it produces false negatives. - name: Verify Windows Code Signature - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} timeout-minutes: 15 shell: pwsh run: | @@ -724,6 +697,17 @@ jobs: path: ./dist/* retention-days: 1 + - name: Cleanup temporary files + if: always() + shell: bash + run: | + rm -f certificate.p12 + rm -rf "$RUNNER_TEMP/msi-verify" + if [ "${{ matrix.platform }}" = "macos-15" ]; then + security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + fi + continue-on-error: true + collect-and-release: needs: [determine-matrix, build, build-jars] runs-on: ubuntu-latest @@ -731,7 +715,7 @@ jobs: contents: write 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 @@ -888,7 +872,7 @@ jobs: # Gate publish on valid updater sigs. Runs after the review upload (so # artifacts survive for debugging) and before action-gh-release. - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | @@ -902,11 +886,11 @@ jobs: # workflow_dispatch path requires platform=='all' so a single-platform # dispatch can't overwrite an existing release's full latest.json with a # partial one (action-gh-release defaults overwrite_files:true). - # release / V2-master always build the full matrix so no extra guard needed. + # release event / release branch always build the full matrix so no extra guard needed. # fail_on_unmatched_files makes a missing latest.json or installer fail loudly # instead of silently shipping a broken auto-update. - name: Upload binaries to Release - if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' + if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release' uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ needs.determine-matrix.outputs.version }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ba4054f190..5daa60f56f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -4,6 +4,11 @@ on: schedule: - cron: "0 2 * * *" # 2 AM UTC every night workflow_dispatch: + pull_request: + paths: + - .github/workflows/nightly.yml + - testing/cucumber/** + - docker/embedded/compose/test_cicd.yml concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -18,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -59,13 +64,17 @@ jobs: # the story itself — a shared component, a theme token — still surfaces within # a day. a11y-all-stories: - name: a11y (every story, light + dark) + name: a11y (every story) + strategy: + fail-fast: false + matrix: + theme: [light, dark] runs-on: ubuntu-latest - # Two full sweeps (one per theme), each ~30 minutes of browser time. - timeout-minutes: 120 + # One full sweep (~30 minutes of browser time). + timeout-minutes: 60 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -82,14 +91,14 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - - name: a11y gate (every story, light + dark) - run: task frontend:storybook:a11y + - name: a11y gate (every story, ${{ matrix.theme }}) + run: task frontend:storybook:a11y:${{ matrix.theme }} - name: Upload scan reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: a11y-scan-nightly-${{ github.run_id }} + name: a11y-scan-nightly-${{ matrix.theme }}-${{ github.run_id }} path: frontend/.a11y-scan/ retention-days: 14 if-no-files-found: ignore @@ -99,8 +108,13 @@ jobs: # Builds all desktop platforms on a schedule so the Rust dependency cache is # written on main, where PR and merge-queue tauri builds can restore it. + # + # The only job here still pinned to schedule/main: it primes a cache rather than + # testing anything, and Actions scopes a cache written on a PR branch to that PR + # alone, so a PR run costs three platform builds and produces nothing reusable. warm-tauri-cache: name: Warm Tauri Rust cache + if: github.event_name == 'schedule' || github.ref == 'refs/heads/main' permissions: contents: read pull-requests: write @@ -109,3 +123,75 @@ jobs: platform: all sign: false secrets: inherit + + # Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run + # of every other feature. + cucumber-nightly: + environment: + name: ci-unsigned + deployment: false + name: Cucumber (nightly scenarios + full concurrency) + runs-on: ubuntu-latest + # Fork pull requests get no MAVEN_* secrets, so the image build cannot work. + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + cache-dependency-glob: | + engine/pyproject.toml + engine/uv.lock + + - name: Install Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + - name: Start the fat image with login and storage enabled + run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + - name: Wait for the server + # Throwaway key from test_cicd.yml; out of the header literal for gitleaks. + env: + TEST_API_KEY: "123456789" + run: | + curl --retry 90 --retry-delay 3 --retry-connrefused --retry-all-errors \ + -sf -H "X-API-KEY: $TEST_API_KEY" http://localhost:8080/api/v1/info/status + + # Heavy LibreOffice/Calibre/Ghostscript conversions, excluded from the PR run. + # Both tasks install the behave deps themselves, so there is no separate uv sync step. + - name: Run @nightly scenarios + run: task cucumber:nightly + + # Genuinely different payloads contending on one backend. + - name: Sharded concurrency validation + run: task cucumber:parallel SHARDS=10 + + - name: Container logs on failure + if: failure() + run: docker compose -f docker/embedded/compose/test_cicd.yml logs --tail 400 + + - name: Tear down + if: always() + run: docker compose -f docker/embedded/compose/test_cicd.yml down -v diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index e88c5e400e..02d946e5a6 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -28,7 +28,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} 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 @@ -73,13 +73,14 @@ jobs: echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT" update-homebrew-and-scoop: + environment: package-publish needs: get-release-info runs-on: ubuntu-latest permissions: contents: write 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 diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index a44d4f7b28..564ea9cd93 100644 --- a/.github/workflows/pr-conflict-labeler.yml +++ b/.github/workflows/pr-conflict-labeler.yml @@ -27,29 +27,22 @@ jobs: name: Label conflicted PRs runs-on: ubuntu-latest permissions: - contents: read - issues: write - pull-requests: read + contents: read # actions/checkout + issues: write # get/create the repo-level conflict label + pull-requests: write # pulls.get/list plus add/remove the label on PRs 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 - name: Check out the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up stirling-bot token - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Apply conflict label uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const conflictLabel = process.env.CONFLICT_LABEL; const owner = context.repo.owner; diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 18c4782927..674822363b 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest 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 @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | @@ -34,7 +34,7 @@ jobs: cache-suffix: pre-commit - name: Install Task - uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Run pre-commit checks run: task pre-commit diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 167b71531e..9da49ad7ae 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -17,6 +17,9 @@ permissions: jobs: push-base: + # Own environment: docker-publish is branch-locked to release/main, + # which excludes the baseDockerImage/accessIssueFix branches this runs on. + environment: docker-base-publish if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }} runs-on: ubuntu-24.04-8core permissions: @@ -32,9 +35,11 @@ jobs: - name: Set version id: version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} run: | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" + VERSION="${INPUT_VERSION}" elif [ "${{ github.ref_name }}" == "accessIssueFix" ]; then VERSION="1.0.3" else @@ -43,7 +48,7 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT - 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 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 906884aaab..844a77b489 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -18,12 +18,20 @@ on: required: false type: boolean default: false + build_engine: + description: "Build & push the standalone stirling-engine image." + required: false + type: boolean + default: true + force_engine_rebuild: + description: "Rebuild stirling-engine even if its source hash is unchanged." + required: false + type: boolean + default: false push: branches: - - master + - release - main - - V2-master - - testMain # cancel in-progress jobs if a new job is triggered # This is useful to avoid running multiple builds for the same branch if a new commit is pushed @@ -42,6 +50,7 @@ permissions: jobs: push: + environment: docker-publish if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-24.04-8core permissions: @@ -51,31 +60,29 @@ jobs: env: RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }} RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} + RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }} 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 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -91,13 +98,13 @@ jobs: MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - name: Install cosign - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' + if: github.ref == 'refs/heads/release' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: "v2.4.1" - name: Install cosign - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' + if: github.ref == 'refs/heads/release' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: "v2.4.1" @@ -133,8 +140,8 @@ jobs: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf tags: | - type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} + type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }} - name: Build and push Unified Dockerfile (latest variant) id: build-push-latest @@ -158,7 +165,7 @@ jobs: sbom: true - name: Sign regular images - if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != '' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != '' env: DIGEST: ${{ steps.build-push-latest.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} @@ -182,8 +189,8 @@ jobs: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf tags: | - type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} - type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} + type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }} + type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }} - name: Build and push Unified Dockerfile (fat variant) id: build-push-fat @@ -204,7 +211,7 @@ jobs: sbom: true - name: Sign fat images - if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != '' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != '' env: DIGEST: ${{ steps.build-push-fat.outputs.digest }} TAGS: ${{ steps.meta-fat.outputs.tags }} @@ -226,8 +233,8 @@ jobs: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf tags: | - type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} - type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} + type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }} + type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }} - name: Build and push Unified Dockerfile (ultra-lite variant) id: build-push-lite @@ -248,7 +255,7 @@ jobs: sbom: true - name: Sign ultra-lite images - if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != '' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != '' env: DIGEST: ${{ steps.build-push-lite.outputs.digest }} TAGS: ${{ steps.meta-lite.outputs.tags }} @@ -260,7 +267,7 @@ jobs: done # Standalone unoserver image — versioned independently via - # docker/unoserver/VERSION. master/V2-master: publish +latest + # docker/unoserver/VERSION. release: publish +latest # only when the version is new. main/testMain: republish :alpha only # when the source hash differs from the published image's annotation. - name: Read unoserver image version @@ -319,7 +326,7 @@ jobs: fi case "$EFFECTIVE_REF" in - refs/heads/master|refs/heads/V2-master) + refs/heads/release) if [ "${FORCE_REBUILD}" = "true" ]; then echo "force_unoserver_rebuild=true — building stable regardless" mode="stable" @@ -391,3 +398,119 @@ jobs: else echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing" fi + + # Standalone AI engine image, same shape as the unoserver image above. + - name: Compute engine image source hash + id: engineHash + if: env.RUN_ENGINE == 'true' + run: | + set -eu + hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \ + find engine/src -type f -print0 | sort -z | xargs -0 cat; } \ + | sha256sum | cut -d' ' -f1) + echo "hash=${hash}" >> "$GITHUB_OUTPUT" + echo "Engine source hash: ${hash}" + + - name: Decide whether to publish engine image + id: engineDecision + if: env.RUN_ENGINE == 'true' + env: + ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }} + ENGINE_HASH: ${{ steps.engineHash.outputs.hash }} + ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine + ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash + FORCE_REBUILD: ${{ inputs.force_engine_rebuild }} + GH_REF: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -eu + mode="skip" + tags="" + + read_published_hash() { + local ref="$1" + docker buildx imagetools inspect "$ref" --raw 2>/dev/null \ + | jq -r --arg key "$ENGINE_HASH_ANNOTATION" \ + '.annotations[$key] // empty' \ + 2>/dev/null || true + } + + # Manual dispatch from any branch routes to the :alpha publish path. + EFFECTIVE_REF="$GH_REF" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + EFFECTIVE_REF="refs/heads/testMain" + fi + + case "$EFFECTIVE_REF" in + refs/heads/release) + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — building stable regardless" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then + echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping" + else + echo "stirling-engine:${ENGINE_VERSION} is new — will publish" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + fi + ;; + refs/heads/main|refs/heads/testMain) + published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha") + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — rebuilding :alpha regardless" + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then + echo "Published :alpha source hash matches (${published_hash}) — skipping" + else + if [ -z "$published_hash" ]; then + echo ":alpha has no source-hash annotation (first publish) — will publish" + else + echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish" + fi + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + fi + ;; + *) + echo "Branch ${GH_REF} does not publish engine image" + ;; + esac + echo "mode=${mode}" >> "$GITHUB_OUTPUT" + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + + - name: Build and push engine image + id: build-push-engine + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-engine + cache-to: type=gha,mode=max,scope=stirling-engine + tags: ${{ steps.engineDecision.outputs.tags }} + # Manifest annotation read by the decision step above to detect drift. + annotations: | + index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }} + platforms: linux/amd64,linux/arm64/v8 + provenance: true + sbom: true + + - name: Sign engine image + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable' + env: + DIGEST: ${{ steps.build-push-engine.outputs.digest }} + TAGS: ${{ steps.engineDecision.outputs.tags }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + if [ -n "$COSIGN_PRIVATE_KEY" ]; then + echo "$TAGS" | tr ',' '\n' | while read -r tag; do + cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" + done + else + echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing" + fi diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 27141eff31..fb0289b5a0 100644 --- a/.github/workflows/rollback-latest.yml +++ b/.github/workflows/rollback-latest.yml @@ -13,12 +13,13 @@ permissions: jobs: rollback: + environment: docker-publish runs-on: ubuntu-latest permissions: packages: write 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 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 2eab52b1d4..dbda06764b 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -35,7 +35,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 @@ -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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ab9078831a..2033154a00 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write 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 diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 733ce9e7ed..1f53edd17b 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - master + - release # cancel in-progress jobs if a new job is triggered # This is useful to avoid running multiple builds for the same branch if a new commit is pushed @@ -23,33 +23,33 @@ permissions: jobs: push: + # package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and + # is limited to main / release / v* tags, so every push to release waits on one. + environment: package-publish if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest 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 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml index 0b27858d5f..a6073b3f1f 100644 --- a/.github/workflows/sync-portal-docs.yml +++ b/.github/workflows/sync-portal-docs.yml @@ -24,6 +24,7 @@ permissions: jobs: sync: + environment: bot-identity name: Sync docs manifest runs-on: ubuntu-latest timeout-minutes: 10 @@ -32,7 +33,7 @@ jobs: pull-requests: write 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 diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 1888c08ca7..a199fd6cbd 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -33,10 +33,11 @@ permissions: jobs: sync-files: + environment: bot-identity runs-on: ubuntu-latest 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 @@ -52,7 +53,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | @@ -65,7 +66,7 @@ jobs: uv sync --project engine --locked --group tools - name: Install Task - uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Sync translation TOML files run: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index b0888d83cf..3b7a0b04fa 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -26,6 +26,10 @@ on: required: false type: boolean default: false + use_shared_cache: + required: false + type: boolean + default: false workflow_dispatch: inputs: platform: @@ -57,13 +61,18 @@ permissions: jobs: determine-matrix: + # Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted + # signing environment - release-signing would block every PR run. + environment: + name: ci-signing + deployment: false if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -103,6 +112,12 @@ jobs: echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT build: + # Windows/GPG signing only runs on main (see the per-step gates below), so only + # that path needs the reviewer-gated release-signing environment. Everything else + # (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no + # approval or branch restriction. + environment: + name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }} needs: determine-matrix strategy: fail-fast: false @@ -110,7 +125,6 @@ jobs: runs-on: ${{ matrix.platform }} env: SM_API_KEY: ${{ secrets.SM_API_KEY }} - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} # Per-platform sign gate. macOS signs on any run with the cert available, @@ -120,7 +134,7 @@ jobs: SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} 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 @@ -150,7 +164,7 @@ jobs: # only recompiles the app crate. Written on main; PRs and the merge queue # restore from it. - name: Cache Rust build - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: frontend/editor/src-tauri # Stable key shared across workflows so the nightly warmer. @@ -160,6 +174,24 @@ jobs: # Save the dependency cache even if a later step fails cache-on-failure: true + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Restore cache Gradle + if: inputs.use_shared_cache == false + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up x86_64 JDK 25 (macOS universal JRE) if: matrix.platform == 'macos-15' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 @@ -179,17 +211,6 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Setup Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -227,20 +248,26 @@ jobs: - name: Setup DigiCert KeyLocker Certificate if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: pwsh + env: + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + SM_HOST: ${{ secrets.SM_HOST }} + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} run: | Write-Host "Setting up DigiCert KeyLocker environment..." # Decode client certificate - $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") + $certBytes = [Convert]::FromBase64String("$env:SM_CLIENT_CERT_FILE_B64") $certPath = "D:\Certificate_pkcs12.p12" [IO.File]::WriteAllBytes($certPath, $certBytes) # Set environment variables echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV - echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV - echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV + echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV + echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV + echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV + echo "SM_KEYPAIR_ALIAS=$env:SM_KEYPAIR_ALIAS" >> $env:GITHUB_ENV # Get PKCS11 config path from DigiCert action $pkcs11Config = $env:PKCS11_CONFIG @@ -258,38 +285,6 @@ jobs: } } - # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - - name: Import Windows Code Signing Certificate - if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }} - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - shell: powershell - run: | - if ($env:WINDOWS_CERTIFICATE) { - Write-Host "Importing Windows Code Signing Certificate..." - - # Decode base64 certificate and save to file - $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx" - [IO.File]::WriteAllBytes($certPath, $certBytes) - - # Import certificate to CurrentUser\My store - $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) - - # Extract and set thumbprint as environment variable - $thumbprint = $cert.Thumbprint - Write-Host "Certificate imported with thumbprint: $thumbprint" - echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV - - # Clean up certificate file - Remove-Item $certPath - - Write-Host "Windows certificate import completed." - } else { - Write-Host "āš ļø WINDOWS_CERTIFICATE secret not set - building unsigned binary" - } - - name: Import Apple Developer Certificate if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' env: @@ -682,6 +677,17 @@ jobs: fi done + - name: Cleanup temporary files + if: always() + shell: bash + run: | + rm -f certificate.p12 + rm -rf "$RUNNER_TEMP/msi-verify" + if [ "${{ matrix.platform }}" = "macos-15" ]; then + security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + fi + continue-on-error: true + pr-comment: needs: build runs-on: ubuntu-latest @@ -697,7 +703,7 @@ jobs: pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -789,7 +795,7 @@ jobs: if: always() steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4ce06e6247..4717c9c387 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -22,21 +22,45 @@ permissions: contents: read jobs: - # TODO: extract a pre-matrix `prepare` job that runs once and produces - # shared artifacts for the three matrix entries below to consume: - # 1. `task backend:build` — currently runs 3Ɨ in parallel with - # identical env (DISABLE_ADDITIONAL_FEATURES=true, - # STIRLING_PDF_DESKTOP_UI=false). Build once, upload the JAR as an - # artifact, matrix entries download. - # 2. The base-image `docker build` (gated on docker-base-changed) — - # currently runs 3Ɨ in parallel against the same Dockerfile and - # context. Build once, `docker save` to an artifact, matrix entries - # `docker load` before the embedded build. - # Saves ~2 full backend builds + 2 base-image builds per PR that touches - # docker. May also be reusable from backend-build.yml's jdk-25 + - # spring-security=true matrix entry if `task backend:build` and - # `task backend:build:ci` produce equivalent JARs (verify before wiring). + # A changed base image is shared by all three embedded-image builds. Build + # it once and transfer it as an artifact; the matrix jobs use the local + # Docker driver so the loaded image is visible to the build. + prepare-base-image: + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + environment: + name: ci-unsigned + deployment: false + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build base image locally + run: docker build --platform linux/amd64 -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base + + - name: Export base image + run: docker save stirling-pdf-base:pr-test | gzip -1 > stirling-pdf-base-pr-test.tar.gz + + - name: Upload base image + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docker-base-pr-test + path: stirling-pdf-base-pr-test.tar.gz + retention-days: 1 + if-no-files-found: error + test-build-docker-images: + if: always() && (needs.prepare-base-image.result == 'success' || needs.prepare-base-image.result == 'skipped') + needs: [prepare-base-image] + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest strategy: fail-fast: false @@ -53,7 +77,7 @@ jobs: cache-scope: stirling-pdf-fat 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 @@ -78,23 +102,30 @@ jobs: docker system prune -af || true echo "Disk space after cleanup:" && df -h + - name: Download prepared base image + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: docker-base-pr-test + + - name: Load prepared base image + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + run: gzip -dc stirling-pdf-base-pr-test.tar.gz | docker load + + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Build application @@ -113,11 +144,6 @@ jobs: id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Build base image locally (PR base change only) - if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' - run: | - docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base - - name: Set base image and platform for this build id: build-params # Pass workflow inputs through env vars rather than expanding `${{ }}` @@ -191,7 +217,7 @@ jobs: runs-on: ubuntu-latest 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 diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml deleted file mode 100644 index 7c03e57967..0000000000 --- a/.github/workflows/testdriver.yml +++ /dev/null @@ -1,213 +0,0 @@ -name: UI test with TestDriverAI - -on: - push: - branches: ["master", "UITest", "testdriver"] - -# cancel in-progress jobs if a new job is triggered -# This is useful to avoid running multiple builds for the same branch if a new commit is pushed -# or a pull request is updated. -# It helps to save resources and time by ensuring that only the latest commit is built and tested -# This is particularly useful for long-running jobs that may take a while to complete. -# The `group` is set to a combination of the workflow name, event name, and branch name. -# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of -# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - deploy: - if: ${{ vars.CI_PROFILE != 'lite' }} - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - - name: Build with Gradle - run: ./gradlew build - env: - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - DISABLE_ADDITIONAL_FEATURES: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Get version number - id: versionNumber - run: | - VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') - echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - - - name: Login to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} - - - name: Build and push test image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ./docker/embedded/Dockerfile - push: true - cache-from: type=gha,scope=stirling-pdf-latest - cache-to: type=gha,mode=max,scope=stirling-pdf-latest - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }} - build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }} - platforms: linux/amd64 - - - name: Set up SSH - run: | - mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key - sudo chmod 600 ../private.key - - - name: Deploy to VPS - run: | - cat > docker-compose.yml << EOF - version: '3.3' - services: - stirling-pdf: - container_name: stirling-pdf-test-${{ github.sha }} - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }} - ports: - - "1337:8080" - volumes: - - /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw - - /stirling/test-${{ github.sha }}/config:/configs:rw - - /stirling/test-${{ github.sha }}/logs:/logs:rw - environment: - DISABLE_ADDITIONAL_FEATURES: "true" - SECURITY_ENABLELOGIN: "false" - SYSTEM_DEFAULTLOCALE: en-US - UI_APPNAME: "Stirling-PDF Test" - UI_HOMEDESCRIPTION: "Test Deployment" - UI_APPNAMENAVBAR: "Test" - SYSTEM_MAXFILESIZE: "100" - METRICS_ENABLED: "true" - SYSTEM_GOOGLEVISIBILITY: "false" - SYSTEM_ENABLEANALYTICS: "false" - restart: on-failure:5 - EOF - - scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml - - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF - mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs} - mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml - cd /stirling/test-${{ github.sha }} - docker-compose pull - docker-compose up -d - EOF - - files-changed: - if: always() - name: detect what files changed - runs-on: ubuntu-latest - timeout-minutes: 3 - outputs: - frontend: ${{ steps.changes.outputs.frontend }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 - id: changes - with: - filters: ".github/config/.files.yaml" - - test: - if: needs.files-changed.outputs.frontend == 'true' - needs: [deploy, files-changed] - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - cache: "npm" - cache-dependency-path: frontend/package-lock.json - - - name: Run TestDriver.ai - uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3 - with: - key: ${{secrets.TESTDRIVER_API_KEY}} - prerun: | - choco install go-task -y - task frontend:build - cd frontend - npm install dashcam-chrome --save - Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337" - Start-Sleep -Seconds 20 - prompt: | - 1. /run testing/testdriver/test.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FORCE_COLOR: "3" - - cleanup: - needs: [deploy, test] - runs-on: ubuntu-latest - if: always() - - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Set up SSH - run: | - mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key - sudo chmod 600 ../private.key - - - name: Cleanup deployment - if: always() - run: | - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF - cd /stirling/test-${{ github.sha }} - docker-compose down - cd /stirling - rm -rf test-${{ github.sha }} - EOF - continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/.github/workflows/update-gradle.yml b/.github/workflows/update-gradle.yml new file mode 100644 index 0000000000..07474f112b --- /dev/null +++ b/.github/workflows/update-gradle.yml @@ -0,0 +1,112 @@ +name: Update Gradle + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 1" + +concurrency: + group: update-gradle + cancel-in-progress: true + +jobs: + update-gradle: + name: Update Gradle and Docker images + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: "25" + + - name: Find latest Gradle release + id: gradle + shell: bash + run: | + set -euo pipefail + version=$(curl --fail --silent --show-error --retry 3 \ + https://services.gradle.org/versions/current | jq -r '.version') + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Could not determine a stable Gradle version: $version" >&2 + exit 1 + } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Find matching Docker image digest + id: docker + env: + GRADLE_VERSION: ${{ steps.gradle.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag="${GRADLE_VERSION}-jdk25" + digest=$(curl --fail --silent --show-error --retry 3 \ + "https://hub.docker.com/v2/repositories/library/gradle/tags/${tag}" \ + | jq -r '.digest // empty') + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "Docker image gradle:${tag} was not found" >&2 + exit 1 + } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Update Gradle wrapper + env: + GRADLE_VERSION: ${{ steps.gradle.outputs.version }} + run: ./gradlew wrapper --gradle-version "$GRADLE_VERSION" --distribution-type bin + + - name: Update Gradle Docker images + env: + DOCKER_TAG: ${{ steps.docker.outputs.tag }} + DOCKER_DIGEST: ${{ steps.docker.outputs.digest }} + shell: bash + run: | + set -euo pipefail + find docker -type f -name 'Dockerfile*' -print0 | + xargs -0 sed -E -i \ + "s#gradle:[^@[:space:]]+-jdk25(@sha256:[^[:space:]]+)?#gradle:${DOCKER_TAG}@${DOCKER_DIGEST}#g" + + - name: Verify Gradle update + env: + EXPECTED_VERSION: ${{ steps.gradle.outputs.version }} + shell: bash + run: | + set -euo pipefail + actual=$(./gradlew --version | sed -n 's/^Gradle \([0-9.]*\)$/\1/p') + [[ "$actual" == "$EXPECTED_VERSION" ]] || { + echo "Wrapper resolved Gradle $actual, expected $EXPECTED_VERSION" >&2 + exit 1 + } + if git diff --quiet; then + echo "Gradle is already up to date." + exit 0 + fi + git diff --check + + - name: Create pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: automation/update-gradle + delete-branch: true + commit-message: "chore: update Gradle" + title: "chore: update Gradle to ${{ steps.gradle.outputs.version }}" + body: | + Automated update of the Gradle wrapper and Gradle Docker build images. + + Gradle version: `${{ steps.gradle.outputs.version }}` + Docker image: `gradle:${{ steps.docker.outputs.tag }}` + labels: dependencies diff --git a/.gitignore b/.gitignore index a064973a3b..1290056e05 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ watchedFolders/ # also matches this frontend source component dir; keep the source tracked. !frontend/editor/src/proprietary/components/watchedFolders/ clientWebUI/ +policy-webhook-spool/ # Scratch dir used by local fixture-regeneration runs (see # app/proprietary/src/test/resources/db-migration-fixtures/README.md). # Holds downloaded JARs and disposable workdirs. Never committed. @@ -38,6 +39,7 @@ exampleYmlFiles/stirling/ /testing/file_snapshots /testing/cucumber/junit/ /testing/cucumber/report.html +/testing/cucumber/.parallel/ /testing/.failed_tests /.test-state/ SwaggerDoc.json @@ -174,6 +176,8 @@ app/core/src/main/resources/static/images/google-drive.svg *.nar *.ear *.zip +# Real backend archives the form-bundle reader is tested against. +!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip *.tar.gz *.rar *.db diff --git a/.gitleaksignore b/.gitleaksignore index 12d98aebeb..c3917e985f 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase. # Supabase publishable key (public by design, RLS-protected) used as a CI fallback # default in the tauri-build workflow when the GitHub secret is unset - not a real secret. .github/workflows/tauri-build.yml:generic-api-key:402 + +# Staging Supabase publishable key (public by design). Ignored here rather than with an +# inline gitleaks:allow because a trailing comment in a .properties file is part of the +# value, so the pragma would end up inside the key. +app/saas/src/main/resources/application-staging.properties:generic-api-key:16 diff --git a/.imgbotconfig b/.imgbotconfig index 720b938475..6a1b1bf7a2 100644 --- a/.imgbotconfig +++ b/.imgbotconfig @@ -1,5 +1,7 @@ { "ignoredFiles": [ - "frontend/editor/src-tauri/icons/icon.png" + "frontend/editor/src-tauri/icons/macos/*", + "frontend/editor/src-tauri/icons/linux/*", + "frontend/editor/src-tauri/icons/windows/*" ] } diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 63773f61fc..08a12b9535 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -57,16 +57,57 @@ tasks: - cmd: ./gradlew clean bootRun -PbuildWithFrontend=true platforms: [linux, darwin] + # SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3, + # PROFILES=none -> production against your own SAAS_DB_*. Production has no named + # task on purpose. Use `none`, not an empty value: Go template `default` treats "" + # as absent and would resolve back to dev. + dev:saas: - desc: "Start backend in SaaS flavor against Supabase" - # `dotenv:` reads from the root Taskfile's directory (".") because this - # subtaskfile is included with `dir: .`. + desc: "Start SaaS backend against the current PR's Supabase preview branch" + dotenv: ['app/.env.saas.local', 'app/.env.saas'] + vars: + PROFILES: '{{.PROFILES | default "dev"}}' + cmds: + # Don't move this check into a `sh:` var: dotenv is visible in cmds but not + # during var evaluation, so the test would always see an empty value. + - cmd: | + if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then + echo ">> SAAS_DEV_PROJECT_REF is not set." + echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local." + echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead." + exit 1 + fi + - task: _run:saas + vars: + PORT: '{{.PORT}}' + PROFILES: '{{.PROFILES}}' + AIENGINE_URL: '{{.AIENGINE_URL}}' + AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + + staging:saas: + desc: "Start SaaS backend against the shared v3 staging project" + cmds: + - task: _run:saas + vars: + PORT: '{{.PORT}}' + PROFILES: staging + AIENGINE_URL: '{{.AIENGINE_URL}}' + AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + + _run:saas: + internal: true dotenv: ['app/.env.saas.local', 'app/.env.saas'] ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' - # Override to "" to run the pure `saas` profile against your own SAAS_DB_*. PROFILES: '{{.PROFILES | default "dev"}}' + # Built here rather than inline in the cmds below: the Windows line is an + # unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X + # "none"}} needs escaped quotes that reach the Go template as literal + # backslashes and fail with `unexpected "\" in operand`. + PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}' AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' @@ -77,9 +118,11 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' cmds: - - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}" + # PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile + # against SAAS_DB_* (production). + - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}" platforms: [windows] - - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}} + - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}} platforms: [linux, darwin] build: diff --git a/.taskfiles/cucumber.yml b/.taskfiles/cucumber.yml new file mode 100644 index 0000000000..cb630abf85 --- /dev/null +++ b/.taskfiles/cucumber.yml @@ -0,0 +1,44 @@ +version: '3' + +tasks: + install: + desc: "Sync the Python environment with the cucumber test dependencies" + run: once + # Deliberately no sources/status fingerprint: the engine venv is shared, so it can + # already exist while synced to a different dependency group. uv no-ops when correct. + cmds: + - uv sync --project ../../engine --locked --group cucumber + + run: + desc: "Run the cucumber suite against a running server (BASE_URL, default localhost:8080)" + deps: [install] + cmds: + - uv run --project ../../engine --locked --group cucumber python -m behave --no-capture -f plain {{.CLI_ARGS}} + + nightly: + desc: "Run the @nightly cucumber scenarios, excluded from the default run" + summary: | + Heavy LibreOffice/Calibre/Ghostscript conversions. behave.ini excludes @nightly, + so this opts back in explicitly. + + Pass extra behave flags via -- : + task cucumber:nightly -- --tags=@convert + deps: [install] + cmds: + - uv run --project ../../engine --locked --group cucumber python -m behave --tags=@nightly --no-capture -f plain {{.CLI_ARGS}} + + parallel: + desc: "Run the cucumber suite as concurrent shards against one server (SHARDS, default 10)" + summary: | + Splits the feature files across SHARDS concurrent behave processes hitting a single + backend, to shake out cross-request interference. Auth-coupled features are pinned + to one shard because they change the admin password mid-scenario. + + task cucumber:parallel + task cucumber:parallel SHARDS=4 + BASE_URL=http://localhost:8081 task cucumber:parallel + deps: [install] + vars: + SHARDS: '{{.SHARDS | default "10"}}' + cmds: + - bash run-parallel.sh {{.SHARDS}} {{if .CLI_ARGS}}-- {{.CLI_ARGS}}{{end}} diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 19a3f6caf5..844306824d 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -5,6 +5,14 @@ version: '3' # mode flag) or use `--project editor/...` for tsc — so the editor lives # under frontend/editor/ without each task needing a cd. +vars: + # Dev-only browser-tab label so concurrent worktrees are distinguishable. Only + # the worktree folder basename (e.g. "wt1") is exposed — never the full path, + # hostname, or user. Dropped from production builds. + DEV_LABEL: + sh: >- + {{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}} + tasks: install: desc: "Install dependencies" @@ -80,16 +88,52 @@ tasks: OPEN: '{{.OPEN | default ""}}' env: BACKEND_URL: '{{.BACKEND_URL}}' - # Dev-only browser-tab label so concurrent worktrees are distinguishable. - # Only the worktree folder basename (e.g. "wt1") is exposed — never the - # full path, hostname, or user. Consumed at dev-serve time by vite.config - # and dropped from production builds. - STIRLING_DEV_LABEL: - sh: >- - {{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}} + STIRLING_DEV_LABEL: '{{.DEV_LABEL}}' cmds: - npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}} + # Separate from dev:_run rather than a flag on it: Task sets an `env:` key even + # when its value resolves to empty, and Vite treats an empty process.env VITE_* as + # authoritative over the committed editor/.env, so folding these in blanks Supabase + # config for the core, proprietary and desktop dev servers. + dev:_run:saas: + internal: true + ignore_error: true + # The backend's own env files, so both halves target one project. Paths are + # relative to this taskfile's dir, `frontend`. + dotenv: ['../app/.env.saas.local', '../app/.env.saas'] + vars: + PORT: '{{.PORT | default "5173"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + OPEN: '{{.OPEN | default ""}}' + SAAS_ENV: '{{.SAAS_ENV | default "dev"}}' + env: + BACKEND_URL: '{{.BACKEND_URL}}' + STIRLING_DEV_LABEL: '{{.DEV_LABEL}}' + SAAS_ENV: '{{.SAAS_ENV}}' + # A real process.env VITE_* beats a committed .env in Vite (loadEnv applies + # process.env last), which is what lets this override editor/.env. + # + # These must stay `sh:`, not Go templates: dotenv values are visible to Task's + # embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is + # always empty. + VITE_SUPABASE_URL: + sh: | + case "${SAAS_ENV:-dev}" in + staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;; + *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;; + esac + echo "https://${ref}.supabase.co" + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: + sh: | + case "${SAAS_ENV:-dev}" in + staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; + *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; + esac + cmds: + - 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"' + - npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}} + dev: desc: "Start frontend dev server" cmds: @@ -111,13 +155,23 @@ tasks: vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } dev:saas: - desc: "Start frontend dev server in SaaS mode" + desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)" deps: - task: prepare vars: { MODE: saas } + vars: + SAAS_ENV: '{{.SAAS_ENV | default "dev"}}' + # prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves + # the committed editor/.env alone. + RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}' cmds: - - task: dev:_run - vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } + - task: '{{.RUNNER}}' + vars: + MODE: saas + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + OPEN: '{{.OPEN}}' + SAAS_ENV: '{{.SAAS_ENV}}' dev:desktop: desc: "Start frontend dev server in desktop mode" @@ -210,15 +264,26 @@ tasks: # task frontend:storybook:test -- Button - npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}} - storybook:a11y: - desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations" + storybook:a11y:light: + desc: "a11y gate over every story in light mode" deps: [prepare, storybook:browser] cmds: - - node .storybook/a11y-scan.mjs + - node .storybook/a11y-scan.mjs {{.CLI_ARGS}} - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt - - SCAN_THEME=dark node .storybook/a11y-scan.mjs + + storybook:a11y:dark: + desc: "a11y gate over every story in dark mode" + deps: [prepare, storybook:browser] + cmds: + - SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CLI_ARGS}} - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json + storybook:a11y: + desc: "a11y gate over every story, light and dark" + cmds: + - task: storybook:a11y:light + - task: storybook:a11y:dark + storybook:a11y:changed: desc: "a11y gate over the stories this branch affects (default base origin/main)" summary: | @@ -233,7 +298,6 @@ tasks: Pass a base ref through CLI_ARGS, e.g. task frontend:storybook:a11y:changed -- origin/release - deps: [prepare, storybook:browser] vars: BASE: '{{.CLI_ARGS | default "origin/main"}}' CHANGED: @@ -244,10 +308,10 @@ tasks: echo "a11y: no story files affected vs {{.BASE}} — nothing to check" exit 0 fi - node .storybook/a11y-scan.mjs {{.CHANGED}} - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt - SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}} - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json + rc=0 + task frontend:storybook:a11y:light -- {{.CHANGED}} || rc=1 + task frontend:storybook:a11y:dark -- {{.CHANGED}} || rc=1 + exit $rc storybook:a11y:record: desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)" @@ -311,13 +375,13 @@ tasks: desc: "Auto-fix code formatting" deps: [install] cmds: - - npx prettier --write . + - npx oxfmt --write . format:check: desc: "Check code formatting" deps: [install] cmds: - - npx prettier --check . + - npx oxfmt --check . fix: desc: "Auto-fix lint and format" @@ -490,6 +554,7 @@ tasks: deps: [install, ":backend:swagger"] cmds: - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts + - task: format sources: - editor/scripts/generate-tool-api-types.mts - ../SwaggerDoc.json @@ -499,9 +564,9 @@ tasks: tool-models:check: desc: "Fail if committed tool API types are out of date" - deps: [install, ":backend:swagger"] cmds: - - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check + - task: tool-models + - git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts licenses:generate: desc: "Generate frontend license report" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9092d5cd72..65fc4bc262 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,13 @@ Thank you for your interest in contributing to Stirling-PDF! There are many ways to contribute other than writing code. For example, reporting bugs, creating suggestions, and adding or modifying translations. +## License + +By contributing to this project, you agree that your contributions will be licensed under the project [license](LICENSE), which follows an open-core model. +The codebase is a mix of MIT and source-available code, so your contribution is licensed according to the directory it is committed to. + +PRs are welcome in any directory by any user, just be aware of which license applies to the code you change. + ## Issue Guidelines Issues can be used to report bugs, request features, or ask questions. If you have a question, you could also ask us in our [Discord](https://discord.gg/FJUSXUSYec). @@ -63,7 +70,3 @@ For technical guides, setup instructions, and development resources: For configuration and usage guides, see: - [Database Guide](DATABASE.md) - Database setup and configuration - [OCR Guide](HowToUseOCR.md) - OCR setup and configuration - -## License - -By contributing to this project, you agree that your contributions will be licensed under the [MIT License](LICENSE). diff --git a/DeveloperGuide.md b/DeveloperGuide.md index 7c2e21d5ca..d87406715b 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -46,8 +46,8 @@ This guide focuses on developing for Stirling 2.0, including both the React fron - Docker - Git - Java JDK 25 -- Node.js 18+ and npm (required for frontend development) -- Gradle 7.0 or later (Included within the repo) +- Node.js 22+ and npm (required for frontend development) +- Gradle 9.0 or later (Included within the repo) - [uv](https://docs.astral.sh/uv/) — Python package manager (required for engine development) - Rust and Cargo (required for Tauri desktop app development) - Tauri CLI (install with `cargo install tauri-cli`) diff --git a/Taskfile.yml b/Taskfile.yml index 304a031ac0..92dcdcc742 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -25,6 +25,9 @@ includes: e2e: taskfile: .taskfiles/e2e.yml dir: . + cucumber: + taskfile: .taskfiles/cucumber.yml + dir: testing/cucumber pre-commit: taskfile: .taskfiles/pre-commit.yml dir: . @@ -96,11 +99,22 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + # Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR. dev:saas: - desc: "Start SaaS backend + frontend concurrently on free ports" + desc: "Start SaaS backend + frontend + engine against the current PR's preview branch" cmds: - task: dev:_all - vars: { FRONTEND: saas, BACKEND: saas } + vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev } + + staging:saas: + desc: "Start SaaS backend + frontend + engine against the shared v3 staging project" + cmds: + - task: dev:_all + vars: + FRONTEND: saas + BACKEND: saas + BACKEND_TASK: backend:staging:saas + SAAS_ENV: staging dev:all: desc: "Start backend + frontend + engine concurrently on free ports" @@ -112,6 +126,9 @@ tasks: vars: FRONTEND: '{{.FRONTEND | default "proprietary"}}' BACKEND: '{{.BACKEND | default "proprietary"}}' + BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}' + # Only meaningful to the saas frontend; every other flavor ignores it. + SAAS_ENV: '{{.SAAS_ENV | default ""}}' PORTS: sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}' BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' @@ -121,7 +138,7 @@ tasks: - task: engine:dev vars: PORT: '{{.ENGINE_PORT}}' - - task: 'backend:dev:{{.BACKEND}}' + - task: '{{.BACKEND_TASK}}' vars: PORT: '{{.BACKEND_PORT}}' AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}' @@ -131,6 +148,7 @@ tasks: PORT: '{{.FRONTEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + SAAS_ENV: '{{.SAAS_ENV}}' # ============================================================ # Build diff --git a/WINDOWS_SIGNING.md b/WINDOWS_SIGNING.md index 58ffd6e657..95cbbd24e2 100644 --- a/WINDOWS_SIGNING.md +++ b/WINDOWS_SIGNING.md @@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop ## Overview +Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never +leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach +of uploading a base64 `.pfx` to a repository secret has been removed from the +workflows - the sections below describe KeyLocker, which is what actually runs. + Windows code signing is essential for: - Preventing Windows SmartScreen warnings - Building trust with users @@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica ### Required Secrets -Navigate to your GitHub repository → Settings → Secrets and variables → Actions +Navigate to your GitHub repository → Settings → Environments → `release-signing`. -Add the following secrets: +These live in the `release-signing` environment, not at repository scope. That +environment requires reviewer approval and is limited to `main`, `release`, +`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console. -#### 1. `WINDOWS_CERTIFICATE` -- **Description**: Base64-encoded .pfx certificate file -- **How to create**: - -**On macOS/Linux:** -```bash -base64 -i certificate.pfx | pbcopy # Copies to clipboard -``` - -**On Windows (PowerShell):** -```powershell -[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard -``` - -Paste the entire base64 string into the GitHub secret. - -#### 2. `WINDOWS_CERTIFICATE_PASSWORD` -- **Description**: Password for the .pfx certificate -- **Value**: The password you set when creating/exporting the .pfx file +| Secret | Description | +| --- | --- | +| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. | +| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. | +| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. | +| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. | +| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. | ### Optional Secrets for Tauri Updater @@ -110,23 +105,23 @@ The Windows signing configuration is already set up: ### 2. GitHub Workflow (.github/workflows/tauri-build.yml) -The workflow includes three Windows signing steps: +The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being +set and the ref being the release branch: -1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store -2. **Build Tauri App**: Builds and signs the application using the imported certificate -3. **Verify Signature**: Validates that both .exe and .msi files are properly signed +1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing` +2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config +3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider +4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed ## Testing the Setup ### 1. Local Testing (Windows Only) -Before pushing to GitHub, test locally: +KeyLocker is CI-only. To check signing locally, install your own certificate into +the Windows store and point Tauri at it; the build no longer reads any certificate +from an environment variable. ```powershell -# Set environment variables -$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) -$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password" - # Build the application cd frontend npm run tauri build @@ -191,9 +186,10 @@ Look for: - Consider EV certificate for immediate reputation ### Certificate Not Found During Build -- Verify `WINDOWS_CERTIFICATE` secret is set -- Check base64 encoding is correct (no extra whitespace) -- Ensure password is correct +- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty + the signing steps skip silently and the build succeeds unsigned. +- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace) +- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair ## Security Best Practices @@ -220,11 +216,10 @@ Look for: ## Certificate Lifecycle ### Before Expiration -1. Obtain new certificate from CA (typically annual renewal) -2. Convert to .pfx format if needed -3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate -4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed -5. Test build to verify new certificate works +1. Renew the certificate in the DigiCert ONE console (typically annual) +2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment +3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD` +4. Test build to verify the new certificate works ### Expired Certificates - Signed binaries remain valid (timestamp proves signing time) diff --git a/app/.env.saas b/app/.env.saas index fb5feec559..25eefb84c5 100644 --- a/app/.env.saas +++ b/app/.env.saas @@ -1,15 +1,16 @@ -############################################################################### -# Stirling-PDF SaaS environment defaults. +# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in +# .env.saas.local, which is loaded first and wins. Do not commit that file. # -# This file is committed and provides non-secret defaults loaded by -# `task backend:dev:saas`. Put real values for secrets (passwords, project -# refs, edge function secrets) in `.env.saas.local` - any variable set there -# takes precedence over what's defined here. +# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref: # -# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in. -############################################################################### +# prod PROFILES=none SAAS_DB_* the live project +# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there +# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch +# +# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when +# testing an open SaaS PR, since its preview branch is the only place those migrations are applied. -# ---------- Supabase project ---------- +# ---------- Supabase project (prod / no-profile) ---------- # Project reference (the subdomain part of .supabase.co). Required. # Set in .env.saas.local. SAAS_DB_PROJECT_REF= @@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF= # Edge function secret used by billing/license rollup calls. Set in .env.saas.local. SUPABASE_EDGE_FUNCTION_SECRET= -# ---------- Database (saas profile) ---------- -# Direct JDBC URL to the Supabase Postgres. Required when running the plain -# `saas` profile (i.e. without `--spring.profiles.include=dev`). +# ---------- Database (no profile) ---------- +# Direct JDBC URL to the Supabase Postgres. Required when running without +# `--spring.profiles.include=...`. # Example: jdbc:postgresql://db..supabase.co:5432/postgres SAAS_DB_URL= SAAS_DB_USERNAME=postgres SAAS_DB_PASSWORD= -# ---------- Database (dev profile overrides) ---------- -# Used when `--spring.profiles.include=dev` is active. The dev profile -# defaults the URL/username to the shared dev Supabase project, but the -# password must still be provided in .env.saas.local. -SAAS_DEV_DB_URL= +# ---------- staging profile ---------- +# The shared long-lived v3 project. application-staging.properties defaults the ref, +# URL, database host and meter endpoint, so staging needs only the password, in +# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives. +# +# The ref and publishable key are duplicated here because the task derives the +# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default. +# Neither is secret: the ref is a public subdomain, the key ships in the bundle. +SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva +SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow +SAAS_STAGING_DB_USERNAME=postgres +SAAS_STAGING_DB_PASSWORD= + +# ---------- dev profile ---------- +# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase +# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and +# database host from it, so this one value follows a different PR. +# +# A preview branch has its own password and keys; the parent project's will not +# authenticate. Both go in .env.saas.local, along with the ref. +SAAS_DEV_PROJECT_REF= +SAAS_DEV_PUBLISHABLE_KEY= SAAS_DEV_DB_USERNAME=postgres SAAS_DEV_DB_PASSWORD= diff --git a/app/common/build.gradle b/app/common/build.gradle index f53ad0eb79..8af68bcb76 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -18,11 +18,11 @@ dependencies { api "org.apache.pdfbox:preflight:$pdfboxVersion" api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' - api 'org.snakeyaml:snakeyaml-engine:3.0.1' + api 'org.snakeyaml:snakeyaml-engine:3.1.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) - api 'org.simplejavamail:simple-java-mail:9.2.0' - api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support + api 'org.simplejavamail:simple-java-mail:9.3.2' + api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index ff1a880010..5e8f7fe336 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; @@ -13,6 +14,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfaLevelAServiceInterface; @Service @Slf4j @@ -51,12 +53,16 @@ public class EndpointConfiguration { private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); private final boolean runningProOrHigher; + private final boolean pdfUaAvailable; public EndpointConfiguration( ApplicationProperties applicationProperties, - @Qualifier("runningProOrHigher") boolean runningProOrHigher) { + @Qualifier("runningProOrHigher") boolean runningProOrHigher, + @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService) { this.applicationProperties = applicationProperties; this.runningProOrHigher = runningProOrHigher; + // The PDF/UA tagger ships in the proprietary module, and so do its endpoints. + this.pdfUaAvailable = pdfaLevelAService != null; init(); processEnvironmentConfigs(); } @@ -356,6 +362,7 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "pdf-to-img"); addEndpointToGroup("Convert", "img-to-pdf"); addEndpointToGroup("Convert", "pdf-to-pdfa"); + addEndpointToGroup("Convert", "pdf-to-ua"); addEndpointToGroup("Convert", "file-to-pdf"); addEndpointToGroup("Convert", "pdf-to-word"); addEndpointToGroup("Convert", "pdf-to-presentation"); @@ -395,6 +402,7 @@ public class EndpointConfiguration { // Backend-only endpoints (not in frontend tool registry endpoints) addEndpointToGroup("Security", "redact"); addEndpointToGroup("Security", "verify-pdf"); + addEndpointToGroup("Security", "accessibility-report"); addEndpointToGroup("Security", "sign"); // Adding endpoints to "Other" group @@ -529,6 +537,8 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "json-to-pdf"); addEndpointToGroup("Java", "pdf-to-video"); addEndpointToGroup("Java", "verify-pdf"); + addEndpointToGroup("Java", "pdf-to-ua"); + addEndpointToGroup("Java", "accessibility-report"); addEndpointToGroup("Java", "flatten"); addEndpointToGroup("Java", "unlock-pdf-forms"); addEndpointToGroup("Java", "validate-signature"); @@ -600,6 +610,8 @@ public class EndpointConfiguration { // veraPDF dependent endpoints addEndpointToGroup("veraPDF", "verify-pdf"); + addEndpointToGroup("veraPDF", "pdf-to-ua"); + addEndpointToGroup("veraPDF", "accessibility-report"); // Pdftohtml dependent endpoints addEndpointToGroup("Pdftohtml", "pdf-to-html"); @@ -630,6 +642,11 @@ public class EndpointConfiguration { disableGroup("enterprise"); } + if (!pdfUaAvailable) { + disableEndpoint("pdf-to-ua"); + disableEndpoint("accessibility-report"); + } + if (!applicationProperties.getSystem().isEnableUrlToPDF()) { disableEndpoint("url-to-pdf"); } diff --git a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java index adfad7704b..96293e9c3f 100644 --- a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java +++ b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java @@ -2,7 +2,9 @@ package stirling.software.common.aop; import java.io.IOException; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; @@ -273,6 +275,7 @@ public class AutoJobAspect { // Store the fileId for later reference pdfFile.setFileId(fileId); + recordPendingInputFile(fileId); // Replace the original MultipartFile with our persistent copy MultipartFile persistentFile = fileStorage.retrieveFile(fileId); @@ -290,6 +293,29 @@ public class AutoJobAspect { return originalArgs; } + /** + * Queue an input copy for attribution to the job. The job id does not exist yet at this point, + * so {@link JobExecutorService} drains this list once it mints one. + */ + @SuppressWarnings("unchecked") + private void recordPendingInputFile(String fileId) { + try { + Object existing = request.getAttribute(JobExecutorService.PENDING_INPUT_FILE_IDS_ATTR); + List ids; + if (existing instanceof List list) { + ids = (List) list; + } else { + ids = new ArrayList<>(); + request.setAttribute(JobExecutorService.PENDING_INPUT_FILE_IDS_ATTR, ids); + } + ids.add(fileId); + } catch (RuntimeException ex) { + // Without a bound request the copy cannot be attributed; the periodic sweep is the + // only backstop, so make the miss visible rather than silently leaking the file. + log.warn("Could not record input copy {} for cleanup: {}", fileId, ex.getMessage()); + } + } + private String getJobIdFromContext() { try { return (String) request.getAttribute("jobId"); diff --git a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java index 54ccafd665..9a0f23aa33 100644 --- a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java +++ b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java @@ -62,6 +62,15 @@ public class FormFieldWithCoordinates { @Schema(description = "Widget coordinates on each page (fields can have multiple widgets)") private List widgets; + @Schema(description = "Maximum character count for a text field (/MaxLen); null when unset") + private Integer maxLength; + + @Schema( + description = + "Push button activation action as a spec string:" + + " 'reset', 'print', 'uri:' or 'submit:'") + private String buttonActionSpec; + /** * Coordinates for a single widget annotation (visual representation of the field). A field can * have multiple widgets if it appears on multiple pages. @@ -94,5 +103,12 @@ public class FormFieldWithCoordinates { @Schema(description = "Font size in PDF points") private Float fontSize; + + @Schema( + description = + "CropBox height in PDF points. Lets the frontend reverse the backend's" + + " Y-flip when sending new widget coordinates back for" + + " create/modify operations.") + private Float cropBoxHeight; } } diff --git a/app/common/src/main/java/stirling/software/common/model/job/JobResult.java b/app/common/src/main/java/stirling/software/common/model/job/JobResult.java index aa43431a15..b3f1f31472 100644 --- a/app/common/src/main/java/stirling/software/common/model/job/JobResult.java +++ b/app/common/src/main/java/stirling/software/common/model/job/JobResult.java @@ -52,6 +52,13 @@ public class JobResult { /** Key/value metadata that survives the write-through into the shared job store. */ private final Map metadata = new ConcurrentHashMap<>(); + /** + * File ids of the persistent input copies made for this job. An async submit copies the upload + * into FileStorage so the job can still read it after the request returns; without tracking + * them here nothing would ever delete those copies. + */ + @JsonIgnore private final List inputFileIds = new CopyOnWriteArrayList<>(); + /** * Create a new JobResult with the given job ID * @@ -167,6 +174,22 @@ public class JobResult { return Collections.unmodifiableList(notes); } + /** Record a persistent input copy so job cleanup deletes it alongside the results. */ + public void addInputFileId(String fileId) { + if (fileId != null && !fileId.isBlank() && !inputFileIds.contains(fileId)) { + this.inputFileIds.add(fileId); + } + } + + /** + * File ids of this job's persistent input copies. + * + * @return An unmodifiable view of the input file ids + */ + public List getInputFileIds() { + return Collections.unmodifiableList(inputFileIds); + } + /** Attach a metadata value, e.g. a policy id so cluster peers can identify a policy run. */ public void putMetadata(String key, String value) { if (key != null && value != null) { diff --git a/app/common/src/main/java/stirling/software/common/service/FileStorage.java b/app/common/src/main/java/stirling/software/common/service/FileStorage.java index c5c1ddb5db..700a6e5a65 100644 --- a/app/common/src/main/java/stirling/software/common/service/FileStorage.java +++ b/app/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -179,6 +179,21 @@ public class FileStorage { return fileStore.delete(fileId); } + /** + * Delete a stored file without the per-file ownership check. + * + *

Job cleanup authorises at the job level and then deletes that job's own files, so the + * deleter is legitimately not their owner - an admin sweeping every user's jobs, or the + * unauthenticated scheduled task. Routing those through {@link #deleteFile(String)} makes the + * ownership check throw and silently orphans the files on disk. + * + *

Only ever pass file ids read back off a job that the caller has already been authorised + * for; never a caller-supplied id. + */ + public boolean deleteFileAsSystem(String fileId) { + return fileStore.delete(fileId); + } + public boolean fileExists(String fileId) { enforceOwnership(fileId); return fileStore.exists(fileId); diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index f283f65763..eca4413350 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -1,6 +1,7 @@ package stirling.software.common.service; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -33,6 +34,14 @@ import stirling.software.common.util.RegexPatternUtils; @Slf4j public class JobExecutorService { + /** + * Request attribute holding the FileStorage ids of persistent input copies made for the job + * about to be created. Populated before the job id exists (the aspect copies the upload while + * processing arguments), drained onto the JobResult as soon as the task is created so cleanup + * can delete them. + */ + public static final String PENDING_INPUT_FILE_IDS_ATTR = "autoJobPendingInputFileIds"; + private final TaskManager taskManager; private final FileStorage fileStorage; private final HttpServletRequest request; @@ -133,6 +142,7 @@ public class JobExecutorService { resourceWeight); taskManager.createTask(jobId); + registerPendingInputFiles(jobId); final String capturedJobIdForQueue = jobId; Supplier wrappedWork = @@ -163,6 +173,7 @@ public class JobExecutorService { return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null)); } else if (async) { taskManager.createTask(jobId); + registerPendingInputFiles(jobId); final String capturedJobId = jobId; @@ -484,4 +495,30 @@ public class JobExecutorService { } return baseJobId; } + + /** + * Hand the input copies made while processing arguments to the freshly created job, so job + * cleanup deletes them. Drains the attribute so a retry cannot attribute the same ids twice. + */ + @SuppressWarnings("unchecked") + private void registerPendingInputFiles(String jobId) { + if (request == null) { + return; + } + Object pending; + try { + pending = request.getAttribute(PENDING_INPUT_FILE_IDS_ATTR); + request.removeAttribute(PENDING_INPUT_FILE_IDS_ATTR); + } catch (RuntimeException ex) { + // No request bound to this thread (e.g. an internally dispatched job). + log.debug("Could not read pending input file ids: {}", ex.getMessage()); + return; + } + if (!(pending instanceof List ids)) { + return; + } + for (String fileId : (List) ids) { + taskManager.registerInputFile(jobId, fileId); + } + } } diff --git a/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java new file mode 100644 index 0000000000..2ee55719b2 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java @@ -0,0 +1,22 @@ +package stirling.software.common.service; + +import java.util.List; + +/** + * Raises a converted PDF/A file from conformance level B to level A, which needs the tagging the + * PDF/UA tagger does. Implemented only in the proprietary module; core builds convert at level B. + */ +public interface PdfaLevelAServiceInterface { + + /** + * @param levelA true only when the file was tagged and validated, so the claim is never a guess + */ + record Result(byte[] pdfBytes, boolean levelA, List warnings) {} + + /** + * @param part PDF/A part, 1 to 3; part 1 keeps its PDF 1.4 version + * @param alsoDeclareUa additionally claim PDF/UA, but only if it validates + */ + Result upgradeToLevelA( + byte[] pdfBytes, int part, String language, String title, boolean alsoDeclareUa); +} diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java index f504b39395..0d0f8c23aa 100644 --- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -17,6 +17,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -234,6 +235,24 @@ public class TaskManager { return false; } + /** + * Record a persistent input copy against a job so cleanup deletes it with the results. + * + * @param jobId The job ID + * @param fileId The FileStorage id of the input copy + * @return true if the job exists and the id was recorded + */ + public boolean registerInputFile(String jobId, String fileId) { + JobResult jobResult = jobResults.get(jobId); + if (jobResult == null) { + log.warn("Attempted to register an input file against non-existent job ID: {}", jobId); + return false; + } + jobResult.addInputFileId(fileId); + log.debug("Registered input file {} for job {}", fileId, jobId); + return true; + } + /** Attach metadata to a job and write it through to the shared store for cluster peers. */ public boolean putMetadata(String jobId, String key, String value) { JobResult jobResult = jobResults.get(jobId); @@ -329,25 +348,59 @@ public class TaskManager { return jobResults.computeIfAbsent(jobId, JobResult::createNew); } + /** + * What a cleanup pass removed. Returned by the on-demand cleanup so callers can assert on it. + */ + public record CleanupSummary(int jobsRemoved, int filesDeleted, int jobsRetained) {} + /** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */ - public void cleanupOldJobs() { + public CleanupSummary cleanupOldJobs() { if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) { - return; + return new CleanupSummary(0, 0, jobResults.size()); } + return cleanupJobs(false, jobId -> true); + } + + /** + * Force-expire this node's finished jobs now, ignoring the age threshold. Jobs still running + * are left alone - deleting their files mid-flight would break them - and are reported as + * retained. + * + *

Unlike {@link #cleanupOldJobs()} this always runs locally: it is an explicit request to + * release this node's storage, not the scheduled sweep the backplane TTL owns. + * + * @param jobIdFilter Only jobs whose id passes this predicate are considered, so a caller can + * restrict the sweep to jobs the requester is allowed to touch + * @return What was removed + */ + public CleanupSummary cleanupFinishedJobsNow(Predicate jobIdFilter) { + return cleanupJobs(true, jobIdFilter); + } + + private CleanupSummary cleanupJobs(boolean force, Predicate filter) { LocalDateTime expiryThreshold = LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES); LocalDateTime pendingExpiryThreshold = LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES); int removedCount = 0; + int filesDeleted = 0; + int retainedCount = 0; try { for (Map.Entry entry : jobResults.entrySet()) { JobResult result = entry.getValue(); + if (!filter.test(entry.getKey())) { + retainedCount++; + continue; + } + boolean expiredCompletedJob = result.isComplete() - && result.getCompletedAt() != null - && result.getCompletedAt().isBefore(expiryThreshold); + && (force + || (result.getCompletedAt() != null + && result.getCompletedAt() + .isBefore(expiryThreshold))); boolean abandonedPendingJob = !result.isComplete() && result.getCreatedAt() != null @@ -360,7 +413,7 @@ public class TaskManager { // Clean up file results if (expiredCompletedJob) { - cleanupJobFiles(result, entry.getKey()); + filesDeleted += cleanupJobFiles(result, entry.getKey()); } // Remove the job result @@ -369,15 +422,22 @@ public class TaskManager { jobStore.delete(entry.getKey()); } removedCount++; + } else { + retainedCount++; } } if (removedCount > 0) { - log.info("Cleaned up {} expired job results", removedCount); + log.info( + "Cleaned up {} {} job results ({} files deleted)", + removedCount, + force ? "finished" : "expired", + filesDeleted); } } catch (Exception e) { log.error("Error during job cleanup: {}", e.getMessage(), e); } + return new CleanupSummary(removedCount, filesDeleted, retainedCount); } /** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */ @@ -525,22 +585,43 @@ public class TaskManager { } } - /** Clean up files associated with a job result */ - private void cleanupJobFiles(JobResult result, String jobId) { + /** + * Clean up files associated with a job result: both the results and the persistent input copy + * an async submit made of the upload. + * + * @return The number of files actually deleted + */ + private int cleanupJobFiles(JobResult result, String jobId) { + int deleted = 0; // Clean up all result files if (result.hasFiles()) { for (ResultFile resultFile : result.getAllResultFiles()) { - try { - fileStorage.deleteFile(resultFile.getFileId()); - } catch (Exception e) { - log.warn( - "Failed to delete file {} for job {}: {}", - resultFile.getFileId(), - jobId, - e.getMessage()); + if (deleteJobFile(resultFile.getFileId(), jobId)) { + deleted++; } } } + for (String inputFileId : result.getInputFileIds()) { + if (deleteJobFile(inputFileId, jobId)) { + deleted++; + } + } + return deleted; + } + + /** + * Deletes as the system, not as the caller: an admin sweeping another user's jobs, or the + * scheduled task running with no security context, is not the file's owner, and the + * ownership-checked delete would throw and leave the file orphaned on disk. The job itself is + * already authorised by the time we get here, and these ids come off that job, not the request. + */ + private boolean deleteJobFile(String fileId, String jobId) { + try { + return fileStorage.deleteFileAsSystem(fileId); + } catch (Exception e) { + log.warn("Failed to delete file {} for job {}: {}", fileId, jobId, e.getMessage()); + return false; + } } /** Find the ResultFile metadata for a given file ID by searching through all job results */ diff --git a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 1f971d8d9e..5d833290ec 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -3,14 +3,20 @@ package stirling.software.common.util; import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; @@ -59,6 +65,24 @@ public enum FormFieldTypeSupport { List options) throws IOException { PDTextField textField = (PDTextField) field; + if (definition.fontSize() != null && definition.fontSize() > 0) { + textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g"); + } + if (Boolean.TRUE.equals(definition.multiline())) { + textField.setMultiline(true); + } + // Comb field: evenly spaced character cells (e.g. SSN, phone). Requires + // a positive MaxLen and is mutually exclusive with multiline. + if (definition.maxLength() != null && definition.maxLength() > 0) { + textField.setMaxLen(definition.maxLength()); + if (!Boolean.TRUE.equals(definition.multiline())) { + try { + textField.setComb(true); + } catch (Exception e) { + log.debug("Unable to set comb flag: {}", e.getMessage()); + } + } + } String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse(""); if (!defaultValue.isBlank()) { FormUtils.setTextValue(textField, defaultValue); @@ -272,14 +296,108 @@ public enum FormFieldTypeSupport { PDTerminalField createField(PDAcroForm acroForm) { return new PDSignatureField(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + // Empty signature placeholder: no value to apply (signed later by a sign tool). }, BUTTON("button", "pushButton", PDPushButton.class) { @Override PDTerminalField createField(PDAcroForm acroForm) { return new PDPushButton(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + if (field.getWidgets().isEmpty()) { + return; + } + PDAnnotationWidget widget = field.getWidgets().get(0); + + // Visible caption (/MK /CA). + String caption = definition.label(); + if (caption == null || caption.isBlank()) { + caption = definition.name(); + } + if (caption != null && !caption.isBlank()) { + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + if (mk == null) { + mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject()); + widget.setAppearanceCharacteristics(mk); + } + mk.setNormalCaption(caption); + } + widget.setPrinted(true); + + applyButtonAction(widget, definition.buttonAction()); + } }; + /** + * Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec, + * returning why it could not, or null on success. A blank spec clears the action. + */ + public static String applyButtonAction(PDAnnotationWidget widget, String action) { + if (action == null) { + return null; + } + if (action.isBlank()) { + // An explicit blank clears the action rather than leaving the old one behind. + widget.getCOSObject().removeItem(COSName.A); + return null; + } + String spec = action.trim(); + if (!ACTION_SPEC.matcher(spec).matches()) { + return "'" + action + "' is not a button action this editor understands"; + } + // The editor emits "uri:" the moment that kind is picked, before a URL is typed; an + // empty target is not yet an action, so clear rather than write an inert one. + int colon = spec.indexOf(':'); + if (colon >= 0 && spec.substring(colon + 1).isBlank()) { + widget.getCOSObject().removeItem(COSName.A); + return null; + } + try { + String lower = spec.toLowerCase(Locale.ROOT); + if (lower.equals("reset")) { + widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject()); + } else if (lower.equals("print")) { + PDActionNamed named = new PDActionNamed(); + named.setN("Print"); + widget.getCOSObject().setItem(COSName.A, named.getCOSObject()); + } else if (lower.startsWith("uri:")) { + PDActionURI uri = new PDActionURI(); + uri.setURI(spec.substring(4)); + widget.getCOSObject().setItem(COSName.A, uri.getCOSObject()); + } else if (lower.startsWith("submit:")) { + PDActionSubmitForm submit = new PDActionSubmitForm(); + // Store the target URL on the action dictionary's /F entry. + submit.getCOSObject().setString(COSName.F, spec.substring(7)); + widget.getCOSObject().setItem(COSName.A, submit.getCOSObject()); + } + return null; + } catch (Exception e) { + log.debug("Unable to apply button action '{}': {}", action, e.getMessage()); + return e.getMessage(); + } + } + + /** The spec forms applyButtonAction understands; anything else is reported, not dropped. */ + private static final Pattern ACTION_SPEC = + Pattern.compile( + "^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Map BY_TYPE = Arrays.stream(values()) .collect( diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index a1862d379c..401b1eb1ac 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -23,6 +23,7 @@ import org.apache.pdfbox.cos.COSArray; import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDPage; @@ -32,10 +33,12 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; @@ -68,6 +71,12 @@ public class FormUtils { public final Set CHOICE_FIELD_TYPES = Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + /** The reserved off-state name every toggle widget must carry an appearance for. */ + private final String OFF_STATE = "Off"; + + /** The on-state a checkbox gets when the definition supplies no export values. */ + private final String DEFAULT_CHECKBOX_ON_STATE = "Yes"; + /** * Threshold in PDF points for considering two widgets to be on the same line. Fields whose * y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead @@ -75,6 +84,9 @@ public class FormUtils { */ private static final float SAME_LINE_THRESHOLD_PT = 10.0f; + /** Below this, a rect change is a no-op and the existing /AP still maps exactly. */ + private static final float GEOMETRY_EPSILON_PT = 0.01f; + private static final Pattern HEX_UUID_PATTERN = Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); @@ -236,6 +248,8 @@ public class FormUtils { .multiline(multiline) .tooltip(tooltip) .widgets(widgets.isEmpty() ? null : widgets) + .maxLength(extractMaxLength(terminalField)) + .buttonActionSpec(extractButtonAction(terminalField)) .build()); } @@ -300,7 +314,8 @@ public class FormUtils { findPageIndexForAnnotation(document, fieldDict, annotationPageMap); if (pageIndex >= 0) { PDRectangle rectangle = new PDRectangle(rectArray); - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, null, field)); } else { @@ -373,7 +388,8 @@ public class FormUtils { } } - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, exportValue, field)); } catch (Exception e) { @@ -387,6 +403,15 @@ public class FormUtils { return result; } + /** Unreadable geometry yields null, which must never reach the list the comparator walks. */ + private void addWidget( + List target, + FormFieldWithCoordinates.WidgetCoordinates widget) { + if (widget != null) { + target.add(widget); + } + } + private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( PDDocument document, PDRectangle rectangle, @@ -424,13 +449,14 @@ public class FormUtils { float finalW = width; float finalH = height; - // Validate coordinates are within reasonable bounds - if (finalX < -1.0f - || finalY < -1.0f - || finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow - || finalY > cropHeight + 1.0f) { + // Only nonsense is rejected. A widget outside the visible page is legal and must still be + // reported, or the field loses its geometry and the user cannot drag it back. + if (!Float.isFinite(finalX) + || !Float.isFinite(finalY) + || !Float.isFinite(finalW) + || !Float.isFinite(finalH)) { log.warn( - "Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}", + "Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}", field.getFullyQualifiedName(), pageIndex, finalX, @@ -439,6 +465,12 @@ public class FormUtils { finalH); return null; } + if (finalX < 0 || finalY < 0 || finalX > cropBox.getWidth() || finalY > cropHeight) { + log.debug( + "Widget for field '{}' sits outside page {}", + field.getFullyQualifiedName(), + pageIndex); + } return FormFieldWithCoordinates.WidgetCoordinates.builder() .pageIndex(pageIndex) @@ -448,6 +480,7 @@ public class FormUtils { .height(finalH) .exportValue(exportValue) .fontSize(extractFontSize(field)) + .cropBoxHeight(cropHeight) .build(); } @@ -459,12 +492,40 @@ public class FormUtils { * * @param document PDF document to repair */ + /** + * PDFBox reads /Opt entries without following references, so an option stored indirectly - as + * real forms do - silently disappears. Resolving in place keeps the value and the reader + * honest. + */ + private void resolveIndirectChoiceOptions(PDAcroForm acroForm) { + try { + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDChoice)) { + continue; + } + COSBase raw = field.getCOSObject().getDictionaryObject(COSName.OPT); + if (!(raw instanceof COSArray options)) { + continue; + } + for (int i = 0; i < options.size(); i++) { + COSBase resolved = options.getObject(i); + if (resolved != null && resolved != options.get(i)) { + options.set(i, resolved); + } + } + } + } catch (Exception e) { + log.debug("Could not resolve indirect choice options: {}", e.getMessage()); + } + } + public void repairMissingWidgetPageReferences(PDDocument document) { try { PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { return; } + resolveIndirectChoiceOptions(acroForm); log.debug("Checking for widgets with missing page references..."); int repairedCount = 0; @@ -887,7 +948,9 @@ public class FormUtils { } PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); try { - // Map standard name used by many DAs + // Both spellings: a DA naming a font missing from /DR makes + // refreshAppearances throw for the whole form, not just that field. + dr.put(COSName.getPDFName("Helv"), helvetica); dr.put(COSName.getPDFName("Helvetica"), helvetica); } catch (Exception ignore) { try { @@ -991,7 +1054,7 @@ public class FormUtils { try { textField.setValue(value != null ? value : ""); return; - } catch (IOException initial) { + } catch (IOException | RuntimeException initial) { log.debug( "Primary fill failed for text field '{}': {}", textField.getFullyQualifiedName(), @@ -1277,6 +1340,11 @@ public class FormUtils { } return String.join(",", selected); } + // A signature has no text value; getValueAsString would emit a JVM identity hash that + // changes on every load, so the same document would describe itself differently. + if (field instanceof PDSignatureField) { + return null; + } return field.getValueAsString(); } catch (Exception e) { log.debug( @@ -1451,16 +1519,26 @@ public class FormUtils { return tooltipLabel; } - // Only check options for choice-type fields (combobox, listbox, radio) - if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + // A clearly meaningful field name describes the whole field and matches + // the name shown in the editor, so it is the best label. + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + // An option only beats the name when the name is auto-generated; a human-typed + // one wins below, so a group named "Choice" does not read as its first option. + if (CHOICE_FIELD_TYPES.contains(type) + && options != null + && !options.isEmpty() + && looksAutoGenerated(name)) { String optionCandidate = cleanLabel(options.getFirst()); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } } - String humanized = cleanLabel(humanizeName(name)); - if (humanized != null && !looksGeneric(humanized)) { + if (humanized != null && !looksAutoGenerated(name)) { return humanized; } @@ -1497,6 +1575,27 @@ public class FormUtils { || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); } + /** + * True only for auto-generated identifiers ("Field_5", "t3", UUIDs). Unlike {@link + * #looksGeneric} it keeps human-typed placeholders like "Choice", which are usable labels. + */ + private boolean looksAutoGenerated(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + if (simplified.isEmpty()) return true; + + String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll(""); + if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true; + + return patterns.getPattern("^field(\\s*\\d+)?$", Pattern.CASE_INSENSITIVE) + .matcher(simplified) + .matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + private String humanizeName(String name) { if (name == null) return null; @@ -1513,35 +1612,62 @@ public class FormUtils { public void modifyFormFields( PDDocument document, List modifications) { + modifyFormFields(document, modifications, null); + } + + public void modifyFormFields( + PDDocument document, + List modifications, + List skipped) { if (document == null || modifications == null || modifications.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot modify fields because the document has no AcroForm"); + for (ModifyFormFieldDefinition modification : modifications) { + if (modification != null) { + recordSkip( + skipped, + "modify", + modification.targetName(), + "the document has no form to modify"); + } + } return; } Set existingNames = collectExistingFieldNames(acroForm); for (ModifyFormFieldDefinition modification : modifications) { - if (modification == null || modification.targetName() == null) { + if (modification == null) { continue; } - String lookupName = modification.targetName().trim(); + String lookupName = + modification.targetName() == null ? "" : modification.targetName().trim(); if (lookupName.isEmpty()) { + recordSkip(skipped, "modify", null, "the request named no field to change"); + continue; + } + + String nameProblem = renameProblem(lookupName, modification.name()); + if (nameProblem != null) { + log.warn("Rejecting rename of '{}': {}", sanitizeForLog(lookupName), nameProblem); + recordSkip(skipped, "modify", lookupName, nameProblem); continue; } PDField originalField = locateField(acroForm, lookupName); if (originalField == null) { log.warn("No matching field '{}' found for modification", lookupName); + recordSkip(skipped, "modify", lookupName, "no field with that name exists"); continue; } List widgets = originalField.getWidgets(); if (widgets == null || widgets.isEmpty()) { log.warn("Field '{}' has no widgets; skipping modification", lookupName); + recordSkip(skipped, "modify", lookupName, "the field has nothing drawn on a page"); continue; } @@ -1552,6 +1678,8 @@ public class FormUtils { log.warn( "Unable to resolve widget page or rectangle for '{}'; skipping", lookupName); + recordSkip( + skipped, "modify", lookupName, "the field is not placed on a known page"); continue; } @@ -1564,6 +1692,11 @@ public class FormUtils { .getSupportedNewFieldTypes() .contains(resolvedType)) { log.warn("Unsupported target type '{}' for field '{}'", resolvedType, lookupName); + recordSkip( + skipped, + "modify", + lookupName, + "'" + abbreviate(resolvedType, 60) + "' is not a supported field type"); continue; } @@ -1571,13 +1704,22 @@ public class FormUtils { Optional.ofNullable(modification.name()) .map(String::trim) .filter(s -> !s.isEmpty()) + // The editor seeds the box with the qualified name, so a submission + // equal to it is not a rename; keep the field's own partial name. + .filter(name -> !name.equals(lookupName)) + .map(name -> leafName(lookupName, name)) .orElseGet(originalField::getPartialName); + String qualified = originalField.getFullyQualifiedName(); + // desiredName is a PARTIAL name but existingNames holds qualified ones, so compare + // under this field's own parent or siblings collide unnoticed. + String prefix = parentPrefix(qualified); + String reservedName = null; if (desiredName != null) { - existingNames.remove(originalField.getFullyQualifiedName()); - existingNames.remove(originalField.getPartialName()); - desiredName = generateUniqueFieldName(desiredName, existingNames); - existingNames.add(desiredName); + existingNames.remove(qualified); + desiredName = generateUniqueFieldName(desiredName, existingNames, prefix); + reservedName = prefix + desiredName; + existingNames.add(reservedName); } // Try to modify field in-place first for simple property changes @@ -1586,17 +1728,27 @@ public class FormUtils { if (!typeChanging) { try { - modifyFieldPropertiesInPlace(originalField, modification, desiredName); + modifyFieldPropertiesInPlace( + document, originalField, modification, desiredName, skipped); log.debug("Successfully modified field '{}' in-place", lookupName); continue; // Skip the remove-and-recreate process } catch (Exception e) { log.debug( "In-place modification failed for '{}', falling back to recreation: {}", - lookupName, + sanitizeForLog(lookupName), e.getMessage()); } } + // Recreation always builds a top-level field, so running it on a field nested under a + // parent would silently move it out of that parent and change its qualified name. + if (!prefix.isEmpty()) { + log.warn("Cannot recreate nested field '{}'; leaving it as it was", lookupName); + recordSkip(skipped, "modify", lookupName, refusalReason(typeChanging)); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + // For type changes or when in-place modification fails, use remove-and-recreate // But create the new field first to ensure success before removing the original NewFormFieldDefinition replacementDefinition = @@ -1613,16 +1765,31 @@ public class FormUtils { modification.multiSelect(), modification.options(), modification.defaultValue(), - modification.tooltip()); + modification.tooltip(), + modification.fontSize(), + modification.readOnly(), + modification.multiline(), + modification.maxLength(), + modification.buttonAction()); List sanitizedOptions = sanitizeOptions(modification.options()); - try { - FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); - if (handler == null || handler.doesNotsupportsDefinitionCreation()) { - handler = FormFieldTypeSupport.TEXT; - } + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Falling back to a text field here would silently retype the field and report + // success, so refuse instead and leave the original alone. + recordSkip( + skipped, + "modify", + lookupName, + "'" + + resolvedType + + "' cannot be rebuilt, so the field was left as it was"); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + try { // Create new field first - if this fails, original field is preserved createNewField( handler, @@ -1635,25 +1802,56 @@ public class FormUtils { removeFieldFromDocument(document, acroForm, originalField); + // A rebuilt toggle has no /AP yet, so without this it renders blank and cannot + // tick. + applyButtonAppearances( + document, + acroForm, + List.of(Map.entry(prefix + desiredName, replacementDefinition))); + log.debug( "Successfully replaced field '{}' with type '{}'", - lookupName, + sanitizeForLog(lookupName), resolvedType); } catch (Exception e) { log.warn( "Failed to modify form field '{}' to type '{}': {}", - lookupName, + sanitizeForLog(lookupName), resolvedType, e.getMessage(), e); + recordSkip(skipped, "modify", lookupName, readableFailure(e)); + releaseReservedName(existingNames, reservedName, qualified); } } ensureAppearances(acroForm); } + /** Nothing was applied, so give the field back its real name and drop the one we reserved. */ + private void releaseReservedName( + Set existingNames, String reservedName, String originalQualifiedName) { + if (reservedName != null) { + existingNames.remove(reservedName); + } + if (originalQualifiedName != null) { + existingNames.add(originalQualifiedName); + } + } + + /** Why the edit was refused, which is not always the type change that triggered the path. */ + private String refusalReason(boolean typeChanging) { + return typeChanging + ? "a field nested under a parent cannot have its type changed here" + : "this change needs the field rebuilt, which a nested field does not support"; + } + private void modifyFieldPropertiesInPlace( - PDField field, ModifyFormFieldDefinition modification, String newName) + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + String newName, + List skipped) throws IOException { if (newName != null && !newName.equals(field.getPartialName())) { field.setPartialName(newName); @@ -1690,6 +1888,14 @@ public class FormUtils { if (modification.multiSelect() != null) { choiceField.setMultiSelect(modification.multiSelect()); } + } else if (modification.options() != null && !(field instanceof PDChoice)) { + // Only a choice field stores an option list, so say so rather than drop the edit and + // let the panel report it as saved. + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "only dropdown and list fields have an editable option list"); } // Update tooltip on widgets @@ -1703,6 +1909,318 @@ public class FormUtils { } } } + + // Update read-only flag + if (modification.readOnly() != null) { + field.setReadOnly(modification.readOnly()); + } + + // Update multiline flag (text fields only) + if (modification.multiline() != null && field instanceof PDTextField tf) { + tf.setMultiline(modification.multiline()); + } + + // Update the activation action (push buttons only) + if (modification.buttonAction() != null && field instanceof PDPushButton) { + String actionProblem = null; + for (PDAnnotationWidget widget : field.getWidgets()) { + String problem = + FormFieldTypeSupport.applyButtonAction(widget, modification.buttonAction()); + if (problem != null && actionProblem == null) { + actionProblem = problem; + } + } + if (actionProblem != null) { + recordSkip(skipped, "modify", field.getFullyQualifiedName(), actionProblem); + } + } + + // Update comb / max length (text fields only). Zero clears it, since a null means + // "unchanged" and the editor otherwise has no way to remove an existing /MaxLen. + if (modification.maxLength() != null && field instanceof PDTextField combTf) { + int maxLength = modification.maxLength(); + if (maxLength > 0) { + combTf.setMaxLen(maxLength); + if (!combTf.isMultiline()) { + try { + combTf.setComb(true); + } catch (Exception ignore) { + // comb is best-effort + } + } + } else { + combTf.getCOSObject().removeItem(COSName.MAX_LEN); + try { + combTf.setComb(false); + } catch (Exception ignore) { + // comb is best-effort + } + } + } + + // Update font size (variable-text fields only: text/combo/list) + if (modification.fontSize() != null + && modification.fontSize() > 0 + && field instanceof PDVariableText vt) { + applyFontSizeToDefaultAppearance(vt, modification.fontSize()); + // Clear the cached appearance so ensureAppearances() regenerates it + // with the new font size; otherwise viewers keep the old glyph sizing. + removeWidgetAppearanceStreams(field); + } + + // Incoming coordinates are CropBox-relative and lower-left-origin, the reverse + // of what createWidgetCoordinates extracts. + if (modification.x() != null + || modification.y() != null + || modification.width() != null + || modification.height() != null + || modification.optionGap() != null + || modification.optionSize() != null) { + updateWidgetGeometry(document, field, modification, skipped); + } + } + + /** + * Moves/resizes a field's widgets. The rect describes widget 0; the rest shift by the same + * delta and keep their own size, so a radio group travels intact instead of being normalised. + */ + /** A size PDFBox would write as "Infinity" or a zero-area box makes the field unusable. */ + private static boolean unusableSize(Float value) { + return value != null && (!Float.isFinite(value) || value <= 0); + } + + /** The rectangle enclosing every widget, or null when none has one. */ + private static PDRectangle widgetBounds(List widgets) { + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + boolean any = false; + for (PDAnnotationWidget widget : widgets) { + PDRectangle r = widget.getRectangle(); + if (r == null) continue; + any = true; + minX = Math.min(minX, r.getLowerLeftX()); + minY = Math.min(minY, r.getLowerLeftY()); + maxX = Math.max(maxX, r.getUpperRightX()); + maxY = Math.max(maxY, r.getUpperRightY()); + } + return any ? new PDRectangle(minX, minY, maxX - minX, maxY - minY) : null; + } + + private void updateWidgetGeometry( + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + List skipped) { + List widgets = field.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + return; + } + if (unusableSize(modification.width()) || unusableSize(modification.height())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "a width and height above zero are required, so the size was left as it was"); + return; + } + if (modification.x() != null && !Float.isFinite(modification.x()) + || modification.y() != null && !Float.isFinite(modification.y())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "the position is not a usable number, so the field was left where it was"); + return; + } + PDAnnotationWidget anchor = widgets.get(0); + PDRectangle anchorRect = anchor.getRectangle(); + if (anchorRect == null) { + return; + } + + Map pageMap = buildAnnotationPageMap(document); + int anchorPage = determineWidgetPageIndex(document, anchor, pageMap); + float offX = 0; + float offY = 0; + if (anchorPage >= 0) { + PDRectangle cropBox = document.getPage(anchorPage).getCropBox(); + offX = cropBox.getLowerLeftX(); + offY = cropBox.getLowerLeftY(); + } + + float newX = + modification.x() != null ? modification.x() + offX : anchorRect.getLowerLeftX(); + float newY = + modification.y() != null ? modification.y() + offY : anchorRect.getLowerLeftY(); + float dx = newX - anchorRect.getLowerLeftX(); + float dy = newY - anchorRect.getLowerLeftY(); + Float newW = modification.width(); + Float newH = modification.height(); + + // Spacing and size are a property of the whole group, so an explicit change re-flows + // every option. Gated on those two: a plain drag sends widget 0's size, which would be + // mistaken for the group's height and collapse the stack. + if ((modification.optionGap() != null || modification.optionSize() != null) + && field instanceof PDRadioButton + && widgets.size() > 1) { + PDRectangle bounds = widgetBounds(widgets); + if (bounds != null) { + PDRectangle box = + new PDRectangle( + bounds.getLowerLeftX() + dx, + bounds.getLowerLeftY() + dy, + modification.width() != null + ? modification.width() + : bounds.getWidth(), + modification.height() != null + ? modification.height() + : bounds.getHeight()); + List reflowed = + radioOptionRects( + box, + widgets.size(), + modification.optionGap(), + modification.optionSize()); + List groupStates = currentWidgetOnStates((PDButton) field); + for (int i = 0; i < widgets.size(); i++) { + widgets.get(i).setRectangle(reflowed.get(i)); + rebuildWidgetAppearance(document, field, widgets.get(i), i, groupStates, true); + } + return; + } + } + + // Read the on-states off /AP /N before the strip: PDFBox derives a button's + // value vocabulary from those keys, so a guessed state orphans /V. + List onStates = + field instanceof PDButton button ? currentWidgetOnStates(button) : List.of(); + boolean isRadio = field instanceof PDRadioButton; + + int leftOffPage = 0; + for (int i = 0; i < widgets.size(); i++) { + PDAnnotationWidget widget = widgets.get(i); + PDRectangle rect = widget.getRectangle(); + if (rect == null) { + continue; + } + if (i > 0 && determineWidgetPageIndex(document, widget, pageMap) != anchorPage) { + // A delta measured in another page's user space means nothing here. + log.warn( + "Field '{}' widget {} sits on a different page; geometry left alone", + field.getFullyQualifiedName(), + i); + leftOffPage++; + continue; + } + // Only the widget the request describes takes the new size; the rest keep theirs. + float targetW = i == 0 && newW != null ? newW : rect.getWidth(); + float targetH = i == 0 && newH != null ? newH : rect.getHeight(); + boolean resized = + Math.abs(targetW - rect.getWidth()) > GEOMETRY_EPSILON_PT + || Math.abs(targetH - rect.getHeight()) > GEOMETRY_EPSILON_PT; + widget.setRectangle( + new PDRectangle( + rect.getLowerLeftX() + dx, + rect.getLowerLeftY() + dy, + targetW, + targetH)); + // A pure translation re-maps the same /AP onto the new /Rect unchanged, but a toggle + // with no /AP at all has no on-state vocabulary and must be given one regardless. + boolean toggle = field instanceof PDCheckBox || field instanceof PDRadioButton; + if (resized || (toggle && normalAppearanceOnState(widget) == null)) { + rebuildWidgetAppearance(document, field, widget, i, onStates, isRadio); + } + } + // One row per field, not per widget, so a split group cannot fill the report on its own. + if (leftOffPage > 0) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + leftOffPage + " widget(s) on another page were left where they were"); + } + } + + /** After a resize, rebuilds the appearance PDFBox cannot regenerate by itself. */ + private void rebuildWidgetAppearance( + PDDocument document, + PDField field, + PDAnnotationWidget widget, + int index, + List onStates, + boolean isRadio) { + if (field instanceof PDSignatureField) { + // PDFBox never rebuilds a signature appearance; dropping it blanks the field. + return; + } + COSName priorState = widget.getCOSObject().getCOSName(COSName.AS); + try { + if (field instanceof PDCheckBox || field instanceof PDRadioButton) { + // Drop the stale streams: viewers stretch an /AP built for the old BBox + // onto the new /Rect, so a resized toggle looks distorted. + widget.getCOSObject().removeItem(COSName.AP); + String onState = + index < onStates.size() ? onStates.get(index) : DEFAULT_CHECKBOX_ON_STATE; + applyToggleAppearance(document, widget, onState, isRadio); + // applyToggleAppearance parks the widget on Off; put the selection back. + if (priorState != null && !OFF_STATE.equals(priorState.getName())) { + widget.getCOSObject().setName(COSName.AS, onState); + } + } else if (field instanceof PDPushButton) { + // /D and /R still carry the old BBox and cannot be regenerated, so drop them + // rather than leave a stretched down-state; viewers fall back to /N. + PDAppearanceDictionary existing = widget.getAppearance(); + if (existing != null) { + existing.getCOSObject().removeItem(COSName.D); + existing.getCOSObject().removeItem(COSName.R); + } + applyPushButtonAppearance(document, widget); + } else { + // text/choice: refreshAppearances() rebuilds these from /DA in ensureAppearances(). + widget.getCOSObject().removeItem(COSName.AP); + } + } catch (Exception e) { + log.warn( + "Could not rebuild the appearance for '{}' widget {}: {}", + field.getFullyQualifiedName(), + index, + e.getMessage()); + } + } + + /** Rewrites only the size token in a variable-text field's /DA, keeping font and colour. */ + private void applyFontSizeToDefaultAppearance(PDVariableText field, float fontSize) { + String da = field.getDefaultAppearance(); + if (da != null && !da.isBlank()) { + // Replace the size token (the operand immediately before "Tf"). + String[] tokens = da.split("\\s+"); + boolean replaced = false; + for (int i = 0; i < tokens.length; i++) { + if ("Tf".equals(tokens[i]) && i > 0) { + tokens[i - 1] = String.valueOf(fontSize); + replaced = true; + break; + } + } + if (replaced) { + field.setDefaultAppearance(String.join(" ", tokens)); + return; + } + } + field.setDefaultAppearance("/Helv " + fontSize + " Tf 0 g"); + } + + /** Drops cached /AP appearance streams from every widget of a field. */ + private void removeWidgetAppearanceStreams(PDField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return; + } + for (PDAnnotationWidget widget : widgets) { + widget.getCOSObject().removeItem(COSName.AP); + } } private String fallbackLabelForType(String type, int typeIndex) { @@ -1830,12 +2348,700 @@ public class FormUtils { return -1; } + /** + * Adds fields, creating an AcroForm if absent. Definition coordinates are CropBox-relative and + * lower-left-origin (the inverse of {@link #createWidgetCoordinates}). + */ + public void addNewFields(PDDocument document, List definitions) + throws IOException { + addNewFields(document, definitions, null); + } + + /** + * A form with variable-text fields needs /DR and /DA; PDFBox refuses to set a value without + * them, and a PDF that never had a form has neither. + */ + private void ensureAcroFormDefaults(PDAcroForm acroForm) { + if (acroForm == null) return; + try { + PDResources dr = acroForm.getDefaultResources(); + if (dr == null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + String resourceName = "Helv"; + COSName alias = dr.add(new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + if (alias != null && alias.getName() != null && !alias.getName().isBlank()) { + resourceName = alias.getName(); + } + String da = acroForm.getDefaultAppearance(); + if (da == null || da.isBlank()) { + acroForm.setDefaultAppearance("/" + resourceName + " 12 Tf 0 g"); + } + } catch (Exception e) { + log.debug("Could not prepare AcroForm defaults: {}", e.getMessage()); + } + } + + public void addNewFields( + PDDocument document, + List definitions, + List skipped) + throws IOException { + if (document == null || definitions == null || definitions.isEmpty()) return; + // A page-less document has nowhere to put a widget; the clamp below cannot make it safe. + if (document.getNumberOfPages() == 0) { + log.warn("Cannot add form fields: document has no pages"); + for (NewFormFieldDefinition definition : definitions) { + if (definition != null) { + recordSkip(skipped, "add", definition.name(), "the document has no pages"); + } + } + return; + } + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + // Create a new AcroForm for PDFs that don't have one yet + acroForm = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(acroForm); + } + ensureAcroFormDefaults(acroForm); + + Set existingNames = collectExistingFieldNames(acroForm); + int pageCount = document.getNumberOfPages(); + // Buttons need their appearances built after creation; see applyButtonAppearances. + List> createdButtons = new ArrayList<>(); + + for (NewFormFieldDefinition definition : definitions) { + if (definition == null) continue; + + String nameProblem = invalidFieldNameReason(definition.name()); + if (nameProblem != null) { + log.warn("Rejecting new field: {}", nameProblem); + recordSkip(skipped, "add", definition.name(), nameProblem); + continue; + } + + String resolvedType = + Optional.ofNullable(definition.type()) + .map(FormUtils::normalizeFieldType) + .orElse(FIELD_TYPE_TEXT); + + int pageIdx = definition.pageIndex() != null ? definition.pageIndex() : 0; + if (pageIdx < 0 || pageIdx >= pageCount) { + log.warn( + "Page index {} out of range (0-{}); clamping to last page", + pageIdx, + pageCount - 1); + // Clamped, so the field IS created; not a dropped edit and not reported as one. + pageIdx = Math.max(0, pageCount - 1); + } + PDPage page; + try { + page = document.getPage(pageIdx); + } catch (RuntimeException e) { + // getNumberOfPages() reports the raw /Count, which a broken /Pages tree can + // overstate, so the page may still not be there. + recordSkip( + skipped, + "add", + definition.name(), + "page " + (pageIdx + 1) + " could not be read from this PDF"); + continue; + } + PDRectangle cropBox = page.getCropBox(); + + // CropBox-relative, lower-left-origin -> absolute PDF user space. + float x = (definition.x() != null ? definition.x() : 0f) + cropBox.getLowerLeftX(); + float y = (definition.y() != null ? definition.y() : 0f) + cropBox.getLowerLeftY(); + float w = definition.width() != null ? definition.width() : 150f; + float h = definition.height() != null ? definition.height() : 20f; + PDRectangle rectangle = new PDRectangle(x, y, w, h); + + String baseName = + Optional.ofNullable(definition.name()) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElse("field"); + String uniqueName = generateUniqueFieldName(baseName, existingNames); + existingNames.add(uniqueName); + + List options = sanitizeOptions(definition.options()); + + try { + if (FIELD_TYPE_RADIO.equals(resolvedType)) { + // Radio is a single field with one widget per option; it can't go + // through the single-widget createNewField path. + createRadioField(acroForm, page, rectangle, uniqueName, definition, options); + } else { + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Quietly making it a text field reported success for a field the + // caller never asked for; say so instead. + recordSkip( + skipped, + "add", + uniqueName, + "'" + resolvedType + "' fields cannot be created"); + existingNames.remove(uniqueName); + continue; + } + createNewField( + handler, acroForm, page, rectangle, uniqueName, definition, options); + } + createdButtons.add(Map.entry(uniqueName, definition)); + } catch (Exception e) { + log.warn( + "Failed to create field '{}' of type '{}': {}", + sanitizeForLog(uniqueName), + resolvedType, + e.getMessage(), + e); + recordSkip(skipped, "add", uniqueName, readableFailure(e)); + } + } + + applyButtonAppearances(document, acroForm, createdButtons); + ensureAppearances(acroForm); + } + + /** + * {@link PDAcroForm#refreshAppearances()} never synthesizes /AP for the button family, and + * PDFBox reads a button's on-state from the /AP /N keys, so draw them then re-apply the value. + */ + private void applyButtonAppearances( + PDDocument document, + PDAcroForm acroForm, + List> created) { + for (Map.Entry entry : created) { + PDField field = acroForm.getField(entry.getKey()); + if (field instanceof PDPushButton) { + for (PDAnnotationWidget widget : field.getWidgets()) { + try { + applyPushButtonAppearance(document, widget); + } catch (Exception e) { + log.warn( + "Could not build an appearance for button '{}': {}", + entry.getKey(), + e.getMessage()); + } + } + continue; + } + if (!(field instanceof PDCheckBox) && !(field instanceof PDRadioButton)) { + continue; + } + boolean isRadio = field instanceof PDRadioButton; + List onStates = buttonOnStates((PDButton) field); + List widgets = field.getWidgets(); + for (int i = 0; i < widgets.size(); i++) { + String onState = i < onStates.size() ? onStates.get(i) : DEFAULT_CHECKBOX_ON_STATE; + try { + applyToggleAppearance(document, widgets.get(i), onState, isRadio); + } catch (Exception e) { + log.warn( + "Could not build an appearance for '{}' widget {}: {}", + entry.getKey(), + i, + e.getMessage()); + } + } + applyButtonDefault((PDButton) field, entry.getValue(), onStates); + } + } + + /** The on-state per widget: the export values when set, else the single checkbox state. */ + private List buttonOnStates(PDButton button) { + List exportValues = button.getExportValues(); + if (exportValues != null && !exportValues.isEmpty()) { + return exportValues; + } + return List.of(DEFAULT_CHECKBOX_ON_STATE); + } + + /** Each widget's live on-state, read from /AP /N before that dictionary is dropped. */ + private List currentWidgetOnStates(PDButton button) { + List exportValues = button.getExportValues(); + List widgets = button.getWidgets(); + // A checkbox's widgets all share one on-state, so /V names it however many there are. + // A radio's widgets each have their own, so /V identifies one and cannot stand in. + boolean sharedOnState = !(button instanceof PDRadioButton); + String fromValue = sharedOnState || widgets.size() == 1 ? nonOffValueName(button) : null; + List states = new ArrayList<>(widgets.size()); + for (int i = 0; i < widgets.size(); i++) { + String state = normalAppearanceOnState(widgets.get(i)); + if ((state == null || state.isEmpty()) + && exportValues != null + && i < exportValues.size()) { + state = sanitizePdfName(exportValues.get(i)); + } + if (state == null || state.isEmpty()) { + state = fromValue; + } + states.add(state == null || state.isEmpty() ? DEFAULT_CHECKBOX_ON_STATE : state); + } + return states; + } + + /** A button's current value when it names a real on-state, else null. */ + private String nonOffValueName(PDButton button) { + try { + // /V is inheritable, so walk up. A malformed PDF can point /Parent back at an + // ancestor, so track what we have seen rather than trusting the chain to end. + COSBase raw = null; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + COSDictionary d = button.getCOSObject(); + while (d != null && raw == null && seen.add(d)) { + raw = d.getDictionaryObject(COSName.V); + COSBase parent = d.getDictionaryObject(COSName.PARENT); + d = parent instanceof COSDictionary parentDict ? parentDict : null; + } + String name = + switch (raw) { + case COSName cosName -> cosName.getName(); + case COSString cosString -> cosString.getString(); + case null, default -> null; + }; + return name == null || name.isEmpty() || OFF_STATE.equals(name) ? null : name; + } catch (Exception e) { + log.debug("Could not read a button's value: {}", e.getMessage()); + return null; + } + } + + /** The first non-Off key of a widget's /AP /N sub-dictionary, or null. */ + private String normalAppearanceOnState(PDAnnotationWidget widget) { + try { + PDAppearanceDictionary appearance = widget.getAppearance(); + PDAppearanceEntry normal = appearance != null ? appearance.getNormalAppearance() : null; + if (normal == null || !normal.isSubDictionary()) { + return null; + } + for (COSName name : normal.getSubDictionary().keySet()) { + if (!OFF_STATE.equals(name.getName())) { + return name.getName(); + } + } + } catch (Exception e) { + log.debug("Could not read a widget's on-state: {}", e.getMessage()); + } + return null; + } + + /** Re-applies the definition's default now that the on-state keys exist to resolve it. */ + private void applyButtonDefault( + PDButton button, NewFormFieldDefinition definition, List onStates) { + try { + if (button instanceof PDCheckBox checkBox) { + if (isChecked(definition.defaultValue())) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + return; + } + String requested = definition.defaultValue(); + if (requested == null || requested.isBlank()) { + return; + } + // The widget states are sanitized PDF names, so match the raw request against those. + String match = + onStates.stream() + .filter( + state -> + state.equals(requested) + || state.equalsIgnoreCase( + sanitizePdfName(requested))) + .findFirst() + .orElse(null); + if (match != null) { + button.setValue(match); + } + } catch (Exception e) { + log.debug( + "Could not apply default value for '{}': {}", + button.getPartialName(), + e.getMessage()); + } + } + + /** + * Builds a toggle's two-state /AP /N from drawing primitives, so no font resource is needed. + */ + private void applyToggleAppearance( + PDDocument document, PDAnnotationWidget widget, String onState, boolean isRadio) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + PDRectangle bbox = new PDRectangle(w, h); + + PDAppearanceDictionary appearance = new PDAppearanceDictionary(); + COSDictionary normalStates = new COSDictionary(); + normalStates.setItem( + COSName.getPDFName(OFF_STATE), + toggleStream(document, bbox, false, false).getCOSObject()); + normalStates.setItem( + COSName.getPDFName(onState), + toggleStream(document, bbox, true, isRadio).getCOSObject()); + appearance.getCOSObject().setItem(COSName.N, normalStates); + widget.setAppearance(appearance); + // Until a value selects it, the widget shows the Off appearance. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + } + + private PDAppearanceStream toggleStream( + PDDocument document, PDRectangle bbox, boolean on, boolean isRadio) throws IOException { + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(bbox); + stream.setResources(new PDResources()); + + float w = bbox.getWidth(); + float h = bbox.getHeight(); + float inset = Math.min(w, h) * 0.1f; + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setStrokingColor(0f, 0f, 0f); + content.setNonStrokingColor(0f, 0f, 0f); + content.setLineWidth(Math.max(0.5f, Math.min(w, h) * 0.06f)); + if (isRadio) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 2 - inset); + content.stroke(); + if (on) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 4 - inset / 2); + content.fill(); + } + } else { + content.addRect(inset, inset, w - 2 * inset, h - 2 * inset); + content.stroke(); + if (on) { + content.moveTo(w * 0.25f, h * 0.5f); + content.lineTo(w * 0.45f, h * 0.28f); + content.lineTo(w * 0.78f, h * 0.72f); + content.stroke(); + } + } + } + return stream; + } + + /** + * Draws a push button's single {@code /AP /N} stream from its {@code /MK} characteristics. + * PDFBox never synthesizes one, so without this a push button has no appearance at all. + */ + private void applyPushButtonAppearance(PDDocument document, PDAnnotationWidget widget) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(new PDRectangle(w, h)); + stream.setResources(new PDResources()); + + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + String caption = mk != null ? mk.getNormalCaption() : null; + // Honour the authored /MK colours; a hardcoded grey would restyle an existing button. + float[] background = mkColour(mk == null ? null : mk.getBackground(), 0.85f); + float[] border = mkColour(mk == null ? null : mk.getBorderColour(), 0f); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + float fontSize = Math.min(12f, h * 0.6f); + + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setNonStrokingColor(background[0], background[1], background[2]); + content.addRect(0, 0, w, h); + content.fill(); + content.setStrokingColor(border[0], border[1], border[2]); + content.setLineWidth(1f); + content.addRect(0.5f, 0.5f, w - 1f, h - 1f); + content.stroke(); + if (caption != null && !caption.isBlank()) { + try { + float textWidth = font.getStringWidth(caption) / 1000f * fontSize; + content.beginText(); + content.setFont(font, fontSize); + content.setNonStrokingColor(0f, 0f, 0f); + content.newLineAtOffset( + Math.max(2f, (w - textWidth) / 2f), + (h - fontSize) / 2f + fontSize * 0.2f); + content.showText(caption); + content.endText(); + } catch (Exception e) { + // Unencodable caption: keep the frame, drop the text. + log.debug("Could not draw button caption '{}': {}", caption, e.getMessage()); + } + } + } + + // Reuse the existing dictionary so an authored /D or /R is not collateral damage. + PDAppearanceDictionary appearance = widget.getAppearance(); + if (appearance == null) { + appearance = new PDAppearanceDictionary(); + widget.setAppearance(appearance); + } + appearance.setNormalAppearance(stream); + // A push button has no value, so no /AS. + widget.getCOSObject().removeItem(COSName.AS); + } + + /** A /MK colour array as RGB, falling back to a grey level when absent or unsupported. */ + private float[] mkColour(PDColor colour, float fallback) { + float[] rgb = {fallback, fallback, fallback}; + if (colour == null) { + return rgb; + } + float[] components; + try { + components = colour.getComponents(); + } catch (Exception e) { + log.debug("Unreadable /MK colour: {}", e.getMessage()); + return rgb; + } + if (components.length == 3) { + rgb = components.clone(); + } else if (components.length == 1) { + rgb = new float[] {components[0], components[0], components[0]}; + } else if (components.length == 4) { + // CMYK to RGB, good enough for button chrome. + float k = components[3]; + rgb = + new float[] { + (1 - components[0]) * (1 - k), + (1 - components[1]) * (1 - k), + (1 - components[2]) * (1 - k) + }; + } + // PDPageContentStream rejects anything outside 0..1, and a throw here loses the appearance. + for (int i = 0; i < rgb.length; i++) { + rgb[i] = Math.min(1f, Math.max(0f, rgb[i])); + } + return rgb; + } + + /** A circle from four Bezier arcs; PDF has no primitive for one. */ + private void drawCircle(PDPageContentStream content, float cx, float cy, float r) + throws IOException { + if (r <= 0) { + return; + } + float k = r * 0.5523f; + content.moveTo(cx - r, cy); + content.curveTo(cx - r, cy + k, cx - k, cy + r, cx, cy + r); + content.curveTo(cx + k, cy + r, cx + r, cy + k, cx + r, cy); + content.curveTo(cx + r, cy - k, cx + k, cy - r, cx, cy - r); + content.curveTo(cx - k, cy - r, cx - r, cy - k, cx - r, cy); + content.closePath(); + } + + /** + * One widget per option stacked below {@code baseRect}, each keyed by its sanitized export + * value so the group behaves as a single selectable field. + */ + /** + * Per-option widget rects laid out INSIDE the drawn box, which is the group's total extent. + * Stacking outside it made a three-option group three times taller than what was drawn. + */ + public static List radioOptionRects( + PDRectangle box, int count, Float gapOverride, Float sizeOverride) { + List rects = new ArrayList<>(); + int n = Math.max(1, count); + float h = box.getHeight(); + float slot = h / n; + + float size; + if (sizeOverride != null && sizeOverride > 0f) { + size = sizeOverride; + } else if (gapOverride != null && gapOverride >= 0f) { + size = (h - (n - 1) * gapOverride) / n; + } else { + // A quarter of each slot is breathing room, so the stack fills the drawn height. + size = slot * 0.75f; + } + // Square keeps the circle round; a wide box becomes a left-aligned column. + size = Math.max(1f, Math.min(size, box.getWidth())); + + float gap; + if (gapOverride != null && gapOverride >= 0f) { + gap = gapOverride; + } else { + gap = n > 1 ? Math.max(0f, (h - n * size) / (n - 1)) : 0f; + } + + float top = box.getLowerLeftY() + h; + for (int i = 0; i < n; i++) { + float y = top - (i + 1) * size - i * gap; + rects.add(new PDRectangle(box.getLowerLeftX(), y, size, size)); + } + return rects; + } + + private void createRadioField( + PDAcroForm acroForm, + PDPage page, + PDRectangle baseRect, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + List values = (options == null || options.isEmpty()) ? List.of("1", "2") : options; + + PDRadioButton radio = new PDRadioButton(acroForm); + radio.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + radio.setAlternateFieldName(definition.label()); + } catch (Exception ignore) { + // alternate name is best-effort + } + } + radio.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + radio.setReadOnly(true); + } + + List optionRects = + radioOptionRects( + baseRect, values.size(), definition.optionGap(), definition.optionSize()); + + List widgets = new ArrayList<>(); + List exportValues = new ArrayList<>(); + Set usedStates = new HashSet<>(); + for (int i = 0; i < values.size(); i++) { + String onState = sanitizeOnState(values.get(i), i, usedStates); + exportValues.add(onState); + + PDRectangle rect = optionRects.get(i); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rect); + widget.setPage(page); + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + widget.setParent(radio); + // The widget's appearance state is "Off" until the group value selects it. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + widgets.add(widget); + + List annotations = page.getAnnotations(); + if (annotations == null) { + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + annotations.add(widget); + } + + radio.setWidgets(widgets); + try { + radio.setExportValues(exportValues); + } catch (Exception e) { + log.debug("Unable to set radio export values for '{}': {}", name, e.getMessage()); + } + + String defaultValue = definition.defaultValue(); + if (defaultValue != null + && !defaultValue.isBlank() + && exportValues.contains(defaultValue)) { + try { + radio.setValue(defaultValue); + } catch (Exception e) { + log.debug("Unable to set radio default '{}': {}", defaultValue, e.getMessage()); + } + } + + acroForm.getFields().add(radio); + } + + /** Builds a unique, PDF-name-safe "on" state for a radio widget. */ + private String sanitizeOnState(String raw, int index, Set used) { + String base = + Optional.ofNullable(raw) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(FormUtils::sanitizePdfName) + .orElse("Option" + (index + 1)); + if (OFF_STATE.equalsIgnoreCase(base)) { + base = "Option" + (index + 1); + } + String candidate = base; + int suffix = 1; + while (!used.add(candidate)) { + candidate = base + "_" + suffix++; + } + return candidate; + } + + /** Reduces a label to characters that are safe inside a PDF name. */ + private static String sanitizePdfName(String raw) { + return raw == null ? "" : raw.trim().replaceAll("[^A-Za-z0-9_-]", "_"); + } + + /** Modify, then delete, then add, so generated names dedupe against the surviving set. */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes) + throws IOException { + applyFieldEdits(document, adds, modifies, deletes, null); + } + + /** + * As above, but records every operation that could not be applied into {@code skipped} so the + * caller can report "3 of 4" instead of a bare success. + */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes, + List skipped) + throws IOException { + if (document == null) return; + if (modifies != null && !modifies.isEmpty()) { + modifyFormFields(document, modifies, skipped); + } + if (deletes != null && !deletes.isEmpty()) { + deleteFormFields(document, deletes, skipped); + } + if (adds != null && !adds.isEmpty()) { + addNewFields(document, adds, skipped); + } + } + + /** Adds an entry to a skip list that may be absent, so call sites stay one-liners. */ + private void recordSkip( + List skipped, String operation, String target, String reason) { + if (skipped != null) { + skipped.add(new SkippedFieldEdit(operation, target, reason)); + } + } + public void deleteFormFields(PDDocument document, List fieldNames) { + deleteFormFields(document, fieldNames, null); + } + + public void deleteFormFields( + PDDocument document, List fieldNames, List skipped) { if (document == null || fieldNames == null || fieldNames.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot delete fields because the document has no AcroForm"); + for (String name : fieldNames) { + recordSkip(skipped, "delete", name, "the document has no form to delete from"); + } return; } @@ -1847,6 +3053,7 @@ public class FormUtils { PDField field = locateField(acroForm, name.trim()); if (field == null) { log.warn("No matching field '{}' found for deletion", name); + recordSkip(skipped, "delete", name, "no field with that name exists"); continue; } @@ -2138,17 +3345,122 @@ public class FormUtils { return Collections.emptySet(); } Set existing = new HashSet<>(); + // Group (non-terminal) names occupy the namespace too, so a new field must not be + // allowed to take one; omitting them hides a whole class of collision. for (PDField field : acroForm.getFieldTree()) { - if (field instanceof PDTerminalField) { - String fqn = field.getFullyQualifiedName(); - if (fqn != null && !fqn.isEmpty()) { - existing.add(fqn); - } + String fqn = field.getFullyQualifiedName(); + if (fqn != null && !fqn.isEmpty()) { + existing.add(fqn); } } return existing; } + /** A text field's /MaxLen, or null when unset so the editor shows an empty box. */ + private Integer extractMaxLength(PDField field) { + if (field instanceof PDTextField textField) { + int maxLen = textField.getMaxLen(); + return maxLen > 0 ? maxLen : null; + } + return null; + } + + /** Reads a push button's action back into the same spec string the editor sends. */ + private String extractButtonAction(PDField field) { + if (!(field instanceof PDPushButton)) { + return null; + } + for (PDAnnotationWidget widget : field.getWidgets()) { + COSBase raw = widget.getCOSObject().getDictionaryObject(COSName.A); + if (!(raw instanceof COSDictionary action)) { + continue; + } + String subtype = action.getNameAsString(COSName.S); + if ("ResetForm".equals(subtype)) { + return "reset"; + } + if ("Named".equals(subtype)) { + return "Print".equalsIgnoreCase(action.getNameAsString(COSName.N)) ? "print" : null; + } + if ("URI".equals(subtype)) { + return "uri:" + Optional.ofNullable(action.getString(COSName.URI)).orElse(""); + } + if ("SubmitForm".equals(subtype)) { + return "submit:" + Optional.ofNullable(action.getString(COSName.F)).orElse(""); + } + } + return null; + } + + /** The parent prefix of a qualified name, including the trailing dot, or "" if top level. */ + private String parentPrefix(String qualifiedName) { + int dot = qualifiedName == null ? -1 : qualifiedName.lastIndexOf('.'); + return dot < 0 ? "" : qualifiedName.substring(0, dot + 1); + } + + /** + * The partial name a rename should set. Only a new name under the target's own parent may be + * qualified; anything else is used verbatim so it cannot silently re-parent the field. + */ + private String leafName(String targetName, String newName) { + String prefix = parentPrefix(targetName); + return !prefix.isEmpty() && newName.startsWith(prefix) + ? newName.substring(prefix.length()) + : newName; + } + + /** + * Why the rename is impossible, or null. An unchanged name is not a rename, so a field nested + * under a parent is not rejected for the period in its qualified name. + */ + public String renameProblem(String targetName, String newName) { + if (newName == null || newName.equals(targetName)) { + return null; + } + // A nested field's box shows "Parent.Child", so renaming the leaf under the same + // parent is legitimate; only the leaf has to be a storable partial name. + String trimmed = newName.trim(); + String leaf = leafName(targetName, trimmed); + if (!trimmed.isEmpty() && leaf.isBlank()) { + return "Field name '" + newName + "' has no name after the parent prefix."; + } + return invalidFieldNameReason(leaf); + } + + /** + * Why {@code name} is unusable as a field name, or null when it is fine. AcroForm reserves the + * period as the parent/child separator, so PDFBox rejects it outright in a partial name. + */ + public String invalidFieldNameReason(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.chars().anyMatch(Character::isISOControl)) { + // A line break in a name would also forge a second line in every log it reaches. + return "Field name cannot contain line breaks or control characters."; + } + if (name.indexOf('.') >= 0) { + return "Field name '" + + sanitizeForLog(name) + + "' cannot contain a period. PDF forms use '.' to separate a parent field" + + " from its children."; + } + return null; + } + + /** + * A caller-supplied string made safe to log. Without this a name containing CR/LF writes an + * extra, attacker-chosen line into the log file (CWE-117). + */ + public static String sanitizeForLog(String value) { + if (value == null) { + return null; + } + StringBuilder out = new StringBuilder(value.length()); + value.chars().forEach(c -> out.append(Character.isISOControl(c) ? ' ' : (char) c)); + return out.toString(); + } + private PDField locateField(PDAcroForm acroForm, String name) { if (acroForm == null || name == null) { return null; @@ -2185,20 +3497,26 @@ public class FormUtils { } private String generateUniqueFieldName(String baseName, Set existingNames) { - String sanitized = + return generateUniqueFieldName(baseName, existingNames, ""); + } + + /** + * A partial name no sibling already uses. {@code qualifiedPrefix} is prepended only for the + * collision check, because {@code existingNames} holds fully qualified names. + */ + private String generateUniqueFieldName( + String baseName, Set existingNames, String qualifiedPrefix) { + // Trimmed, not sanitized: callers must reject bad names first via invalidFieldNameReason. + String trimmed = Optional.ofNullable(baseName) .map(String::trim) .filter(s -> !s.isEmpty()) .orElse("field"); - StringBuilder candidateBuilder = new StringBuilder(sanitized); - String candidate = candidateBuilder.toString(); + String candidate = trimmed; int counter = 1; - - while (existingNames.contains(candidate)) { - candidateBuilder.setLength(0); - candidateBuilder.append(sanitized).append("_").append(counter); - candidate = candidateBuilder.toString(); + while (existingNames.contains(qualifiedPrefix + candidate)) { + candidate = trimmed + "_" + counter; counter++; } @@ -2235,9 +3553,29 @@ public class FormUtils { } } field.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + field.setReadOnly(true); + } - PDAnnotationWidget widget = - existingWidget != null ? existingWidget : new PDAnnotationWidget(); + // A terminal field with no /Kids shares its dictionary with one merged widget. + // A separately built widget is not linked via /Kids, so its /Rect is lost on save. + boolean reuseFieldDict; + PDAnnotationWidget widget; + if (existingWidget != null) { + widget = existingWidget; + reuseFieldDict = false; + } else { + List current = field.getWidgets(); + if (current != null && !current.isEmpty()) { + widget = current.get(0); + } else { + widget = new PDAnnotationWidget(); + } + reuseFieldDict = widget.getCOSObject() == field.getCOSObject(); + // Make sure the shared dictionary is recognised as a widget annotation. + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + } // Ensure rectangle is valid and set before any appearance-related operations // please note removal of this might cause **subtle** issues @@ -2250,10 +3588,10 @@ public class FormUtils { } widget.setRectangle(validRectangle); widget.setPage(page); - - if (existingWidget == null) { - widget.setPrinted(true); - } + // Explicitly set the /P entry so the widget keeps a valid page reference + // after save/reload (some viewers rely on it to resolve the widget page). + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.setPrinted(true); if (definition.tooltip() != null && !definition.tooltip().isBlank()) { widget.getCOSObject().setString(COSName.TU, definition.tooltip()); @@ -2265,13 +3603,25 @@ public class FormUtils { } } - field.getWidgets().add(widget); - widget.setParent(field); + // Only link a SEPARATE widget into the field; the merged widget IS the + // field dictionary and is already its own widget. + if (!reuseFieldDict) { + List widgets = new ArrayList<>(field.getWidgets()); + if (!widgets.contains(widget)) { + widgets.add(widget); + field.setWidgets(widgets); + } + widget.setParent(field); + } List annotations = page.getAnnotations(); if (annotations == null) { - page.getAnnotations().add(widget); - } else if (!annotations.contains(widget)) { + // page.getAnnotations() can return null; calling it again and adding + // would NPE. Initialise the list and attach it to the page first. + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + if (!annotations.contains(widget)) { annotations.add(widget); } acroForm.getFields().add(field); @@ -2399,7 +3749,60 @@ public class FormUtils { Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; derived from the drawn box when null. */ + Float optionGap, + /** Radio option size in points; derived from the drawn box when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to derived. */ + public NewFormFieldDefinition( + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } @JsonInclude(JsonInclude.Include.NON_NULL) public record ModifyFormFieldDefinition( @@ -2407,11 +3810,120 @@ public class FormUtils { String name, String label, String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, Boolean required, Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; leaves the existing layout alone when null. */ + Float optionGap, + /** Radio option size in points; leaves the existing layout alone when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to unchanged. */ + public ModifyFormFieldDefinition( + String targetName, + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + targetName, + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } + + /** A mixed batch of field edits applied in one request via {@link #applyFieldEdits}. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FieldEditBatch( + List add, + List modify, + List delete) {} + + /** + * One requested edit the document could not take, so the caller can report "3 of 4" rather than + * a bare success. {@code operation} is "add", "modify" or "delete". + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + /** + * Turns a library failure into something a person can act on. Raw messages like "/DR is a + * required entry" name PDF internals the user has never heard of. + */ + public static String readableFailure(Throwable failure) { + String raw = failure == null ? null : failure.getMessage(); + if (raw == null || raw.isBlank()) { + return "this PDF would not accept the change"; + } + String lower = raw.toLowerCase(java.util.Locale.ROOT); + if (lower.contains("/dr") || lower.contains("default resources")) { + return "this PDF's form has no font settings, so the field could not be styled"; + } + if (lower.contains("font") && lower.contains("not")) { + return "the font this field asks for is not embedded in the PDF"; + } + if (lower.contains("encrypt") || lower.contains("password")) { + return "the PDF is protected, so its form cannot be changed"; + } + if (lower.contains("read-only") || lower.contains("readonly")) { + return "the field is read-only in this PDF"; + } + // Anything unrecognised stays vague rather than leaking internals at the user. + log.debug("Unmapped form edit failure: {}", raw); + return "this PDF would not accept the change"; + } + + /** Skip reasons travel in a response header, so an echoed value cannot be unbounded. */ + public static String abbreviate(String value, int max) { + if (value == null || value.length() <= max) { + return value; + } + return value.substring(0, max) + "..."; + } + + public record SkippedFieldEdit(String operation, String target, String reason) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record FormFieldInfo( @@ -2433,19 +3945,25 @@ public class FormUtils { static final class FieldCoordinateComparator implements Comparator { private static int firstWidgetPageIndex(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getPageIndex() : -1; } private static float firstWidgetY(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getY() : 0; } private static float firstWidgetX(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getX() : 0; } diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 52f79f733a..1d7320ca84 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -12,6 +12,7 @@ import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -54,6 +55,10 @@ public class GeneralUtils { private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; private final String PYTHON_SCRIPTS_DIR = "python"; + + // Extracted once per run. Rewriting a script while another request is exec-ing it + // races wherever rename is not atomic, such as 9p or NFS bind mounts. + private final Map EXTRACTED_SCRIPTS = new ConcurrentHashMap<>(); private final RegexPatternUtils patternCache = RegexPatternUtils.getInstance(); // Valid size units used for convertSizeToBytes validation and parsing private final Set VALID_SIZE_UNITS = Set.of("B", "KB", "MB", "GB", "TB"); @@ -1025,17 +1030,30 @@ public class GeneralUtils { } Path scriptsDir = Path.of(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR); - Files.createDirectories(scriptsDir); - Path target = scriptsDir.resolve(scriptName); - ClassPathResource res = - new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName); - if (!res.exists()) { - log.error("Resource not found: {}", res.getPath()); - throw new IOException("Resource not found: " + res.getPath()); + + Path cached = EXTRACTED_SCRIPTS.get(scriptName); + if (cached != null && Files.isRegularFile(cached)) { + return cached; + } + + synchronized (EXTRACTED_SCRIPTS) { + cached = EXTRACTED_SCRIPTS.get(scriptName); + if (cached != null && Files.isRegularFile(cached)) { + return cached; + } + + Files.createDirectories(scriptsDir); + ClassPathResource res = + new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + EXTRACTED_SCRIPTS.put(scriptName, target); + return target; } - copyResourceToFile(res, target); - return target; } /* diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index f85880df5d..bfd3f53c67 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -4,7 +4,14 @@ import java.util.regex.Pattern; public class RequestUriUtils { - private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$"); + // Share tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly + private static final Pattern SHARE_LINK_PATTERN = + Pattern.compile( + "^/share/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$"); + // Invite tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly + private static final Pattern INVITE_LINK_PATTERN = + Pattern.compile( + "^/invite/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$"); public static boolean isStaticResource(String requestURI) { return isStaticResource("", requestURI); @@ -69,7 +76,7 @@ public class RequestUriUtils { // cookie, so the server can't authenticate the navigation itself). The // portal gates access via its own auth gate + RequirePortalAccess, and its // data APIs stay protected, so serving the shell pre-auth is safe. - if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) { + if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) { return true; } @@ -209,7 +216,9 @@ public class RequestUriUtils { // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected - || SHARE_LINK_PATTERN.matcher(trimmedUri).matches(); + || SHARE_LINK_PATTERN.matcher(trimmedUri).matches() + // Invite-accept SPA bootstrap; data APIs remain protected + || INVITE_LINK_PATTERN.matcher(trimmedUri).matches(); } private static String stripContextPath(String contextPath, String requestURI) { diff --git a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java index 6275b49343..afc6fa7020 100644 --- a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java +++ b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test; import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfaLevelAServiceInterface; /** * Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in @@ -32,7 +33,14 @@ class EndpointConfigurationGapTest { * Construct an EndpointConfiguration with the given pro flag and current applicationProperties. */ private EndpointConfiguration build(boolean runningProOrHigher) { - return new EndpointConfiguration(applicationProperties, runningProOrHigher); + return build(runningProOrHigher, null); + } + + /** The PDF/UA service is only present in proprietary builds, so it is injected separately. */ + private EndpointConfiguration build( + boolean runningProOrHigher, PdfaLevelAServiceInterface pdfaLevelAService) { + return new EndpointConfiguration( + applicationProperties, runningProOrHigher, pdfaLevelAService); } /** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */ @@ -177,6 +185,28 @@ class EndpointConfigurationGapTest { } } + @Nested + @DisplayName("PDF/UA availability") + class PdfUaTests { + + @Test + @DisplayName("the PDF/UA endpoints are off when the proprietary tagger is absent") + void disabledWithoutTagger() { + EndpointConfiguration config = build(false, null); + assertFalse(config.isEndpointEnabled("pdf-to-ua")); + assertFalse(config.isEndpointEnabled("accessibility-report")); + } + + @Test + @DisplayName("they are on once the tagger is on the classpath") + void enabledWithTagger() { + EndpointConfiguration config = + build(false, (pdfBytes, part, language, title, alsoDeclareUa) -> null); + assertTrue(config.isEndpointEnabled("pdf-to-ua")); + assertTrue(config.isEndpointEnabled("accessibility-report")); + } + } + @Nested @DisplayName("group enable / disable") class GroupTests { diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java index 861efa8509..9bf78b6af3 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java @@ -2,6 +2,7 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +66,24 @@ class FileStorageOwnershipTest { assertThrows(SecurityException.class, () -> fs.deleteFile(id)); } + @Test + void systemDeleteOfAnotherUsersFile_allowed_soJobCleanupDoesNotOrphanIt(@TempDir Path tempDir) + throws IOException { + // An admin sweeping every user's finished jobs is not the owner of their files. The + // ownership-checked delete throws there, which used to drop the job record and leave the + // files stranded on disk with nothing left able to reference them. + AtomicReference user = new AtomicReference<>("alice"); + FileStorage fs = newStorageWithCurrentUser(tempDir, user); + String id = fs.storeBytes("alice's file".getBytes(), "x.bin"); + user.set("admin"); + + assertThrows(SecurityException.class, () -> fs.deleteFile(id)); + assertTrue(fs.deleteFileAsSystem(id), "System delete must not be blocked by ownership"); + + user.set("alice"); + assertThrows(IOException.class, () -> fs.retrieveBytes(id), "File should really be gone"); + } + @Test void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir) throws IOException { diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerCleanupTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerCleanupTest.java new file mode 100644 index 0000000000..050004dc1f --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerCleanupTest.java @@ -0,0 +1,263 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.ResultFile; + +/** + * Covers the on-demand cleanup path and the input-copy tracking that makes it complete. An async + * submit persists a copy of the upload as well as its results; before both were tracked, only the + * results were ever deleted and the input copy stayed on disk indefinitely. + */ +class TaskManagerCleanupTest { + + @Mock private FileStorage fileStorage; + @Mock private JobStore jobStore; + @Mock private ClusterBackplane clusterBackplane; + + @InjectMocks private TaskManager taskManager; + + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node"); + lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true); + lenient().when(fileStorage.deleteFileAsSystem(anyString())).thenReturn(true); + ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); + ReflectionTestUtils.setField(taskManager, "pendingJobExpiryMinutes", 1440); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @SuppressWarnings("unchecked") + private Map jobResults() { + return (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); + } + + /** Complete a job with a single result file, as an async file-producing job would. */ + private void completeWithFile(String jobId, String fileId) { + taskManager.setFileResult(jobId, fileId, "out.pdf", MediaType.APPLICATION_PDF_VALUE); + taskManager.setComplete(jobId); + } + + @Test + void forcedCleanupRemovesFinishedJobsRegardlessOfAge() { + String jobId = "fresh-job"; + taskManager.createTask(jobId); + completeWithFile(jobId, "result-file"); + + // The scheduled sweep leaves it alone: it completed well inside the retention window. + taskManager.cleanupOldJobs(); + assertTrue(jobResults().containsKey(jobId), "Scheduled cleanup should respect the expiry"); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.jobsRemoved()); + assertEquals(1, summary.filesDeleted()); + assertEquals(0, summary.jobsRetained()); + assertFalse(jobResults().containsKey(jobId)); + verify(fileStorage).deleteFileAsSystem("result-file"); + verify(jobStore).delete(jobId); + } + + @Test + void forcedCleanupDeletesThePersistedInputCopy() { + String jobId = "job-with-input"; + taskManager.createTask(jobId); + assertTrue(taskManager.registerInputFile(jobId, "input-file")); + completeWithFile(jobId, "result-file"); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.jobsRemoved()); + assertEquals(2, summary.filesDeleted(), "Both the result and the input copy must go"); + verify(fileStorage).deleteFileAsSystem("result-file"); + verify(fileStorage).deleteFileAsSystem("input-file"); + } + + @Test + void scheduledCleanupAlsoDeletesThePersistedInputCopy() { + String jobId = "expired-job"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + completeWithFile(jobId, "result-file"); + + JobResult result = taskManager.getJobResult(jobId); + ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(1)); + + taskManager.cleanupOldJobs(); + + assertFalse(jobResults().containsKey(jobId)); + verify(fileStorage).deleteFileAsSystem("result-file"); + verify(fileStorage).deleteFileAsSystem("input-file"); + } + + @Test + void forcedCleanupLeavesRunningJobsAlone() { + String running = "running-job"; + taskManager.createTask(running); + taskManager.registerInputFile(running, "in-flight-input"); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(0, summary.jobsRemoved()); + assertEquals(0, summary.filesDeleted()); + assertEquals(1, summary.jobsRetained()); + assertTrue(jobResults().containsKey(running)); + // Deleting a running job's input mid-flight would break it. + verify(fileStorage, never()).deleteFileAsSystem(anyString()); + } + + @Test + void forcedCleanupSkipsJobsTheFilterRejects() { + taskManager.createTask("alice:job"); + completeWithFile("alice:job", "alice-file"); + taskManager.createTask("bob:job"); + completeWithFile("bob:job", "bob-file"); + + TaskManager.CleanupSummary summary = + taskManager.cleanupFinishedJobsNow(id -> id.startsWith("alice:")); + + assertEquals(1, summary.jobsRemoved()); + assertEquals(1, summary.jobsRetained()); + assertFalse(jobResults().containsKey("alice:job")); + assertTrue(jobResults().containsKey("bob:job"), "Another user's job must survive"); + verify(fileStorage).deleteFileAsSystem("alice-file"); + verify(fileStorage, never()).deleteFileAsSystem("bob-file"); + } + + @Test + void forcedCleanupIsIdempotent() { + String jobId = "job-to-clean"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + completeWithFile(jobId, "result-file"); + + taskManager.cleanupFinishedJobsNow(id -> true); + TaskManager.CleanupSummary second = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(0, second.jobsRemoved()); + assertEquals(0, second.filesDeleted()); + } + + @Test + void forcedCleanupRunsEvenWhenTheBackplaneOwnsScheduledExpiry() { + // The scheduled sweep defers to the backplane TTL in cluster mode, but an explicit + // request to release this node's storage still has to do something. + when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false); + String jobId = "clustered-job"; + taskManager.createTask(jobId); + completeWithFile(jobId, "result-file"); + + taskManager.cleanupOldJobs(); + assertTrue(jobResults().containsKey(jobId)); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.jobsRemoved()); + assertFalse(jobResults().containsKey(jobId)); + } + + @Test + void cleanupCountsOnlyFilesThatWereActuallyDeleted() { + // A file already gone (a retry deleted it, say) must not be counted as freed. + String jobId = "partially-cleaned"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "already-gone"); + completeWithFile(jobId, "result-file"); + when(fileStorage.deleteFileAsSystem("already-gone")).thenReturn(false); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.filesDeleted()); + } + + @Test + void cleanupSurvivesAFileStorageFailure() { + String jobId = "job-with-unhappy-storage"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + completeWithFile(jobId, "result-file"); + when(fileStorage.deleteFileAsSystem("result-file")) + .thenThrow(new RuntimeException("disk on fire")); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + // The job is still released and the remaining file still deleted. + assertEquals(1, summary.jobsRemoved()); + assertEquals(1, summary.filesDeleted()); + assertFalse(jobResults().containsKey(jobId)); + verify(fileStorage).deleteFileAsSystem("input-file"); + } + + @Test + void registerInputFileRejectsAnUnknownJob() { + assertFalse(taskManager.registerInputFile("no-such-job", "input-file")); + } + + @Test + void registerInputFileIgnoresDuplicatesAndBlanks() { + String jobId = "dedupe-job"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + taskManager.registerInputFile(jobId, "input-file"); + taskManager.registerInputFile(jobId, " "); + taskManager.registerInputFile(jobId, null); + + List inputFileIds = taskManager.getJobResult(jobId).getInputFileIds(); + + assertEquals(List.of("input-file"), inputFileIds); + } + + @Test + void multiFileResultsAndTheInputCopyAreAllDeleted() { + String jobId = "split-job"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + JobResult result = taskManager.getJobResult(jobId); + result.completeWithFiles( + List.of( + ResultFile.builder() + .fileId("page-1") + .fileName("1.pdf") + .contentType(MediaType.APPLICATION_PDF_VALUE) + .fileSize(10L) + .build(), + ResultFile.builder() + .fileId("page-2") + .fileName("2.pdf") + .contentType(MediaType.APPLICATION_PDF_VALUE) + .fileSize(10L) + .build())); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(3, summary.filesDeleted()); + verify(fileStorage).deleteFileAsSystem("page-1"); + verify(fileStorage).deleteFileAsSystem("page-2"); + verify(fileStorage).deleteFileAsSystem("input-file"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java index 0a02039ab3..1871e31a3f 100644 --- a/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java @@ -290,7 +290,8 @@ class TaskManagerMoreTest { ReflectionTestUtils.setField(job, "complete", true); ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2)); - when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked")); + when(fileStorage.deleteFileAsSystem("doomed")) + .thenThrow(new RuntimeException("locked")); // Must not propagate; the job is still removed afterwards. taskManager.cleanupOldJobs(); diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java index 9d880d3451..cb423d977c 100644 --- a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -258,7 +258,7 @@ class TaskManagerTest { .build(); ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile)); - when(fileStorage.deleteFile("file-id")).thenReturn(true); + when(fileStorage.deleteFileAsSystem("file-id")).thenReturn(true); // Obtain access to the private jobResults map Map jobResultsMap = @@ -281,7 +281,7 @@ class TaskManagerTest { assertFalse(jobResultsMap.containsKey(oldJobId)); assertTrue(jobResultsMap.containsKey(recentJobId)); assertTrue(jobResultsMap.containsKey(activeJobId)); - verify(fileStorage).deleteFile("file-id"); + verify(fileStorage).deleteFileAsSystem("file-id"); } @Test @@ -308,7 +308,7 @@ class TaskManagerTest { // Assert: nothing was removed locally, and no jobStore.delete was issued. assertTrue(jobResultsMap.containsKey(oldJobId)); verify(jobStore, never()).delete(anyString()); - verify(fileStorage, never()).deleteFile(anyString()); + verify(fileStorage, never()).deleteFileAsSystem(anyString()); } @Test diff --git a/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java new file mode 100644 index 0000000000..b5312576e4 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java @@ -0,0 +1,116 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSObject; +import org.apache.pdfbox.cos.COSObjectKey; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Pins how a choice field's options survive a save, which real forms rely on. */ +class ChoiceOptionRoundTripTest { + + private static PDComboBox combo(PDDocument document, List options) throws IOException { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDComboBox field = new PDComboBox(form); + field.setPartialName("state"); + field.setOptions(options); + form.getFields().add(field); + return field; + } + + @Test + @DisplayName("a whitespace-only option survives a load, save and reload") + void whitespaceOptionSurvivesRoundTrip() throws IOException { + List options = List.of(" ", "Alabama", "Alaska"); + + byte[] first; + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + combo(document, options); + document.save(out); + first = out.toByteArray(); + } + // The real path edits a document loaded from bytes, not one built in memory. + byte[] saved; + try (PDDocument loaded = Loader.loadPDF(first); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + loaded.save(out); + saved = out.toByteArray(); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDComboBox reread = + (PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state"); + assertEquals( + options, + reread.getOptionsExportValues(), + "an option must not vanish because the writer made it indirect"); + } + } + + @Test + @DisplayName("an option stored as an indirect reference is still reported") + void indirectOptionIsStillReported() throws IOException { + try (PDDocument document = new PDDocument()) { + PDComboBox field = combo(document, List.of(" ", "Alabama")); + + // Real forms reference option strings indirectly; the reader must follow the reference. + COSArray options = new COSArray(); + options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0))); + options.add(new COSString("Alabama")); + field.getCOSObject().setItem(COSName.OPT, options); + + // Every read path runs this repair first, which is where the reference is followed. + FormUtils.repairMissingWidgetPageReferences(document); + + assertEquals( + List.of(" ", "Alabama"), + field.getOptionsExportValues(), + "an indirectly stored option must not be dropped"); + } + } + + @Test + @DisplayName("a signature field reports no value rather than a JVM identity hash") + void signatureValueIsNotAnIdentityHash() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDSignatureField signature = new PDSignatureField(form); + signature.setPartialName("approval"); + // Only a field that actually holds a signature hits getValueAsString's toString(). + signature.setValue(new PDSignature()); + form.getFields().add(signature); + + List fields = FormUtils.extractFormFields(document); + + FormUtils.FormFieldInfo field = + fields.stream() + .filter(f -> "approval".equals(f.name())) + .findFirst() + .orElseThrow(); + // An identity hash differs per load, so the same document would describe itself twice. + assertNull(field.value(), "a signature has no text value"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java new file mode 100644 index 0000000000..4c28952ff0 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java @@ -0,0 +1,62 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */ +class DeepFieldTreeTest { + + private static byte[] chainOfKids(int depth) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + + COSDictionary root = new COSDictionary(); + root.setString(COSName.T, "n0"); + COSDictionary cursor = root; + for (int i = 1; i < depth; i++) { + COSDictionary kid = new COSDictionary(); + kid.setString(COSName.T, "n" + i); + kid.setItem(COSName.PARENT, cursor); + COSArray kids = new COSArray(); + kids.add(kid); + cursor.setItem(COSName.KIDS, kids); + cursor = kid; + } + cursor.setItem(COSName.FT, COSName.getPDFName("Tx")); + + COSArray fields = new COSArray(); + fields.add(root); + form.getCOSObject().setItem(COSName.FIELDS, fields); + document.save(out); + return out.toByteArray(); + } + } + + @Test + @DisplayName("a deeply nested field tree extracts without overflowing the stack") + void deepKidsChainDoesNotOverflow() throws IOException { + // 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in + // the writer, not in extraction, so it is not something a read endpoint would hit. + byte[] pdf = chainOfKids(2000); + + try (PDDocument document = Loader.loadPDF(pdf)) { + assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java new file mode 100644 index 0000000000..1f213c15ba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java @@ -0,0 +1,201 @@ +package stirling.software.common.util; + +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.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.FormFieldWithCoordinates; + +/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */ +class FormEditSafetyTest { + + private static PDDocument formWith(String name, String type) throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.A4)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + 50f, + 700f, + 200f, + 20f, + null, + null, + type.equals("radio") ? List.of("a", "b") : null, + null, + null, + null, + null, + null, + null, + null))); + return document; + } + + private static FormUtils.ModifyFormFieldDefinition modify( + String target, String type, Float width, Float height) { + // Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest. + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, type, null, null, null, width, height, null, null, null, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field") + void unrebuildableTypeIsRefused() throws IOException { + try (PDDocument document = formWith("choice", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("choice", "radio", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice"); + assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller"); + assertFalse( + field instanceof PDRadioButton, + "it could not become a radio, so it must not claim to be one"); + assertEquals( + "text", + FormUtils.extractFormFields(document).getFirst().type(), + "the original field must survive untouched rather than be retyped"); + } + } + + @Test + @DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked") + void rebuiltCheckboxIsUsable() throws IOException { + try (PDDocument document = formWith("agree", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("agree", "checkbox", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree"); + assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox"); + assertNotNull( + field.getWidgets().getFirst().getAppearance(), + "without an appearance the checkbox renders blank and cannot be ticked"); + } + } + + @Test + @DisplayName("a size of zero or infinity is refused rather than written into the page") + void unusableSizeIsRefused() throws IOException { + for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) { + try (PDDocument document = formWith("box", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("box", null, bad, 20f)), skipped); + + PDRectangle rect = + document.getDocumentCatalog() + .getAcroForm(null) + .getField("box") + .getWidgets() + .getFirst() + .getRectangle(); + assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad); + assertEquals( + 200f, + rect.getWidth(), + 0.01f, + "the original size must survive: width " + bad); + } + } + } + + @Test + @DisplayName("a widget off the page still reports its geometry instead of dropping the field") + void offPageWidgetKeepsItsGeometry() throws IOException { + try (PDDocument document = formWith("stray", "text")) { + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray"); + // Above the page top: legal PDF, and the user needs the coordinates to drag it back. + field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(document); + + FormFieldWithCoordinates stray = + fields.stream() + .filter(f -> "stray".equals(f.getName())) + .findFirst() + .orElseThrow(); + assertNotNull(stray.getWidgets(), "the field must keep its widget list"); + assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported"); + assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay"); + } + } + + private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, null, value, + null, null, null, null, null, null); + } + + private static FormUtils.ModifyFormFieldDefinition withOptions( + String target, List options) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, options, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a value a radio group cannot hold does not destroy the group") + void badRadioValueLeavesTheGroupIntact() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withValue("plan", "not-an-option")), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + assertTrue( + field instanceof PDRadioButton, + "a rejected value must not turn the group into another kind of field"); + assertEquals( + 2, + field.getWidgets().size(), + "the group's options must survive a rejected value"); + assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied"); + } + } + + @Test + @DisplayName("editing a radio group's options is either applied or reported, never ignored") + void radioOptionEditIsNotSilentlyDropped() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + boolean applied = field.getWidgets().size() == 3; + assertTrue( + applied || !skipped.isEmpty(), + "a change the UI shows as saved must either happen or be reported as skipped"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java new file mode 100644 index 0000000000..c1b0c806e1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java @@ -0,0 +1,61 @@ +package stirling.software.common.util; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * A field name is caller-supplied and reaches several loggers. A line break in one would forge a + * second log line (CWE-117), so names carrying control characters are refused outright. + */ +class FormFieldNameSafetyTest { + + @Test + void aNameWithCrLfIsRefused() { + String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4"; + String reason = FormUtils.invalidFieldNameReason(forged); + assertNotNull(reason, "a name containing CR/LF must be refused"); + assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break"); + assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return"); + } + + @Test + void otherControlCharactersAreRefusedToo() { + assertNotNull(FormUtils.invalidFieldNameReason("tab\there")); + assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte")); + } + + @Test + void ordinaryNamesStillPass() { + assertNull(FormUtils.invalidFieldNameReason("Full Name")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason("Mr Smith")); + } + + @Test + void thePeriodRefusalDoesNotEchoControlCharacters() { + // Both problems at once: the period branch must not leak the raw name into a log line. + String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED"); + assertNotNull(reason); + assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason); + } + + @Test + void sanitizeForLogFlattensControlCharacters() { + assertEquals("a b", FormUtils.sanitizeForLog("a\nb")); + assertEquals("a b", FormUtils.sanitizeForLog("a\rb")); + assertEquals("plain", FormUtils.sanitizeForLog("plain")); + assertNull(FormUtils.sanitizeForLog(null)); + } + + @Test + void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason); + assertTrue(reason.contains("period"), "the message should name the problem: " + reason); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java index 771ce89659..6924764a65 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java @@ -130,13 +130,15 @@ class FormFieldTypeSupportTest { } @Test - void doesNotSupportsDefinitionCreation_signatureReturnsTrue() { - assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_signatureReturnsFalse() { + // Signature placeholders are now creatable via the editor. + assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); } @Test - void doesNotSupportsDefinitionCreation_buttonReturnsTrue() { - assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_buttonReturnsFalse() { + // Push buttons (with actions) are now creatable via the editor. + assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); } @Test diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java new file mode 100644 index 0000000000..408380b790 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java @@ -0,0 +1,911 @@ +package stirling.software.common.util; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Guards the form editor against silently destroying a field it edits. Assertions run after a + * save/reload cycle because only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditRegressionTest { + + private static PDAcroForm setupForm(PDDocument document) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + /** Moves a field to a rect; null width/height leave the size alone. */ + private static FormUtils.ModifyFormFieldDefinition moveTo( + String target, float x, float y, Float w, Float h) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + return field.getWidgets().get(0).getRectangle(); + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(Collectors.toSet()); + } + + @Test + void movingCheckboxKeepsItFillable() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("agree"); + assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox"); + assertFalse( + ((PDCheckBox) field).getOnValue().isEmpty(), + "a moved checkbox must keep an on-state, or it can never be ticked again"); + assertTrue( + normalStateNames(field.getWidgets().get(0)).size() >= 2, + "both /AP /N states must survive a move"); + PDRectangle rect = firstWidgetRect(acroForm, "agree"); + assertEquals(200f, rect.getLowerLeftX(), 0.5f); + assertEquals(400f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree"); + assertFalse( + checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state"); + PDAnnotationWidget widget = checkBox.getWidgets().get(0); + assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt"); + PDRectangle bbox = + widget.getAppearance() + .getNormalAppearance() + .getSubDictionary() + .get(COSName.getPDFName(checkBox.getOnValue())) + .getBBox(); + assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size"); + } + } + + /** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */ + @Test + void resizingCheckboxKeepsItChecked() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + ((PDCheckBox) form.getField("agree")).check(); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + ((PDCheckBox) acroForm.getField("agree")).isChecked(), + "a resize must not silently untick the box"); + } + } + + /** Only widgets.get(0) used to move, so a radio group lost every option but the first. */ + @Test + void movingRadioGroupMovesEveryOption() throws IOException { + byte[] saved; + float[] before = new float[6]; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + assertEquals(3, widgets.size(), "the fixture needs three option widgets"); + for (int i = 0; i < 3; i++) { + before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX(); + before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY(); + } + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group"); + List widgets = field.getWidgets(); + assertEquals(3, widgets.size(), "no option may be left behind"); + float dx = 90f - before[0]; + float dy = 670f - before[1]; + for (int i = 0; i < 3; i++) { + PDRectangle rect = widgets.get(i).getRectangle(); + assertEquals( + before[i * 2] + dx, + rect.getLowerLeftX(), + 0.5f, + "option " + i + " should shift by the same delta"); + assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f); + } + } + } + + /** A signature's /AP is the signature, so it must never be dropped. */ + @Test + void movingSignatureKeepsItsAppearance() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("signature", "sig", 50, 700, 120, 40, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "'sig' should still be a signature"); + assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f); + } + } + + @Test + void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason, "a period must be refused, not silently dropped"); + assertTrue(reason.contains("period"), "the message should name the offending character"); + assertNull(FormUtils.invalidFieldNameReason("Has Space")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason(null)); + } + + /** Dropped operations used to log a warning and still report success. */ + @Test + void applyFieldEdits_reportsEveryDroppedOperation() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "present", 50, 700, 200, 20, null))); + + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)), + List.of(moveTo("ghost", 10f, 10f, null, null)), + List.of("alsoGhost"), + skipped); + + assertEquals(3, skipped.size(), "each dropped operation should be reported"); + assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation()))); + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("present"), + "the rest of the document must still be applied"); + } + } + + /** A clean batch must not report anything, or the UI would cry wolf on every save. */ + @Test + void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "fine", 50, 700, 200, 20, null)), + List.of(), + List.of(), + skipped); + assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips"); + } + } + + /** A drag must not normalise other options to the dragged widget's size. */ + @Test + void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + // Hand-authored groups legitimately have option boxes of differing size. + PDRectangle second = widgets.get(1).getRectangle(); + widgets.get(1) + .setRectangle( + new PDRectangle( + second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f)); + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + List widgets = acroForm.getField("choice").getWidgets(); + assertEquals( + 40f, + widgets.get(1).getRectangle().getWidth(), + 0.5f, + "a pure drag must not shrink the other options"); + assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */ + @Test + void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) form.getField("agree"); + // A NeedAppearances form exported by Word/LibreOffice looks exactly like this. + box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP); + box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On")); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertEquals( + "On", + box.getOnValue(), + "the export value must survive; inventing 'Yes' would orphan /V"); + assertTrue(box.isChecked(), "the box was ticked and must stay ticked"); + } + } + + /** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */ + @Test + void renameProblem_ignoresAnUnchangedQualifiedName() { + assertNull( + FormUtils.renameProblem("Customer.Name", "Customer.Name"), + "a field standing still must not be rejected for its parent's period"); + assertNull(FormUtils.renameProblem("plain", null)); + assertNotNull( + FormUtils.renameProblem("plain", "New.Name"), + "an actual rename introducing a period must still be refused"); + } + + /** A nested field whose name box was left at its qualified name must still be modified. */ + @Test + void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + // Re-parent it so its qualified name legitimately contains a period. + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Name", + null, + null, + 0, + 90f, + 600f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + assertTrue( + skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("Customer.Name"); + assertNotNull(field, "the nested field must survive the edit"); + assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */ + @Test + void maxLengthZeroClearsTheCombSetting() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, 8, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen()); + + FormUtils.ModifyFormFieldDefinition clear = + new FormUtils.ModifyFormFieldDefinition( + "code", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, 0, null); + FormUtils.modifyFormFields(document, List.of(clear)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertEquals( + -1, + ((PDTextField) acroForm.getField("code")).getMaxLen(), + "/MaxLen should be gone, not merely zero"); + } + } + + /** An unrecognised button action must be reported rather than silently ignored. */ + @Test + void unknownButtonActionIsReported() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "an unusable action spec should be reported"); + assertTrue(skipped.get(0).reason().contains("launchTheMissiles")); + } + } + + /** Renaming a nested field must not re-parent it to the top level. */ + @Test + void renamingNestedFieldKeepsItUnderItsParent() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Phone", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(rename), skipped); + assertTrue( + skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Phone"), + "the field should still live under Customer, not at the top level"); + assertNull(acroForm.getField("Customer.Name"), "the old name should be gone"); + } + } + + /** One rejected action on a multi-widget button is one report, not one per widget. */ + @Test + void unknownButtonActionIsReportedOncePerField() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDField button = form.getField("go"); + // Give it a second widget, as a button repeated on two pages would have. + PDAnnotationWidget extra = new PDAnnotationWidget(); + extra.setRectangle(new PDRectangle(50, 600, 100, 24)); + extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject()); + List widgets = new ArrayList<>(button.getWidgets()); + widgets.add(extra); + button.getCOSObject() + .setItem( + COSName.KIDS, + new org.apache.pdfbox.cos.COSArray() { + { + for (PDAnnotationWidget w : widgets) add(w.getCOSObject()); + } + }); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "one field, one report: " + skipped); + } + } + + /** A clamped page index still creates the field, so it is not a dropped edit. */ + @Test + void clampedPageIsNotReportedAsSkipped() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("late"), + "the field is created on the clamped page"); + assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped); + } + } + + /** Recreation builds a top-level field, so it must refuse rather than re-parent. */ + @Test + void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(retype), skipped); + + assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Name"), + "the original nested field must be left intact"); + assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level"); + } + } + + /** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */ + @Test + void incompleteUrlActionClearsRatherThanFailing() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition pickUri = + new FormUtils.ModifyFormFieldDefinition( + "go", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, "uri:"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(pickUri), skipped); + + assertTrue( + skipped.isEmpty(), + "choosing a URL action before typing the URL is not an error: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "an empty target must leave no action behind"); + } + } + + /** A real URL still writes a real action. */ + @Test + void completeUrlActionIsApplied() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition setUri = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "uri:https://example.com"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(setUri), skipped); + + assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNotNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "the action should be written"); + } + } + + /** Builds a parent with the given terminal children already attached. */ + private static PDNonTerminalField nest( + PDDocument document, PDAcroForm form, String parentName, String... childNames) + throws IOException { + List defs = new ArrayList<>(); + for (int i = 0; i < childNames.length; i++) { + defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null)); + } + FormUtils.addNewFields(document, defs); + + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName(parentName); + List kids = new ArrayList<>(); + for (String child : childNames) { + PDField field = form.getField(child); + field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + kids.add(field); + } + parent.setChildren(kids); + form.setFields(List.of(parent)); + return parent; + } + + /** A refused edit must not release the name the field still really has. */ + @Test + void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name", "Email"); + + // Edit 1 is refused (type change on a nested field). Edit 2 then asks for the + // name edit 1 still occupies, which must not be handed out. + FormUtils.ModifyFormFieldDefinition refused = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Foo", + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Email", + "Customer.Name", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(refused, rename), skipped); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName()); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "two fields must never share a qualified name: " + names); + assertTrue( + names.contains("Customer.Name"), "the refused field keeps its name: " + names); + } + } + + /** A group name occupies the namespace, so a new field must not be able to take it. */ + @Test + void groupNamesParticipateInCollisionChecks() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name"); + + FormUtils.addNewFields( + document, List.of(newField("text", "Customer", 50, 500, 100, 20, null))); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + String fqn = f.getFullyQualifiedName(); + if (fqn != null) names.add(fqn); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "the new field must not take the group's name: " + names); + } + } + + /** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */ + @Test + void renameToBareParentPrefixIsRefused() { + assertNotNull( + FormUtils.renameProblem("Customer.Name", "Customer."), + "a name with nothing after the parent prefix is not a rename"); + assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone")); + } + + /** A type change must leave the field on its own page, not relocate it to the last one. */ + @Test + void typeChangeKeepsTheFieldOnItsPage() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = new PDAcroForm(document); + for (int i = 0; i < 5; i++) { + document.addPage(new PDPage(PDRectangle.A4)); + } + form.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(form); + + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "onPageTwo", + null, + "text", + 1, + 50f, + 700f, + 200f, + 20f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null))); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "onPageTwo", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(retype)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("onPageTwo"); + assertNotNull(field, "the retyped field should exist"); + int page = -1; + for (int i = 0; i < reloaded.getNumberOfPages(); i++) { + for (var annot : reloaded.getPage(i).getAnnotations()) { + if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i; + } + } + assertEquals( + 1, page, "a retyped field must stay on its own page, not move to the last"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java new file mode 100644 index 0000000000..2c606d481a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java @@ -0,0 +1,118 @@ +package stirling.software.common.util; + +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.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.junit.jupiter.api.Test; + +/** An edit the backend cannot honour must be reported, not logged and reported as success. */ +class FormUtilsEditReportingTest { + + private static FormUtils.NewFormFieldDefinition field(String type, String name) { + return new FormUtils.NewFormFieldDefinition( + name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDDocument blank() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + @Test + void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException { + List skipped = new ArrayList<>(); + try (PDDocument document = blank()) { + FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped); + PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getFields().isEmpty(), + "an unsupported type must not quietly become a text field"); + } + assertEquals(1, skipped.size(), "the caller must be told: " + skipped); + assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason()); + } + + @Test + void aLyingPageCountIsSurvivable() throws IOException { + // /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws. + byte[] broken = + ("%PDF-1.4\n" + + "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n" + + "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n" + + "trailer << /Root 1 0 R >>\n") + .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + List skipped = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(broken)) { + // Must not throw; the field is reported as skipped instead. + FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped); + } catch (IOException loadFailure) { + // A parser that refuses the file outright is an equally acceptable outcome. + return; + } + assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown"); + } + + @Test + void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.LETTER)); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "agree", + "agree", + "checkbox", + 0, + 60f, + 700f, + 14f, + 14f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null)), + new ArrayList<>()); + FormUtils.modifyFormFields( + document, + List.of( + new FormUtils.ModifyFormFieldDefinition( + "agree", null, null, null, 0, 200f, 400f, null, null, null, + null, null, null, null, null, null, null, null, null))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertNotNull(box); + assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java new file mode 100644 index 0000000000..a97c06daba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java @@ -0,0 +1,467 @@ +package stirling.software.common.util; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText; +import org.junit.jupiter.api.Test; + +/** + * Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit + * {@code /Kids}, so only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditingTest { + + private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) { + PDPage page = new PDPage(pageSize); + document.addPage(page); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newText( + String name, float x, float y, float w, float h) { + return new FormUtils.NewFormFieldDefinition( + name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null, + null, null); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, + String name, + float x, + float y, + float w, + float h, + List options, + Integer maxLength, + String buttonAction) { + return new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + x, + y, + w, + h, + null, + null, + options, + null, + null, + null, + null, + null, + maxLength, + buttonAction); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget"); + return field.getWidgets().get(0).getRectangle(); + } + + /** + * PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an + * explicit appearance a created checkbox or radio renders blank and resolves to Off. + */ + @Test + void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("checkbox", "agree", 50, 600, 20, 20, null, null, null), + newField( + "radio", + "choice", + 50, + 500, + 20, + 20, + List.of("Yes", "No"), + null, + null), + newText("fullname", 50, 400, 200, 24))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm); + + // NeedAppearances=false means viewers trust our streams, so they must exist. + assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run"); + + PDField checkBox = acroForm.getField("agree"); + assertTrue(checkBox instanceof PDCheckBox); + assertEquals( + Set.of("Off", "Yes"), + normalStateNames(checkBox.getWidgets().get(0)), + "checkbox needs an Off and an on-state appearance"); + + PDField radio = acroForm.getField("choice"); + assertTrue(radio instanceof PDRadioButton); + assertEquals(2, radio.getWidgets().size()); + assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0))); + assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1))); + + // A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for + // the whole form and every field above loses its appearance too. + PDField text = acroForm.getField("fullname"); + assertNotNull( + text.getWidgets().get(0).getAppearance().getNormalAppearance(), + "text field should have a generated appearance"); + } + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(java.util.stream.Collectors.toSet()); + } + + @Test + void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "AcroForm should exist after reload"); + assertTrue(acroForm.getField("created") instanceof PDTextField); + PDRectangle rect = firstWidgetRect(acroForm, "created"); + assertNotNull(rect, "created widget should keep its rectangle after reload"); + assertEquals(50f, rect.getLowerLeftX(), 0.5f); + assertEquals(700f, rect.getLowerLeftY(), 0.5f); + assertEquals(200f, rect.getWidth(), 0.5f); + assertEquals(20f, rect.getHeight(), 0.5f); + } + } + + @Test + void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + // Shift the CropBox origin; the frontend sends CropBox-relative coords. + document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700)); + FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "shifted"); + // Absolute = CropBox-relative + CropBox lower-left offset. + assertEquals(15f, rect.getLowerLeftX(), 0.5f); + assertEquals(25f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.NewFormFieldDefinition def = + new FormUtils.NewFormFieldDefinition( + "opts", + null, + "text", + 0, + 10f, + 10f, + 120f, + 18f, + null, + null, + null, + null, + null, + 18f, + Boolean.TRUE, + Boolean.TRUE, + null, + null); + FormUtils.addNewFields(document, List.of(def)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("opts"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + assertTrue(field instanceof PDTextField); + assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("18"), "default appearance should carry the font size: " + da); + } + } + + @Test + void modifyFormFields_movesAndResizesWidget() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null, + null, null, null, null, null, null, null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "movable"); + assertEquals(100f, rect.getLowerLeftX(), 0.5f); + assertEquals(600f, rect.getLowerLeftY(), 0.5f); + assertEquals(150f, rect.getWidth(), 0.5f); + assertEquals(30f, rect.getHeight(), 0.5f); + } + } + + @Test + void modifyFormFields_setsReadOnlyAndFontSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "editable", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 22f, + Boolean.TRUE, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("editable"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("22"), "font size should be reflected in DA: " + da); + } + } + + @Test + void deleteFormFields_removesField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm acroForm = setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20))); + FormUtils.deleteFormFields(document, List.of("temp")); + // After delete the AcroForm may still exist; the field must be gone. + if (acroForm != null) { + assertNull(acroForm.getField("temp")); + } + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue(acroForm == null || acroForm.getField("temp") == null); + } + } + + @Test + void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertNotNull(field, "radio field should exist"); + assertTrue(field instanceof PDRadioButton, "should be a radio button group"); + assertEquals(2, field.getWidgets().size(), "one widget per option"); + assertTrue(((PDRadioButton) field).getExportValues().contains("Yes")); + assertTrue(((PDRadioButton) field).getExportValues().contains("No")); + } + } + + @Test + void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException { + // A radio group's label is its field name, not its first option, so the viewer label + // matches the name shown in the editor. + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "Choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + FormUtils.FormFieldInfo choice = + FormUtils.extractFormFields(reloaded).stream() + .filter(f -> "Choice".equals(f.name())) + .findFirst() + .orElse(null); + assertNotNull(choice, "radio field should be extracted"); + assertEquals( + "Choice", choice.label(), "field name should win over the first option value"); + } + } + + @Test + void addNewFields_createsCombTextField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDTextField field = (PDTextField) acroForm.getField("ssn"); + assertNotNull(field); + assertEquals(9, field.getMaxLen(), "comb max length should persist"); + assertTrue(field.isComb(), "comb flag should be set"); + } + } + + @Test + void addNewFields_createsSignatureAndButton() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("signature", "sig", 50, 600, 200, 60, null, null, null), + newField("button", "btn", 50, 500, 120, 24, null, null, "reset"))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "signature placeholder should exist"); + assertTrue( + acroForm.getField("btn") instanceof PDPushButton, "push button should exist"); + } + } + + @Test + void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20))); + + FormUtils.applyFieldEdits( + document, + List.of(newText("fresh", 50, 600, 200, 20)), + List.of(), + List.of("old")); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm.getField("fresh"), "added field should be present"); + assertNull(acroForm.getField("old"), "deleted field should be gone"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java index dd828327b1..5c13b13908 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java @@ -705,10 +705,20 @@ class FormUtilsGapTest { "newName", "New Label", null, // keep type (text) -> in-place path + null, + null, + null, + null, + null, Boolean.TRUE, null, null, null, + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -731,7 +741,8 @@ class FormUtilsGapTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "missing", null, null, null, null, null, null, null, null); + "missing", null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -754,7 +765,8 @@ class FormUtilsGapTest { mods.add(null); mods.add( new FormUtils.ModifyFormFieldDefinition( - " ", null, null, null, null, null, null, null, null)); + " ", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null)); FormUtils.modifyFormFields(doc, mods); assertEquals(1, FormUtils.extractFormFields(doc).size()); diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java index f4e013e082..fa3425176a 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java @@ -285,13 +285,13 @@ class FormUtilsMoreTest { } @Test - void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException { + void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException { try (PDDocument doc = new PDDocument()) { SetupDocument setup = createBasicDocument(doc); PDTextField text = new PDTextField(setup.acroForm()); text.setPartialName("offpage"); - // Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates - // returns null, which is still added to the per-field widget list. + // Off the page is legal PDF; dropping it would leave the user unable to drag it + // back. attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20)); List fields = @@ -301,7 +301,8 @@ class FormUtilsMoreTest { fields.get(0).getWidgets(); assertNotNull(widgets); assertEquals(1, widgets.size()); - assertNull(widgets.get(0)); + assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay"); + assertEquals(50f, widgets.get(0).getX(), 0.01f); } } @@ -476,8 +477,18 @@ class FormUtilsMoreTest { "combobox", null, null, + null, + null, + null, + null, + null, List.of("One", "Two"), "One", + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -505,10 +516,20 @@ class FormUtilsMoreTest { null, "listbox", // same type -> in-place path null, + null, + null, + null, + null, + null, Boolean.TRUE, List.of("X", "Y", "Z"), null, - "Choose items"); + "Choose items", + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -529,7 +550,25 @@ class FormUtilsMoreTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "keep", null, null, "bogusType", null, null, null, null, null); + "keep", + null, + null, + "bogusType", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); // The field is preserved unchanged because the target type is unsupported. @@ -554,7 +593,8 @@ class FormUtilsMoreTest { // Rename beta -> alpha; should be uniquified to avoid the collision. FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "beta", "alpha", null, null, null, null, null, null, null); + "beta", "alpha", null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -575,7 +615,8 @@ class FormUtilsMoreTest { doc.addPage(new PDPage()); FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "x", null, null, null, null, null, null, null, null); + "x", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); } } diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java new file mode 100644 index 0000000000..d8f5e1deb2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java @@ -0,0 +1,102 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Most real PDFs have no AcroForm at all, so adding the very first field has to build one that + * PDFBox will accept. + */ +class FormUtilsNoAcroFormTest { + + private static final Path PLAIN_PDF = + Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf"); + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float y, List options, String defaultValue) { + // name, label, type, pageIndex, x, y, width, height, required, multiSelect, + // options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction + return new FormUtils.NewFormFieldDefinition( + name, + name, + type, + 0, + 60f, + y, + 200f, + 20f, + null, + null, + options, + defaultValue, + null, + null, + null, + null, + null, + null); + } + + private static PDDocument loadPlain() throws IOException { + return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF)); + } + + @Test + void plainPdfReallyHasNoAcroForm() throws IOException { + try (PDDocument document = loadPlain()) { + assertNull( + document.getDocumentCatalog().getAcroForm(null), + "fixture must have no AcroForm or this test proves nothing"); + } + } + + @Test + void addsFirstFieldToAPdfWithNoAcroForm() throws IOException { + byte[] saved; + List skipped = new ArrayList<>(); + try (PDDocument document = loadPlain()) { + FormUtils.addNewFields( + document, + List.of( + newField("text", "fullName", 700f, null, "Ada"), + newField("checkbox", "agree", 660f, null, null), + newField("radio", "contact", 600f, List.of("Email", "Post"), null)), + skipped); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + + assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped); + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "an AcroForm should have been created"); + assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text"); + assertTrue( + acroForm.getDefaultAppearance() != null + && !acroForm.getDefaultAppearance().isBlank(), + "/DA is required for variable text"); + PDTextField text = (PDTextField) acroForm.getField("fullName"); + assertNotNull(text, "the text field should exist"); + assertEquals("Ada", text.getValueAsString()); + assertNotNull(acroForm.getField("agree")); + assertNotNull(acroForm.getField("contact")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java new file mode 100644 index 0000000000..04b317feb8 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java @@ -0,0 +1,175 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +/** + * Option captions belong to the viewer, not the page. Drawing them into the content stream left + * orphan text behind on every move and delete, so these pin the page staying clean. + */ +class FormUtilsRadioCaptionTest { + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + + private static PDDocument blankWithForm() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + private static String textOf(byte[] pdf) throws IOException { + try (PDDocument reloaded = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(reloaded); + } + } + + @Test + void radioOptionsAreNotBakedIntoThePage() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + saved = save(document); + } + + // The caption is the viewer's job; page content cannot follow a widget that moves. + String text = textOf(saved); + assertFalse(text.contains("Email"), "options must not be page content: " + text); + assertFalse(text.contains("Telephone"), "options must not be page content: " + text); + assertFalse(text.contains("Post"), "options must not be page content: " + text); + } + + @Test + void captionsDoNotReplaceTheWidgetsThemselves() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L")))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRadioButton radio = (PDRadioButton) acroForm.getField("size"); + assertEquals(3, radio.getWidgets().size(), "one widget per option"); + assertFalse(radio.getExportValues().isEmpty(), "export values must survive"); + } + } + + @Test + void aTextFieldDrawsNoStrayCaption() throws IOException { + // Control: proves the assertions above read the captions and not some unrelated content. + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, List.of(newField("text", "fullName", 72, 600, 200, 18, null))); + saved = save(document); + } + assertTrue(textOf(saved).isBlank(), "a text field should add no page content"); + } + + @Test + void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException { + byte[] withRadio; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + withRadio = save(document); + } + assertFalse( + textOf(withRadio).contains("Telephone"), + "the group adds no page text to begin with"); + + byte[] afterDelete; + try (PDDocument document = Loader.loadPDF(withRadio)) { + FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact")); + afterDelete = save(document); + } + + String text = textOf(afterDelete); + assertFalse( + text.contains("Telephone"), + "a deleted radio group must not leave its captions on the page: " + text); + } + + @Test + void theDrawnBoxIsTheWholeGroupNotOneOption() { + // A 90pt box used to become a 360pt stack because each option got the full height. + PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, null, null); + + assertEquals(3, rects.size()); + float top = rects.get(0).getUpperRightY(); + float bottom = rects.get(2).getLowerLeftY(); + assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height"); + assertEquals( + box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge"); + for (PDRectangle r : rects) { + assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square"); + assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box"); + } + } + + @Test + void explicitSizeAndGapWin() { + PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f); + for (PDRectangle r : rects) { + assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim"); + } + float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY(); + assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim"); + } + + @Test + void aSingleOptionStillFitsTheBox() { + var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null); + assertEquals(1, rects.size()); + assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java new file mode 100644 index 0000000000..8c8d7e14be --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java @@ -0,0 +1,57 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A form with no default resources is ordinary; adding a field to it must still work. */ +class MissingDefaultResourcesTest { + + @Test + @DisplayName("a text field can be added to a form that has no default resources") + void addsToFormWithoutDefaultResources() throws IOException { + // A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all. + byte[] pdf; + try (PDDocument built = new PDDocument(); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + built.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(built); + // A /DA naming a font with no /DR to resolve it is what PDFBox refuses. + form.setDefaultAppearance("/Helv 0 Tf 0 g"); + form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR); + built.getDocumentCatalog().setAcroForm(form); + built.save(out); + pdf = out.toByteArray(); + } + + try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) { + + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertTrue( + skipped.isEmpty(), + "adding a plain text field should not be refused: " + skipped); + assertEquals( + 1, + FormUtils.extractFormFields(document).size(), + "the field should be in the document"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 0e399c1fae..72e5eae9a2 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -206,12 +206,24 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareLinkTokenTrailingSlash() { - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/share/abc123/", "")); + assertTrue( + RequestUriUtils.isPublicAuthEndpoint( + "/share/00dcac3a-fc7a-4989-9c4f-97745484d62f/", "")); } @Test void testIsPublicAuthEndpoint_shareLinkWithContextPath() { - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/share/abc123", "/app")); + assertTrue( + RequestUriUtils.isPublicAuthEndpoint( + "/app/share/00dcac3a-fc7a-4989-9c4f-97745484d62f", "/app")); + } + + @Test + void testIsPublicAuthEndpoint_shareLinkWithInvalidTokenLength() { + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/abc123", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/share/00dcac3a-fc7a-4989-9c4f-97745484d62fa", "")); } @Test @@ -236,4 +248,86 @@ class RequestUriUtilsTest { RequestUriUtils.isPublicAuthEndpoint( "/api/v1/storage/share-links/abc123/metadata", "")); } + + // --- invite-accept SPA bootstrap --- + + private static final String INVITE_TOKEN = "06a20e7e-2e35-4e26-be7d-2dce14f28f12"; + + @Test + void testIsPublicAuthEndpoint_inviteLinkToken() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN, "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkTokenTrailingSlash() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkWithContextPath() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/invite/" + INVITE_TOKEN, "/app")); + } + + @Test + void testIsPublicAuthEndpoint_inviteRootNotPublic() { + // Avoid matching bare "/invite" or "/invite/" - must have a token segment + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNestedPathNotPublic() { + // Guard against future additions like /invite//foo becoming accidentally public + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/foo", "")); + } + + @Test + void testIsPublicAuthEndpoint_invitePrefixDoesNotOvermatch() { + // "/inviteX" must not match the invite pattern + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/inviteX", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNonUuidTokenNotPublic() { + // Only exactly-shaped 36-char lowercase UUID tokens are treated as invite links + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc123", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteUppercaseUuidNotPublic() { + // Tokens are generated lowercase by UUID.randomUUID().toString() + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06A20E7E-2E35-4E26-BE7D-2DCE14F28F12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongLengthNotPublic() { + // 35-char and 37-char UUID-like tokens are not valid UUIDs + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f122", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongGroupingNotPublic() { + // Groups of 8-4-4-4-4 must not be shifted around (e.g. 4-4-4-4-8) + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a2-0e7e-2e35-4e26-be7d2dce14f28f12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteTokenInvalidCharsNotPublic() { + // Hex-only; anything outside [0-9a-f] or the UUID hyphens is rejected + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc$123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc..123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc%2F123", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1g", "")); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 2726f8d764..01d3f49e2c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -183,7 +183,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } else if (hasConfiguredOrigins) { @@ -229,7 +231,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } else { @@ -256,7 +260,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java index 49bf4e4895..0354817315 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.*; +import java.util.Locale; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -71,6 +72,7 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema; import org.apache.xmpbox.schema.XMPBasicSchema; import org.apache.xmpbox.xml.DomXmpParser; import org.apache.xmpbox.xml.XmpSerializer; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -83,7 +85,6 @@ import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; @@ -93,6 +94,7 @@ import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; @@ -102,14 +104,26 @@ import stirling.software.common.util.WebResponseUtils; @ConvertApi @Slf4j -@RequiredArgsConstructor public class ConvertPDFToPDFA { private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]"); private final RuntimePathConfig runtimePathConfig; private final stirling.software.SPDF.service.VeraPDFService veraPDFService; + // Level A needs the proprietary tagger; core builds convert at level B instead. + private final PdfaLevelAServiceInterface pdfaLevelAService; private final TempFileManager tempFileManager; + public ConvertPDFToPDFA( + RuntimePathConfig runtimePathConfig, + stirling.software.SPDF.service.VeraPDFService veraPDFService, + @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService, + TempFileManager tempFileManager) { + this.runtimePathConfig = runtimePathConfig; + this.veraPDFService = veraPDFService; + this.pdfaLevelAService = pdfaLevelAService; + this.tempFileManager = tempFileManager; + } + private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc"; private static final int PDFA_COMPATIBILITY_POLICY = 1; @@ -604,7 +618,10 @@ public class ConvertPDFToPDFA { return handlePdfXConversion(inputFile, outputFormat); } else { return handlePdfAConversion( - inputFile, outputFormat, request.getStrict() != null && request.getStrict()); + inputFile, + outputFormat, + request.getStrict() != null && request.getStrict(), + request.getPdfUa() != null && request.getPdfUa()); } } @@ -1815,8 +1832,64 @@ public class ConvertPDFToPDFA { return Files.readAllBytes(outputPdf); } + /** Tags a converted PDF/A for level A; must run after Ghostscript, which discards tags. */ + private PdfaLevelAServiceInterface.Result applyLevelA( + byte[] converted, + Path original, + PdfaProfile profile, + String baseFileName, + boolean declarePdfUa) { + if (!profile.requiresTagging()) { + return new PdfaLevelAServiceInterface.Result(converted, true, List.of()); + } + if (pdfaLevelAService == null) { + return new PdfaLevelAServiceInterface.Result( + converted, + false, + List.of( + "Level A tagging is not available in this build, so the file was left" + + " at conformance level B.")); + } + // Prefer the document's own title/language; hardcoding "en" mislabelled German reports. + // Read the original, not the converted bytes: Ghostscript discards /Lang, so probing its + // output always yields null and every document would be relabelled with the default. + String language = null; + String title = null; + try (PDDocument probe = Loader.loadPDF(original.toFile())) { + language = probe.getDocumentCatalog().getLanguage(); + title = probe.getDocumentInformation().getTitle(); + } catch (IOException e) { + log.debug("Could not read original title/language: {}", e.getMessage()); + } + if (language == null || language.isBlank()) { + try (PDDocument probe = Loader.loadPDF(converted)) { + language = probe.getDocumentCatalog().getLanguage(); + if (title == null || title.isBlank()) { + title = probe.getDocumentInformation().getTitle(); + } + } catch (IOException e) { + log.debug("Could not read converted title/language: {}", e.getMessage()); + } + } + PdfaLevelAServiceInterface.Result result = + pdfaLevelAService.upgradeToLevelA( + converted, + profile.getPart(), + language, + title != null && !title.isBlank() ? title : baseFileName, + declarePdfUa); + result.warnings().forEach(warning -> log.info("PDF/A level A: {}", warning)); + if (!result.levelA()) { + log.warn( + "{} requested but the document could not be tagged; returning level B", + profile.getDisplayName()); + } + return result; + } + private ResponseEntity handlePdfAConversion( - MultipartFile inputFile, String outputFormat, boolean strict) throws Exception { + MultipartFile inputFile, String outputFormat, boolean strict, boolean declarePdfUa) + throws Exception { PdfaProfile profile = PdfaProfile.fromRequest(outputFormat); // Get the original filename without extension @@ -1841,12 +1914,15 @@ public class ConvertPDFToPDFA { log.info("Using Ghostscript for PDF/A conversion to {}", profile.getDisplayName()); try { converted = convertWithGhostscript(inputPath, workingDir, profile); - String outputFilename = baseFileName + profile.outputSuffix(); + var levelA = + applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa); + converted = levelA.pdfBytes(); + String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA()); validateAndWarnPdfA(converted, profile, "Ghostscript"); if (strict) { - verifyStrictCompliance(converted); + verifyStrictCompliance(converted, profile, levelA.levelA()); } TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); @@ -1867,13 +1943,15 @@ public class ConvertPDFToPDFA { } converted = convertWithPdfBoxMethod(inputPath, profile); - String outputFilename = baseFileName + profile.outputSuffix(); + var levelA = applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa); + converted = levelA.pdfBytes(); + String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA()); // Validate with PDFBox preflight and warn if issues found validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice"); if (strict) { - verifyStrictCompliance(converted); + verifyStrictCompliance(converted, profile, levelA.levelA()); } TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); @@ -1889,11 +1967,56 @@ public class ConvertPDFToPDFA { } } - private void verifyStrictCompliance(byte[] pdfBytes) throws IOException { + /** True for a PDF/UA or WCAG result, which says nothing about archival conformance. */ + private static boolean isAccessibilityProfile( + stirling.software.SPDF.model.api.security.PDFVerificationResult result) { + String profile = result.getValidationProfile(); + if (profile == null) { + return false; + } + String normalised = profile.toLowerCase(Locale.ROOT); + return normalised.contains("ua") || normalised.contains("wcag"); + } + + /** + * True when a result speaks for the requested profile. Only archival results count, and a level + * B pass must never satisfy a level A request. + */ + private static boolean answersRequest( + PdfaProfile profile, + stirling.software.SPDF.model.api.security.PDFVerificationResult result) { + if (isAccessibilityProfile(result)) { + return false; + } + String standard = result.getStandard(); + if (standard == null || standard.length() < 2) { + return false; + } + if (standard.charAt(0) != Character.forDigit(profile.getPart(), 10)) { + return false; + } + return !profile.requiresTagging() || Character.toLowerCase(standard.charAt(1)) == 'a'; + } + + private void verifyStrictCompliance(byte[] pdfBytes, PdfaProfile profile, boolean levelAReached) + throws IOException { + // Tagging is the only route to level A, so an untagged file cannot answer a strict request. + if (!levelAReached) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Strict PDF/A mode enabled: the document could not be tagged, so " + + profile.getDisplayName() + + " was not reached. It is valid at level B."); + } try (InputStream is = new ByteArrayInputStream(pdfBytes)) { List results = veraPDFService.validatePDF(is); - boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant()); + boolean isCompliant = + results.stream() + .filter(result -> answersRequest(profile, result)) + .anyMatch( + stirling.software.SPDF.model.api.security.PDFVerificationResult + ::isCompliant); if (!isCompliant) { String details = results.stream() @@ -1901,7 +2024,9 @@ public class ConvertPDFToPDFA { .collect(Collectors.joining("; ")); throw new ResponseStatusException( HttpStatus.BAD_REQUEST, - "Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: " + "Strict PDF/A mode enabled: the output is not perfectly compliant with " + + profile.getDisplayName() + + ". Details: " + details); } } catch (Exception e) { @@ -2466,11 +2591,16 @@ public class ConvertPDFToPDFA { @Getter private enum PdfaProfile { - PDF_A_1B(1, "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"), - PDF_A_2B(2, "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"), - PDF_A_3B(3, "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"); + PDF_A_1B(1, "B", "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"), + PDF_A_2B(2, "B", "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"), + PDF_A_3B(3, "B", "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"), + // Level A = level B plus tagging, declared language and Unicode text; tagged post-convert. + PDF_A_1A(1, "A", "PDF/A-1a", "_PDFA-1a.pdf", "1.4", Format.PDF_A1B, "pdfa-1a"), + PDF_A_2A(2, "A", "PDF/A-2a", "_PDFA-2a.pdf", "1.7", null, "pdfa-2a"), + PDF_A_3A(3, "A", "PDF/A-3a", "_PDFA-3a.pdf", "1.7", null, "pdfa-3a"); private final int part; + private final String conformanceLevel; private final String displayName; private final String suffix; private final String compatibilityLevel; @@ -2479,12 +2609,14 @@ public class ConvertPDFToPDFA { PdfaProfile( int part, + String conformanceLevel, String displayName, String suffix, String compatibilityLevel, Format preflightFormat, String... requestTokens) { this.part = part; + this.conformanceLevel = conformanceLevel; this.displayName = displayName; this.suffix = suffix; this.compatibilityLevel = compatibilityLevel; @@ -2495,6 +2627,10 @@ public class ConvertPDFToPDFA { .toList(); } + boolean requiresTagging() { + return "A".equals(conformanceLevel); + } + static PdfaProfile fromRequest(String requestToken) { if (requestToken == null) { return PDF_A_2B; @@ -2508,8 +2644,11 @@ public class ConvertPDFToPDFA { return match.orElse(PDF_A_2B); } - String outputSuffix() { - return suffix; + /** + * Names the file at the level actually reached; a level A name over level B content lies. + */ + String outputSuffix(boolean levelAReached) { + return levelAReached ? suffix : "_PDFA-" + part + "b.pdf"; } Optional preflightFormat() { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index d24d175f5c..0bd3daf180 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.poi.ss.usermodel.*; @@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper; @RequiredArgsConstructor public class FormFillController { + /** Carries the edits a request asked for but the document could not take, as base64 JSON. */ + public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits"; + + /** How many were skipped in total, which may exceed the number listed in the header above. */ + public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total"; + + /** Keeps the header well inside Jetty's response-header budget. */ + private static final int MAX_REPORTED_SKIPS = 20; + + /** Bytes of encoded header value, well under the container's limit for the whole header set. */ + private static final int MAX_SKIP_HEADER_BYTES = 4096; + + private static final int MAX_SKIP_FIELD_CHARS = 120; + + /** Entry names inside the {@code ?includeFields=true} bundle. */ + private static final String FIELDS_ENTRY = "fields.json"; + + private static final String DOCUMENT_ENTRY = "document.pdf"; + private final CustomPDFDocumentFactory pdfDocumentFactory; private final ObjectMapper objectMapper; private final TempFileManager tempFileManager; @@ -68,6 +98,72 @@ public class FormFillController { return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager); } + /** + * Rejects field names PDFBox cannot store before the document is touched, so the caller gets a + * 400 naming the offending character instead of a 200 with the field quietly missing. + */ + private static void requireUsableFieldNames( + List adds, + List modifies) { + Stream problems = + Stream.concat( + adds.stream() + .map(FormUtils.NewFormFieldDefinition::name) + .map(FormUtils::invalidFieldNameReason), + // A rename to the same name is not a rename, so a nested field whose + // qualified name already contains a period is left alone. + modifies.stream() + .map(m -> FormUtils.renameProblem(m.targetName(), m.name()))); + problems.filter(Objects::nonNull) + .findFirst() + .ifPresent( + reason -> { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", "{0}", reason); + }); + } + + /** + * The body is the updated PDF, so dropped edits travel as a base64 JSON header; + * percent-encoding would turn every space into a plus sign. + */ + private ResponseEntity withSkippedEdits( + ResponseEntity response, List skipped) { + if (skipped.isEmpty()) { + return response; + } + // A count cap alone is not enough: one very long field name can still overflow the + // header budget and turn the response into an error page, losing the edited PDF. + List reported = new ArrayList<>(); + String encoded = ""; + for (FormUtils.SkippedFieldEdit edit : skipped) { + if (reported.size() >= MAX_REPORTED_SKIPS) { + break; + } + reported.add( + new FormUtils.SkippedFieldEdit( + edit.operation(), + FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS), + FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS))); + String candidate = + Base64.getEncoder() + .encodeToString( + objectMapper + .writeValueAsString(reported) + .getBytes(StandardCharsets.UTF_8)); + if (candidate.length() > MAX_SKIP_HEADER_BYTES) { + reported.removeLast(); + break; + } + encoded = candidate; + } + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size())) + .header(SKIPPED_EDITS_HEADER, encoded) + .body(response.getBody()); + } + private static String buildBaseName(MultipartFile file, String suffix) { String original = Filenames.toSimpleFileName(file.getOriginalFilename()); if (original == null || original.isBlank()) { @@ -257,6 +353,110 @@ public class FormFillController { } } + @PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Add new form fields", + description = + "Creates new form fields in the provided PDF and returns the updated file") + public ResponseEntity addFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = "JSON array of new field definitions", + example = + "[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]") + @RequestPart(value = "fields", required = false) + byte[] fieldsPayload) + throws IOException { + + String rawFields = decodePart(fieldsPayload); + List definitions = + FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields); + if (definitions.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", + "{0} must contain at least one definition", + "fields payload"); + } + + requireUsableFieldNames(definitions, List.of()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.addNewFields(document, definitions, skipped)), + skipped); + } + + @PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Apply a batch of form field edits", + description = + "Adds, modifies, and deletes form fields in a single request (one document" + + " load/save) and returns the updated file") + public ResponseEntity editFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = + "JSON object with optional 'add', 'modify' and 'delete'" + + " sections", + example = + "{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]," + + "\"modify\":[],\"delete\":[]}") + @RequestPart(value = "edits", required = false) + byte[] editsPayload, + @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.") + @RequestParam(value = "includeFields", defaultValue = "false") + boolean includeFields) + throws IOException { + + String rawEdits = decodePart(editsPayload); + FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits); + if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", "{0} must contain at least one edit", "edits payload"); + } + requireUsableFieldNames(batch.add(), batch.modify()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + includeFields, + document -> + FormUtils.applyFieldEdits( + document, + batch.add(), + batch.modify(), + batch.delete(), + skipped)), + skipped); + } + @PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Modify existing form fields", @@ -285,8 +485,15 @@ public class FormFillController { "updates payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.modifyFormFields(document, modifications)); + requireUsableFieldNames(List.of(), modifications); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.modifyFormFields(document, modifications, skipped)), + skipped); } @PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -319,8 +526,13 @@ public class FormFillController { "error.dataRequired", "{0} must contain at least one value", "names payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.deleteFormFields(document, names)); + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.deleteFormFields(document, names, skipped)), + skipped); } @PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -358,13 +570,81 @@ public class FormFillController { private ResponseEntity processSingleFile( MultipartFile file, String suffix, DocumentProcessor processor) throws IOException { + return processSingleFile(file, suffix, false, processor); + } + + private ResponseEntity processSingleFile( + MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor) + throws IOException { requirePdf(file); String baseName = buildBaseName(file, suffix); try (PDDocument document = pdfDocumentFactory.load(file)) { FormUtils.repairMissingWidgetPageReferences(document); processor.accept(document); - return saveDocument(document, baseName); + return includeFields + ? saveDocumentWithFields(document, baseName) + : saveDocument(document, baseName); + } + } + + /** + * Answers "what fields does the saved file have?" from the document still open here, so the + * caller does not have to upload the result back to ask. + */ + private ResponseEntity saveDocumentWithFields(PDDocument document, String baseName) + throws IOException { + TempFile zip = null; + boolean zipTransferred = false; + try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) { + document.save(pdf.getFile()); + // Read the fields after the save so they describe the bytes actually being returned. + byte[] fields = + objectMapper.writeValueAsBytes( + FormUtils.extractFormFieldsWithCoordinates(document)); + zip = tempFileManager.createManagedTempFile(".zip"); + writeFieldBundle(zip.getPath(), pdf.getPath(), fields); + ResponseEntity response = + WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip"); + zipTransferred = true; + return response; + } finally { + if (zip != null && !zipTransferred) { + zip.close(); + } + } + } + + /** + * Deflates the JSON because it is text, but stores the PDF: its streams are already compressed, + * so deflating costs ~25ms per MB to save a few percent. + */ + private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields) + throws IOException { + long pdfSize = Files.size(pdfPath); + CRC32 crc = new CRC32(); + try (InputStream in = Files.newInputStream(pdfPath)) { + byte[] buffer = new byte[8192]; + for (int read; (read = in.read(buffer)) != -1; ) { + crc.update(buffer, 0, read); + } + } + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) { + ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY); + fieldsEntry.setMethod(ZipEntry.DEFLATED); + zip.putNextEntry(fieldsEntry); + zip.write(fields); + zip.closeEntry(); + + ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY); + documentEntry.setMethod(ZipEntry.STORED); + documentEntry.setSize(pdfSize); + documentEntry.setCompressedSize(pdfSize); + documentEntry.setCrc(crc.getValue()); + zip.putNextEntry(documentEntry); + Files.copy(pdfPath, zip); + zip.closeEntry(); + zip.finish(); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index 6f82c7546e..f48f419a6d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -28,6 +28,8 @@ final class FormPayloadParser { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private static final TypeReference> MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> NEW_FIELD_LIST_TYPE = + new TypeReference<>() {}; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; private FormPayloadParser() {} @@ -94,6 +96,43 @@ final class FormPayloadParser { return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } + static List parseNewFieldDefinitions( + ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE); + } + + /** + * Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each + * section is optional. The delete section accepts the same shapes as {@link #parseNameList}. + */ + static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of()); + } + final JsonNode root = objectMapper.readTree(json); + List adds = List.of(); + List modifies = List.of(); + List deletes = List.of(); + if (root != null && root.isObject()) { + final JsonNode addNode = root.get("add"); + if (addNode != null && addNode.isArray()) { + adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE); + } + final JsonNode modifyNode = root.get("modify"); + if (modifyNode != null && modifyNode.isArray()) { + modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE); + } + final JsonNode deleteNode = root.get("delete"); + if (deleteNode != null && !deleteNode.isNull()) { + deletes = parseNameList(objectMapper, deleteNode.toString()); + } + } + return new FormUtils.FieldEditBatch(adds, modifies, deletes); + } + static List parseNameList(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java index bb0520a4ba..921663912b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java @@ -14,9 +14,19 @@ public class PdfToPdfARequest extends PDFFile { @Schema( description = "The output format type (PDF/A or PDF/X)", requiredMode = Schema.RequiredMode.REQUIRED, - allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"}) + allowableValues = { + "pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfa-1a", "pdfa-2a", + "pdfa-3a", "pdfx" + }) private String outputFormat; + @Schema( + description = + "Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A" + + " formats, and the claim is written only if it validates.", + defaultValue = "false") + private Boolean pdfUa; + @Schema( description = "If true, the conversion will fail if the output is not perfectly compliant") diff --git a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java index bb3c84534c..6361157b21 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java @@ -285,6 +285,8 @@ public class VeraPDFService { } } + // Never force PDF/UA here - it flags every ordinary document as non-compliant and doubles + // verify cost; /accessibility-report checks PDF/UA on demand. if (!hasPdfaDeclaration) { results.add(createNoPdfaDeclarationResult()); } diff --git a/app/core/src/main/java/stirling/software/common/controller/JobController.java b/app/core/src/main/java/stirling/software/common/controller/JobController.java index ef9e81873e..c6fec4b92b 100644 --- a/app/core/src/main/java/stirling/software/common/controller/JobController.java +++ b/app/core/src/main/java/stirling/software/common/controller/JobController.java @@ -12,6 +12,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -213,6 +214,35 @@ public class JobController { } } + /** + * Self-service counterpart to the admin-only {@code POST /api/v1/admin/job/cleanup}: that one + * sweeps every user's jobs and needs ROLE_ADMIN, this one releases only the caller's own and so + * is safe for any authenticated user. Both run the same sweep inside {@link TaskManager}. + */ + @PostMapping("/jobs/cleanup") + @Operation( + summary = "Release finished jobs and their stored files now", + description = + "Force-expires this node's finished jobs instead of waiting out the retention" + + " window, deleting their result files and the persistent copies made of" + + " their inputs. Only jobs the caller may access are touched, and jobs" + + " still running are left alone. Admins can sweep every user's jobs" + + " with POST /api/v1/admin/job/cleanup?force=true.") + public ResponseEntity cleanupFinishedJobs() { + TaskManager.CleanupSummary summary = + taskManager.cleanupFinishedJobsNow(this::validateJobAccess); + log.info( + "On-demand job cleanup removed {} job(s) and {} file(s), retained {} job(s)", + summary.jobsRemoved(), + summary.filesDeleted(), + summary.jobsRetained()); + return ResponseEntity.ok( + Map.of( + "jobsRemoved", summary.jobsRemoved(), + "filesDeleted", summary.filesDeleted(), + "jobsRetained", summary.jobsRetained())); + } + @GetMapping("/job/{jobId}/result/files") @Operation(summary = "Get job result files") public ResponseEntity getJobFiles(@PathVariable("jobId") String jobId) { diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index fa852846b3..a0dae640e0 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -14,6 +14,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, + { + "moduleName": "ch.qos.logback:logback-classic", + "moduleUrl": "http://www.qos.ch", + "moduleVersion": "1.6.3", + "moduleLicense": "LGPL-2.1-only", + "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + }, { "moduleName": "ch.qos.logback:logback-core", "moduleUrl": "http://www.qos.ch", @@ -21,6 +28,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, + { + "moduleName": "ch.qos.logback:logback-core", + "moduleUrl": "http://www.qos.ch", + "moduleVersion": "1.6.3", + "moduleLicense": "LGPL-2.1-only", + "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + }, { "moduleName": "com.adobe.xmp:xmpcore", "moduleUrl": "https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html", @@ -182,7 +196,7 @@ { "moduleName": "com.github.mwiede:jsch", "moduleUrl": "https://github.com/mwiede/jsch", - "moduleVersion": "0.2.23", + "moduleVersion": "2.28.6", "moduleLicense": "Revised BSD", "moduleLicenseUrl": "https://github.com/mwiede/jsch/blob/master/LICENSE.txt" }, @@ -513,27 +527,45 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-image", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.common:common-io", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-io", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.common:common-lang", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-lang", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-batik", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, { "moduleName": "com.twelvemonkeys.imageio:imageio-bmp", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -543,9 +575,15 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-core", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-jpeg", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -555,9 +593,15 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-psd", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -567,12 +611,24 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-webp", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-webp", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.vladsch.flexmark:flexmark", "moduleVersion": "0.64.8", @@ -758,7 +814,7 @@ { "moduleName": "commons-net:commons-net", "moduleUrl": "https://commons.apache.org/proper/commons-net/", - "moduleVersion": "3.11.1", + "moduleVersion": "3.13.0", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -1008,21 +1064,14 @@ { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.47", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.47", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" - }, - { - "moduleName": "io.swagger.core.v3:swagger-core-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1034,9 +1083,9 @@ "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { - "moduleName": "io.swagger.core.v3:swagger-models-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", - "moduleVersion": "2.2.46", + "moduleName": "io.swagger.core.v3:swagger-core-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1047,6 +1096,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "io.swagger.core.v3:swagger-models-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", + "moduleVersion": "2.2.53", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "jakarta.activation:jakarta.activation-api", "moduleUrl": "https://www.eclipse.org", @@ -1213,24 +1269,48 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-networking", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-security", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-security", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-support", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-support", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-velocity", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-velocity", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.antlr:antlr4-runtime", "moduleUrl": "https://www.antlr.org/", @@ -1325,13 +1405,6 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "org.apache.httpcomponents:httpclient", - "moduleUrl": "http://hc.apache.org/httpcomponents-client", - "moduleVersion": "4.5.13", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "org.apache.httpcomponents:httpclient", "moduleUrl": "http://hc.apache.org/httpcomponents-client-ga", @@ -1456,6 +1529,13 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.apache.santuario:xmlsec", + "moduleUrl": "https://www.apache.org/", + "moduleVersion": "3.0.6", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.apache.tomcat.embed:tomcat-embed-el", "moduleUrl": "https://tomcat.apache.org/", @@ -1470,6 +1550,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.apache.velocity:velocity-engine-core", + "moduleUrl": "https://www.apache.org/", + "moduleVersion": "2.4.1", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.apache.xmlbeans:xmlbeans", "moduleUrl": "https://xmlbeans.apache.org/", @@ -1647,6 +1734,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "http://www.gnu.org/licenses/lgpl-3.0.txt" }, + { + "moduleName": "org.cryptacular:cryptacular", + "moduleUrl": "https://www.cryptacular.org", + "moduleVersion": "1.3.0", + "moduleLicense": "GNU Lesser General Public License", + "moduleLicenseUrl": "https://www.gnu.org/licenses/lgpl-3.0.txt" + }, { "moduleName": "org.eclipse.angus:angus-activation", "moduleUrl": "https://www.eclipse.org", @@ -2016,78 +2110,156 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-core-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-core-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-core-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-messaging-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-messaging-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-profile-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-profile-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-saml-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-saml-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-saml-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-saml-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-security-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-security-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-security-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-security-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-soap-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-soap-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-soap-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-soap-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-storage-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-storage-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-xmlsec-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-xmlsec-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-xmlsec-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-xmlsec-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.ow2.asm:asm", "moduleUrl": "http://asm.ow2.org", @@ -2132,7 +2304,7 @@ }, { "moduleName": "org.simplejavamail:core-module", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2145,13 +2317,13 @@ }, { "moduleName": "org.simplejavamail:outlook-module", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.simplejavamail:simple-java-mail", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2171,10 +2343,10 @@ }, { "moduleName": "org.snakeyaml:snakeyaml-engine", - "moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine", - "moduleVersion": "3.0.1", + "moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine", + "moduleVersion": "3.1.1", "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.springdoc:springdoc-openapi-starter-common", @@ -2564,6 +2736,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.security:spring-security-core", + "moduleUrl": "https://spring.io/projects/spring-security", + "moduleVersion": "7.1.0", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.security:spring-security-crypto", "moduleUrl": "https://spring.io/projects/spring-security", @@ -2606,6 +2785,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.security:spring-security-saml2-service-provider", + "moduleUrl": "https://spring.io/projects/spring-security", + "moduleVersion": "7.1.0", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.security:spring-security-web", "moduleUrl": "https://spring.io/projects/spring-security", @@ -2805,207 +2991,207 @@ }, { "moduleName": "software.amazon.awssdk:annotations", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { - "moduleName": "software.amazon.awssdk:apache-client", - "moduleVersion": "2.44.12", + "moduleName": "software.amazon.awssdk:apache5-client", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:arns", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:auth", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-query-protocol", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-xml-protocol", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:checksums", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:checksums-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:crt-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:endpoints-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-aws", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-aws-eventstream", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-client-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:identity-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:json-utils", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:metrics-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:netty-nio-client", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:profiles", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:protocol-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:regions", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:retries", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:retries-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:s3", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:sdk-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:third-party-jackson-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:url-connection-client", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:utils", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:utils-lite", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, diff --git a/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java index f6fe52f2a6..d7d211a7ce 100644 --- a/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java @@ -61,7 +61,10 @@ class ToolIODeclarationCoverageTest { // signing tool itself is /api/v1/security/cert-sign, which is declared. "/api/v1/security/cert-sign/sessions", "/api/v1/security/cert-sign/validate-certificate", - "/api/v1/security/cert-sign/hardware"); + "/api/v1/security/cert-sign/hardware", + // Releases finished jobs and their stored files; server maintenance, takes and + // returns no document. + "/api/v1/general/jobs/cleanup"); private record Scan(Set required, Map declared) {} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java index b64776665b..ea693ddd27 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java @@ -46,6 +46,7 @@ import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; import stirling.software.SPDF.model.api.security.PDFVerificationResult; import stirling.software.SPDF.service.VeraPDFService; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.TempFileManager; /** @@ -62,10 +63,12 @@ class ConvertPDFToPDFAGapTest { @Mock private RuntimePathConfig runtimePathConfig; @Mock private VeraPDFService veraPDFService; + @Mock private PdfaLevelAServiceInterface pdfaLevelAService; @Mock private TempFileManager tempFileManager; private ConvertPDFToPDFA newController() { - return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager); + return new ConvertPDFToPDFA( + runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager); } // ---- reflection helpers ---------------------------------------------------------------- @@ -161,9 +164,21 @@ class ConvertPDFToPDFAGapTest { } private String suffixOf(Object profile) throws Exception { - Method m = profile.getClass().getDeclaredMethod("outputSuffix"); + return suffixOf(profile, true); + } + + private String suffixOf(Object profile, boolean levelAReached) throws Exception { + Method m = profile.getClass().getDeclaredMethod("outputSuffix", boolean.class); m.setAccessible(true); - return (String) m.invoke(profile); + return (String) m.invoke(profile, levelAReached); + } + + @Test + @DisplayName("a level A profile falls back to the level B name when tagging failed") + void levelANotReachedIsNamedLevelB() throws Exception { + assertThat(suffixOf(resolveProfile("pdfa-1a"), false)).isEqualTo("_PDFA-1b.pdf"); + assertThat(suffixOf(resolveProfile("pdfa-2a"), false)).isEqualTo("_PDFA-2b.pdf"); + assertThat(suffixOf(resolveProfile("pdfa-3a"), true)).isEqualTo("_PDFA-3a.pdf"); } @Test @@ -717,6 +732,30 @@ class ConvertPDFToPDFAGapTest { @DisplayName("verifyStrictCompliance (VeraPDFService mocked)") class StrictCompliance { + private Object profile(String token) throws Exception { + Class enumClass = null; + for (Class inner : ConvertPDFToPDFA.class.getDeclaredClasses()) { + if (inner.getSimpleName().equals("PdfaProfile")) { + enumClass = inner; + } + } + Method m = enumClass.getDeclaredMethod("fromRequest", String.class); + m.setAccessible(true); + return m.invoke(null, token); + } + + private Throwable verify(String token, boolean levelAReached) throws Exception { + ConvertPDFToPDFA controller = newController(); + return catchThrowable( + () -> + invokeInstance( + controller, + "verifyStrictCompliance", + (Object) "dummy".getBytes(), + profile(token), + levelAReached)); + } + @Test @DisplayName("compliant result passes without throwing") void compliantPasses() throws Exception { @@ -726,14 +765,7 @@ class ConvertPDFToPDFAGapTest { ok.setComplianceSummary("PDF/A-1b compliant"); when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); - ConvertPDFToPDFA controller = newController(); - assertThatCode( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())) - .doesNotThrowAnyException(); + assertThat(verify("pdfa-1", true)).isNull(); } @Test @@ -745,34 +777,70 @@ class ConvertPDFToPDFAGapTest { bad.setComplianceSummary("PDF/A-1b with errors"); when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad)); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(ex.getReason()).contains("PDF/A-1b with errors"); } + @Test + @DisplayName("a level B pass does not satisfy a level A request") + void levelBDoesNotSatisfyLevelA() throws Exception { + PDFVerificationResult ok = new PDFVerificationResult(); + ok.setCompliant(true); + ok.setStandard("1b"); + ok.setComplianceSummary("PDF/A-1b compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); + + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1a", true); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getReason()).contains("PDF/A-1a"); + } + + @Test + @DisplayName("a level A result satisfies a level A request") + void levelASatisfiesLevelA() throws Exception { + PDFVerificationResult ok = new PDFVerificationResult(); + ok.setCompliant(true); + ok.setStandard("2a"); + ok.setComplianceSummary("PDF/A-2a compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); + + assertThat(verify("pdfa-2a", true)).isNull(); + } + + @Test + @DisplayName("untagged output fails a level A request before validation runs") + void untaggedLevelARequestFails() throws Exception { + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2a", false); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getReason()).contains("could not be tagged"); + verifyNoInteractions(veraPDFService); + } + + @Test + @DisplayName("a compliant PDF/UA result never satisfies a strict PDF/A request") + void accessibilityResultIsIgnored() throws Exception { + PDFVerificationResult ua = new PDFVerificationResult(); + ua.setCompliant(true); + ua.setStandard("ua1"); + ua.setValidationProfile("ua1"); + ua.setComplianceSummary("PDF/UA-1 compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ua)); + + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2b", true); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + @Test @DisplayName("empty result list is treated as non-compliant -> 400") void emptyResultsTreatedNonCompliant() throws Exception { when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList()); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @@ -782,15 +850,7 @@ class ConvertPDFToPDFAGapTest { void serviceErrorWrappedAs500() throws Exception { when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom")); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java index e9d9b9ce1d..d56a283464 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java @@ -42,6 +42,7 @@ import org.springframework.mock.web.MockMultipartFile; import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; import stirling.software.SPDF.service.VeraPDFService; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.TempFile; @@ -63,10 +64,12 @@ class ConvertPDFToPDFAMoreTest { @Mock private RuntimePathConfig runtimePathConfig; @Mock private VeraPDFService veraPDFService; + @Mock private PdfaLevelAServiceInterface pdfaLevelAService; @Mock private TempFileManager tempFileManager; private ConvertPDFToPDFA newController() { - return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager); + return new ConvertPDFToPDFA( + runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager); } private static ResponseEntity streamingOk(byte[] bytes) { diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java new file mode 100644 index 0000000000..8082949c8e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java @@ -0,0 +1,374 @@ +package stirling.software.SPDF.controller.api.form; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.model.FormFieldWithCoordinates; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays + * interchangeable with the follow-up request it exists to remove. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("edit-fields field bundle") +class FormFieldBundleTest { + + /** Set to a directory to dump the produced archives for the frontend reader's fixtures. */ + private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures"); + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @InjectMocks private FormFillController controller; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() throws Exception { + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + invocation -> { + File file = + Files.createTempFile( + "bundle", invocation.getArgument(0)) + .toFile(); + TempFile temp = mock(TempFile.class); + lenient().when(temp.getFile()).thenReturn(file); + lenient().when(temp.getPath()).thenReturn(file.toPath()); + return temp; + }); + objectMapper = JsonMapper.builder().build(); + var field = FormFillController.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(controller, objectMapper); + } + + // -- document shapes ---------------------------------------------- + + private record Style( + String name, int pages, int rotation, List fields) {} + + private static FormUtils.NewFormFieldDefinition field( + String name, String type, int page, float y, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null, + null, null, null, null); + } + + static List

{banner}
{children}
diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 1a55c53a90..22c969e704 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -300,6 +300,10 @@ const FileEditorThumbnail = ({ const [showVersionHistory, setShowVersionHistory] = useState(false); const policyEnforcing = policies.some((p) => p.enforcing); + // The overlay swallows clicks, so a run that never settles would leave the card + // unusable with no way out. Dismissible, like the viewer's; resets per run. + const [enforcingDismissed, setEnforcingDismissed] = useState(false); + if (!policyEnforcing && enforcingDismissed) setEnforcingDismissed(false); // The policy currently enforcing, so the overlay's icon/spinner match that // policy's badge instead of a fixed blue. const enforcingPolicy = policies.find((p) => p.enforcing); @@ -548,8 +552,9 @@ const FileEditorThumbnail = ({ {/* Policy enforcement overlay — shown while any policy is in-flight */} setEnforcingDismissed(true)} accentVar={enforcingPolicy?.accentColor} categoryId={enforcingPolicy?.id} /> diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index e54964c592..40e17dc502 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -13,6 +13,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import HistoryIcon from "@mui/icons-material/History"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; @@ -36,6 +37,8 @@ import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; import { findFolderIcon } from "@app/components/filesPage/folderIcons"; import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; @@ -83,6 +86,12 @@ interface FileGridProps { onSaveToServer?: (file: StirlingFileStub) => void; /** Open the version-history modal for a file (only when it has >1 version). */ onVersionHistory?: (file: StirlingFileStub) => void; + /** Download a copy (desktop: save a copy). */ + onDownloadFile?: (file: StirlingFileStub) => void; + /** Open the rename dialog for a file. */ + onRenameFile?: (file: StirlingFileStub) => void; + /** Save a second copy of the file into the library. */ + onDuplicateFile?: (file: StirlingFileStub) => void; /** When set, the Save to server item renders disabled with this tooltip. */ saveToServerDisabledReason?: string | null; /** When supplied the list-view column headers become sortable. */ @@ -366,24 +375,21 @@ function EmptyState({ ); } -function GridView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, -}: FileGridProps) { +function GridView(props: FileGridProps) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); return (
{entries.map((entry) => { @@ -424,22 +430,7 @@ function GridView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onDoubleClick={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -626,7 +617,222 @@ function PolicyBadges({ fileId }: { fileId: string }) { return ; } -interface FileCardProps { +/** Per-file actions. Shared verbatim by the grid card and the list row, and + * kept in step with the file sidebar's kebab so both surfaces offer the same. */ +interface FileActionsMenuProps { + file: StirlingFileStub; + triggerRef: React.RefObject; + onOpen: () => void; + onMove: () => void; + onRemove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + onSaveToServer?: () => void; + onVersionHistory?: () => void; + saveToServerDisabledReason?: string | null; +} + +function FileActionsMenu({ + file, + triggerRef, + onOpen, + onMove, + onRemove, + onDownload, + onRename, + onDuplicate, + onSaveToServer, + onVersionHistory, + saveToServerDisabledReason, +}: FileActionsMenuProps) { + const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const DownloadIcon = useFileActionIcons().download; + const showSaveToServer = + Boolean(onSaveToServer) && file.remoteStorageId == null; + const showVersionHistory = + Boolean(onVersionHistory) && (file.versionNumber ?? 1) > 1; + return ( + + + e.stopPropagation()} + aria-label={t("filesPage.fileMenu", "File actions")} + data-testid="file-card-actions" + > + + + + + } + onClick={(e) => { + e.stopPropagation(); + onOpen(); + }} + > + {t("filesPage.addToWorkspace", "Add to workspace")} + + + } + onClick={(e) => { + e.stopPropagation(); + onMove(); + }} + data-testid="file-menu-move-to" + > + {t("filesPage.moveTo", "Move to…")} + + + {(onDownload || onRename || onDuplicate) && } + {onDownload && ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + data-testid="file-menu-download" + > + {terminology.download} + + )} + {onRename && ( + } + onClick={(e) => { + e.stopPropagation(); + onRename(); + }} + data-testid="file-menu-rename" + > + {t("filesPage.rename", "Rename")} + + )} + {onDuplicate && ( + } + onClick={(e) => { + e.stopPropagation(); + onDuplicate(); + }} + data-testid="file-menu-duplicate" + > + {t("filesPage.duplicate", "Duplicate")} + + )} + + {(showSaveToServer || showVersionHistory) && } + {/* Per-file Save to server; shown for local-only files. When + storage is off it stays visible but disabled with a tooltip. */} + {showSaveToServer && onSaveToServer && ( + + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={(e) => { + e.stopPropagation(); + onSaveToServer(); + }} + style={ + saveToServerDisabledReason + ? { pointerEvents: "auto" } + : undefined + } + > + {t("filesPage.saveToServer", "Save to server")} + + + )} + {showVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(); + }} + > + {t("filesPage.versionHistory", "Version history")} + + )} + + + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t("filesPage.remove", "Delete")} + + + + ); +} + +/** Binds one file's kebab handlers, so grid and list wire them identically. */ +function useFileMenuHandlers( + props: FileGridProps, +): (file: StirlingFileStub) => FileMenuHandlers { + const { + selectedFileIds, + onRemoveFiles, + onPromptMoveFiles, + onSaveToServer, + onVersionHistory, + onDownloadFile, + onRenameFile, + onDuplicateFile, + saveToServerDisabledReason, + } = props; + return (file: StirlingFileStub) => ({ + onRemove: () => onRemoveFiles([file.id]), + // A move acts on the whole selection when this file is part of it. + onMove: () => + onPromptMoveFiles( + selectedFileIds.has(file.id) ? Array.from(selectedFileIds) : [file.id], + ), + onDownload: onDownloadFile ? () => onDownloadFile(file) : undefined, + onRename: onRenameFile ? () => onRenameFile(file) : undefined, + onDuplicate: onDuplicateFile ? () => onDuplicateFile(file) : undefined, + onSaveToServer: onSaveToServer ? () => onSaveToServer(file) : undefined, + onVersionHistory: onVersionHistory + ? () => onVersionHistory(file) + : undefined, + saveToServerDisabledReason, + }); +} + +/** Per-file kebab handlers, shared by the card and row wrappers. */ +interface FileMenuHandlers { + onRemove: () => void; + onMove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; + /** Open the version-history modal; shown only when file has >1 version. */ + onVersionHistory?: () => void; + /** When set, the kebab Save to server is disabled with this tooltip. */ + saveToServerDisabledReason?: string | null; +} + +interface FileCardProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -637,14 +843,6 @@ interface FileCardProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onDoubleClick: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileCard({ @@ -656,11 +854,7 @@ function FileCard({ multiSelectActive, onClick, onDoubleClick, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileCardProps) { const { t } = useTranslation(); const cardRef = useRef(null); @@ -784,126 +978,48 @@ function FileCard({ )}
{fileSize} - Ā· + {fileDate}
- - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onDoubleClick(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - data-testid="file-menu-move-to" - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - +
); } -function ListView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onSetSelection, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - sortMode, - onChangeSortMode, -}: FileGridProps & { - sortMode?: FilesPageSortMode; - onChangeSortMode?: (next: FilesPageSortMode) => void; -}) { +function ListView( + props: FileGridProps & { + sortMode?: FilesPageSortMode; + onChangeSortMode?: (next: FilesPageSortMode) => void; + }, +) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onSetSelection, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + sortMode, + onChangeSortMode, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); const { t } = useTranslation(); // Tri-state header checkbox state - computed from current entries. @@ -1029,22 +1145,7 @@ function ListView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onOpen={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -1241,7 +1342,7 @@ function FolderRow({ ); } -interface FileRowProps { +interface FileRowProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -1251,14 +1352,6 @@ interface FileRowProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onOpen: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileRow({ @@ -1270,11 +1363,7 @@ function FileRow({ multiSelectActive, onClick, onOpen, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileRowProps) { const { t } = useTranslation(); const kebabRef = useRef(null); @@ -1414,91 +1503,12 @@ function FileRow({ {fileSize} {fileDate} - - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onOpen(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - + ); diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index a0b0c6cfa1..36ffb9c4dc 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -33,6 +33,10 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; +import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu"; +import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount"; +import { FilesToolbarFilterMenu } from "@app/components/filesPage/FilesToolbarFilterMenu"; +import { FilesToolbarSortMenu } from "@app/components/filesPage/FilesToolbarSortMenu"; import { stripBasePath } from "@app/constants/app"; import { useAuth } from "@app/auth/UseSession"; @@ -71,6 +75,10 @@ import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog"; import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal"; +import { RenameFileDialog } from "@app/components/shared/RenameFileDialog"; +import { duplicateStoredFile } from "@app/utils/duplicateFile"; +import { downloadFileFromStorage } from "@app/utils/downloadUtils"; +import { fileStorage } from "@app/services/fileStorage"; import { materializeServerStubs } from "@app/services/fileSyncService"; import { FILES_PAGE_DRAG_TYPE, @@ -794,6 +802,92 @@ export default function FileManagerView() { [removeFiles], ); + // ─── per-file kebab: download / rename / duplicate ─────────────────────── + // Same actions the file sidebar's kebab offers, so both surfaces match. + + /** Cloud-only rows hold no bytes; pull them local before acting on them. */ + const localCopyOf = useCallback( + async (file: StirlingFileStub): Promise => { + const [materialized] = await materializeServerStubs([file], { + addFiles: fileActions.addFilesWithOptions, + updateStub: fileActions.updateStirlingFileStub, + }); + return materialized ?? null; + }, + [fileActions], + ); + + const handleDownloadFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + await downloadFileFromStorage(local); + } catch (err) { + console.error("[FilesPage] Download failed", err); + folders.setError( + t("filesPage.error.downloadFailed", "Could not download the file."), + ); + } + }, + [localCopyOf, folders, t], + ); + + const handleDuplicateFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + const copyId = await duplicateStoredFile( + local, + allFiles.map((f) => f.name), + addFiles, + ); + if (!copyId) { + throw new Error(`File "${local.name}" not found in storage`); + } + await refresh(); + } catch (err) { + console.error("[FilesPage] Duplicate failed", err); + folders.setError( + t("filesPage.error.duplicateFailed", "Could not duplicate the file."), + ); + } + }, + [localCopyOf, allFiles, addFiles, refresh, folders, t], + ); + + const [renameTarget, setRenameTarget] = useState( + null, + ); + + // The stub name is what the UI and exports read, so a rename is a metadata + // write; the workbench copy (if any) is updated in the same breath. + const handleConfirmRename = useCallback( + async (name: string) => { + const file = renameTarget; + if (!file) return; + const local = await localCopyOf(file); + if (!local) return; + // quickKey is name|size|lastModified; a stale one would make a re-upload + // of the original look like a duplicate of the renamed file. + const quickKey = `${name}|${local.size}|${local.lastModified}`; + const saved = await fileStorage.updateFileMetadata(local.id, { + name, + quickKey, + }); + if (!saved) { + throw new Error( + t("fileSidebar.rename.error", "Could not rename the file."), + ); + } + fileActions.updateStirlingFileStub(local.id, { name, quickKey }); + setRenameTarget(null); + await refresh(); + }, + [renameTarget, localCopyOf, fileActions, refresh, t], + ); + // ─── derived UI bits ──────────────────────────────────────────────────── const currentFolderRecord = currentFolderId ? (foldersById.get(currentFolderId) ?? null) @@ -803,6 +897,9 @@ export default function FileManagerView() { () => Array.from(selectedFileIds), [selectedFileIds], ); + // A phone with files selected shows a contextual selection bar instead of the + // full toolbar - five bulk buttons plus filters cannot fit the width. + const mobileSelection = isMobile && selectedFiles.length > 0; // Local-only subset of selection; drives Save-to-server visibility. const localOnlySelectedStubs = useMemo( @@ -1120,22 +1217,12 @@ export default function FileManagerView() { })()}
- - {loading - ? t("filesPage.loading", "Loading…") - : t("filesPage.summary", "{{count}} items", { - count: totalCount, - })} - {selectedFiles.length > 0 && ( - - {" "} - Ā·{" "} - {t("filesPage.selectedCount", "{{count}} selected", { - count: selectedFiles.length, - })} - - )} - + {(() => { // Select all / Clear toggle over visible files. if (visibleFiles.length === 0) return null; @@ -1175,289 +1262,382 @@ export default function FileManagerView() { ); })()}
- {selectedFiles.length > 0 && - (() => { - // Bulk-action labels; CSS collapses to icon-only below 900px. - const addLabel = + {mobileSelection ? ( + handleAddToWorkspace(selectedFiles)} + onSaveToServer={ + localOnlySelectedStubs.length > 0 + ? () => setSaveToServerTarget(localOnlySelectedStubs) + : undefined + } + saveToServerDisabledReason={ + saveToServerDisabledReason ?? undefined + } + onShowDetails={ selectedFiles.length === 1 - ? t("filesPage.addToWorkspace", "Add to workspace") - : t( - "filesPage.addToWorkspaceCount", - "Add {{count}} to workspace", - { count: selectedFiles.length }, - ); - const moveLabel = t("filesPage.moveTo", "Move to…"); - const removeLabel = t("filesPage.remove", "Remove"); - return ( - // wrap="nowrap" keeps the row single-line. - - - - - {/* Save to server; shown whenever local-only files are + ? () => setMobileDetailsOpen(true) + : undefined + } + onMove={() => promptMoveFiles(selectedFiles)} + onRemove={() => handleRemoveFiles(selectedFiles)} + /> + ) : ( + <> + {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {/* Save to server; shown whenever local-only files are selected. When storage is off it stays visible but disabled, tooltip pointing at the admin. */} - {localOnlySelectedStubs.length > 0 && ( - - + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + )} - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {/* Show details button on compact viewports. */} - {selectedFiles.length === 1 && - isCompactDetailsViewport && ( - + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + × + + + + ); + })()} + {selectedFiles.length > 0 && ( +
@@ -1503,6 +1683,9 @@ export default function FileManagerView() { onPromptMoveFiles={promptMoveFiles} onSaveToServer={(file) => setSaveToServerTarget([file])} onVersionHistory={(file) => setVersionHistoryFile(file)} + onDownloadFile={handleDownloadFile} + onRenameFile={setRenameTarget} + onDuplicateFile={handleDuplicateFile} saveToServerDisabledReason={saveToServerDisabledReason} // Center-of-grid CTAs when the empty state shows - same // handlers the corner header buttons use so behaviour @@ -1662,6 +1845,14 @@ export default function FileManagerView() { onConfirm={confirmRemoveFiles} /> + {/* Rename (opened from the card kebab). */} + setRenameTarget(null)} + onSubmit={handleConfirmRename} + /> + {/* Version journey in a modal (opened from the card kebab). */} span { + white-space: nowrap; + } + .files-page-card-meta-sep { + display: none; + } +} + /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { @@ -1295,30 +1323,38 @@ sits next to the Upload button without breaking the action row. */ display: none; } -@media (max-width: 900px) { +@media (max-width: 1024px) { .files-page-toolbar { /* nowrap so "7 items" + "Select all" sit on the same row as the filter dropdowns and view-toggle instead of stacking on three - separate lines. Per-child min-width:0 lets them shrink as needed. - Used to only kick in at ≤640px which left a broken zone where - both side panels were hidden but the toolbar was still wrapping - to multiple rows. */ + separate lines. Runs to the app's mobile breakpoint: capping it at + 900px left 901-1024px wrapping to two rows, which is the band the + mobile layout actually renders in. + + Scrolls rather than clips. With a selection active the bulk-action + strip cannot fit any phone width, and `overflow-x: hidden` put those + buttons permanently out of reach behind the edge. */ flex-wrap: nowrap; gap: 0.35rem; padding: 0.35rem 0.5rem; min-height: auto; - overflow-x: hidden; + overflow-x: auto; + scrollbar-width: none; + } + .files-page-toolbar::-webkit-scrollbar { + display: none; } .files-page-toolbar-info { - /* Was `flex-basis: 100%` which forced a row break. Let it share - the row, shrink hard if needed, and ellipsize so the count line - collapses gracefully (was overlapping the bulk-action buttons - at ~400px because no truncation rule existed). */ - flex: 0 1 auto; + /* The toolbar's only status text. Pinned, because against nowrap + siblings it lost every shrink round and rendered as "3 i". */ + flex-shrink: 0; min-width: 0; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + } + /* Filter and sort collapse to icon triggers here (see FilesToolbar*Menu); + they are the whole control, so they never shrink. */ + .files-page-toolbar-icon-btn { + flex-shrink: 0; } .files-page-toolbar-actions { flex-wrap: nowrap; @@ -1350,41 +1386,48 @@ navigation, so the in-header Home/Apps/Close trio is duplicated and the first to go. Same for "Upload" - the user can use the centre drop overlay. */ -@media (max-width: 640px) { - /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto - * its own row when chrome is too cramped to share. */ +/* ── Mobile + tablet chrome (≤1024px = useIsMobile) ────────────────── + The desktop header is a 3-column grid whose middle track can grow to + 40rem. Below ~1024px that track eats the row: the breadcrumb column + collapsed to ~36px (wrapping "All files" to two lines) and the action + column overflowed, pushing Upload off the right edge. One flex row + instead - breadcrumb and actions keep their intrinsic width and the + search takes whatever is left. Ends at the app's mobile breakpoint so + it matches the layout HomePage is already rendering. */ +@media (max-width: 1024px) { .files-page-header { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: center; gap: 0.4rem; - padding: 0 0.4rem; + padding: 0.25rem 0.4rem; overflow-x: hidden; } - .files-page-header [data-mobile-hide="true"] { - display: none !important; + .files-page-header-search { + flex: 1 1 auto; + min-width: 0; + justify-content: flex-start; } - .files-page-header [data-desktop-hide="true"] { - display: inline-flex !important; - } - /* Mobile-hide for sub-toolbar create buttons. */ - .files-page-toolbar [data-mobile-hide="true"] { - display: none !important; + /* Undo the fixed 24rem basis so the pill tracks the row's spare width. */ + .files-page-header-search .super-search { + flex: 1 1 auto; + width: 100%; + max-width: none; } .files-page-header-actions { + flex: 0 0 auto; margin-left: auto; - gap: 0.3rem; + gap: 0.25rem; flex-wrap: nowrap; } .files-page-breadcrumbs { + flex: 0 1 auto; font-size: 0.85rem; flex-wrap: nowrap; overflow-x: auto; min-width: 0; } - /* Upload becomes an icon-only square button on mobile so the action - row stops getting clipped. Scoped to `.files-page-header-actions` - so the Back button at the header level keeps its visible "Back" - label (Back has no other on-screen indicator that it's about leaving). */ + /* Icon-only actions: the labels are what pushed Upload past the edge. */ .files-page-header-actions .mantine-Button-root { padding-left: 0.55rem; padding-right: 0.55rem; @@ -1395,6 +1438,9 @@ .files-page-header-actions .mantine-Button-label { display: none; } +} + +@media (max-width: 640px) { /* Grid: single column on very narrow phones; two columns from ~440px */ .files-page-grid { grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx new file mode 100644 index 0000000000..3e344ac7b0 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx @@ -0,0 +1,104 @@ +import { Menu } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + +import { Button } from "@app/ui/Button"; + +interface FilesToolbarBulkMenuProps { + selectedCount: number; + onAddToWorkspace: () => void; + /** Local-only files in the selection; omit when there are none to upload. */ + onSaveToServer?: () => void; + /** Set when storage is off - the item stays listed but disabled. */ + saveToServerDisabledReason?: string; + onShowDetails?: () => void; + onMove: () => void; + onRemove: () => void; +} + +/** + * Bulk actions behind one trigger. The full strip is five buttons wide, which + * no phone can hold alongside the count and the clear control, so rather than + * letting the row scroll them off the edge they collapse into a menu where + * every action keeps its label. + */ +export function FilesToolbarBulkMenu({ + selectedCount, + onAddToWorkspace, + onSaveToServer, + saveToServerDisabledReason, + onShowDetails, + onMove, + onRemove, +}: FilesToolbarBulkMenuProps) { + const { t } = useTranslation(); + + const addLabel = + selectedCount === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t("filesPage.addToWorkspaceCount", "Add {{count}} to workspace", { + count: selectedCount, + }); + + return ( + + + + + + } + onClick={onAddToWorkspace} + > + {addLabel} + + {onSaveToServer && ( + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={onSaveToServer} + > + {t("filesPage.saveToServer", "Save to server")} + + )} + {onShowDetails && ( + } + onClick={onShowDetails} + > + {t("filesPage.showDetails", "Show details")} + + )} + } + onClick={onMove} + > + {t("filesPage.moveTo", "Move to…")} + + + } + onClick={onRemove} + > + {t("filesPage.remove", "Remove")} + + + + ); +} + +export default FilesToolbarBulkMenu; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx new file mode 100644 index 0000000000..d808638d63 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; + +interface FilesToolbarCountProps { + loading: boolean; + totalCount: number; + selectedCount: number; + /** + * Selection-bar mode: report only the selection. A phone spends the room on + * the actions rather than on "3 items Ā· 3 selected". + */ + selectionOnly: boolean; +} + +/** Status text at the head of the files toolbar. */ +export function FilesToolbarCount({ + loading, + totalCount, + selectedCount, + selectionOnly, +}: FilesToolbarCountProps) { + const { t } = useTranslation(); + + const selected = t("filesPage.selectedCount", "{{count}} selected", { + count: selectedCount, + }); + + if (selectionOnly) { + return {selected}; + } + + return ( + + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { count: totalCount })} + {selectedCount > 0 && Ā· {selected}} + + ); +} + +export default FilesToolbarCount; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx new file mode 100644 index 0000000000..81d84daf67 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx @@ -0,0 +1,149 @@ +import { MultiSelect, Popover, Select, Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "@mui/icons-material/Close"; +import SearchIcon from "@mui/icons-material/Search"; +import TuneIcon from "@mui/icons-material/Tune"; + +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { FilesPageOriginFilter } from "@app/contexts/FilesPageContext"; + +interface FilesToolbarFilterMenuProps { + originFilter: FilesPageOriginFilter; + onOriginChange: (value: FilesPageOriginFilter) => void; + availableTypes: string[]; + typeFilter: string[]; + onTypeChange: (value: string[]) => void; + search: string; + onSearchChange: (value: string) => void; +} + +/** + * Source, type and name filters collapsed behind one icon. Side by side these + * three need ~480px, so on narrow viewports they were each truncated to + * unreadable stubs ("All sour"). In the popover they get their full width back, + * and a dot on the trigger keeps an active filter discoverable while hidden. + */ +export function FilesToolbarFilterMenu({ + originFilter, + onOriginChange, + availableTypes, + typeFilter, + onTypeChange, + search, + onSearchChange, +}: FilesToolbarFilterMenuProps) { + const { t } = useTranslation(); + + const activeCount = + (originFilter !== "all" ? 1 : 0) + + (typeFilter.length > 0 ? 1 : 0) + + (search.trim() !== "" ? 1 : 0); + const label = t("filesPage.filters.label", "Filters"); + + const clearAll = () => { + onOriginChange("all"); + onTypeChange([]); + onSearchChange(""); + }; + + return ( + + +
+ 0 + ? t( + "filesPage.filters.activeCount", + "{{count}} filters active", + { + count: activeCount, + }, + ) + : label + } + position="bottom" + > + 0 ? "primary" : "tertiary"} + size="sm" + aria-label={label} + className="files-page-toolbar-icon-btn" + > + + + +
+
+ + + onSearchChange(e.currentTarget.value)} + placeholder={t("filesPage.search.placeholder", "Filter files…")} + leftSection={} + rightSection={ + search ? ( + onSearchChange("")} + aria-label={t("filesPage.search.clear", "Clear filter")} + > + + + ) : null + } + aria-label={t("filesPage.search.label", "Filter files by name")} + /> + update({ profile: value || "ua1" })} + data={profileOptions} + disabled={disabled} + comboboxProps={{ zIndex: Z_INDEX_AUTOMATE_DROPDOWN }} + data-testid="pdfua-profile-select" + /> + + + update({ language: event.currentTarget.value })} + disabled={disabled} + data-testid="pdfua-language-input" + /> + + + update({ overrideLanguage: event.currentTarget.checked }) + } + disabled={disabled} + data-testid="pdfua-override-language" + /> + + update({ title: event.currentTarget.value })} + disabled={disabled} + data-testid="pdfua-title-input" + /> + + + update({ embedFonts: event.currentTarget.checked }) + } + disabled={disabled} + data-testid="pdfua-embed-fonts" + /> + + + + {t( + "convert.pdfUaAltTextNotice", + "Images need a written description before a document can be certified. Descriptions are never generated automatically, because an invented one passes the checker while telling a screen-reader user nothing. Any image left without one is reported, and the file comes back tagged but not certified.", + )} + + + + {tooManyFiles && ( + + + {t( + "convert.pdfUaAltTextSingleFileOnly", + "Descriptions belong to one document: an image is identified by its position, which is a different image in every file. Convert these {{fileCount}} files to tag them, then convert one at a time to describe its images.", + { fileCount: selectedFiles.length }, + )} + + + )} + + {!tooManyFiles && ( + + )} + + {scanError && ( + + {scanError} + + )} + + {!tooManyFiles && figures?.length === 0 && ( + + {t( + "convert.pdfUaNoImagesNeedingText", + "No image is missing a description.", + )} + + )} + + {!tooManyFiles && + figures?.map((figure) => ( + + update({ + altText: formatAltText({ + ...descriptions, + [figure.key]: event.currentTarget.value, + }), + }) + } + disabled={disabled} + data-testid={`pdfua-alt-text-${figure.key}`} + /> + ))} + + ); +}; + +export default ConvertToPdfUaSettings; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx index 298fcc847b..fa035eb067 100644 --- a/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx @@ -3,6 +3,7 @@ import { Stack, Text, Select, Alert, Checkbox } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters"; import { usePdfSignatureDetection } from "@app/hooks/usePdfSignatureDetection"; +import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { StirlingFile } from "@app/types/fileContext"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; @@ -26,11 +27,21 @@ const ConvertToPdfaSettings = ({ const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles); const outputFormatLabelId = useId(); + // Level A needs the same tagger as PDF/UA, so it stands or falls with that endpoint. + const { enabled: taggingAvailable } = useEndpointEnabled("pdf-to-ua"); const pdfaFormatOptions = [ { value: "pdfa-1", label: "PDF/A-1b" }, { value: "pdfa-2b", label: "PDF/A-2b" }, { value: "pdfa-3b", label: "PDF/A-3b" }, + // Level A is level B plus accessibility: it additionally tags the document. + ...(taggingAvailable === false + ? [] + : [ + { value: "pdfa-1a", label: "PDF/A-1a (accessible)" }, + { value: "pdfa-2a", label: "PDF/A-2a (accessible)" }, + { value: "pdfa-3a", label: "PDF/A-3a (accessible)" }, + ]), ]; return ( diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx index 2648990248..e7246bb4e3 100644 --- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx +++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx @@ -1,3 +1,4 @@ +import { useContext, useRef } from "react"; import { Stack, Text, @@ -7,6 +8,7 @@ import { Divider, } from "@mantine/core"; import { Button } from "@app/ui/Button"; +import { FilePicker } from "@app/ui/FilePicker"; import { ActionIcon } from "@app/ui/ActionIcon"; import { SegmentedControl } from "@app/ui/SegmentedControl"; import { useTranslation } from "react-i18next"; @@ -15,7 +17,7 @@ import { type OverlayMode, } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { FilesModalContext } from "@app/contexts/FilesModalContext"; import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; @@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({ disabled = false, }: OverlayPdfsSettingsProps) { const { t } = useTranslation(); - const { openFilesModal } = useFilesModalContext(); + // Read optionally: the portal pipeline builder mounts no FilesModalProvider. + // Present (editor tool + Automate modal) -> keep the workspace file picker; + // absent (portal) -> fall back to the plain file input below. + const filesModal = useContext(FilesModalContext); + // Clears the FilePicker so the same file can be re-selected (Mantine resetRef). + const resetOverlayPicker = useRef<() => void>(null); const handleOverlayFilesChange = (files: File[]) => { onParameterChange("overlayFiles", files); @@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({ }; const handleOpenOverlayFilesModal = () => { - if (disabled) return; - openFilesModal({ + if (disabled || !filesModal) return; + filesModal.openFilesModal({ customHandler: (files: File[]) => { handleOverlayFilesChange([ ...(parameters.overlayFiles || []), @@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({ }); }; + const appendOverlayFiles = (files: File[]) => { + if (files.length === 0) return; + handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]); + resetOverlayPicker.current?.(); + }; + + const overlayFilesButtonLabel = + parameters.overlayFiles?.length > 0 + ? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...") + : t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)..."); + return ( @@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({ {t("overlay-pdfs.overlayFiles.label", "Overlay Files")} - + {filesModal ? ( + + ) : ( + } + fullWidth + > + {overlayFilesButtonLabel} + + )} {parameters.overlayFiles?.length > 0 && (() => { diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index 9a590587ee..9c92730077 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -17,6 +17,8 @@ import { useFileContext } from "@app/contexts/FileContext"; import { isStirlingFile, type FileId } from "@app/types/fileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import apiClient from "@app/services/apiClient"; +import { openExternalTab } from "@app/platform/openExternalTab"; +import { getExternalHref } from "@app/utils/externalUrl"; import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models"; import { useTranslation } from "react-i18next"; import BookmarksIcon from "@mui/icons-material/BookmarksRounded"; @@ -74,6 +76,17 @@ const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => { return null; }; +// Bookmark targets are PDF-supplied, so sanitise before opening. Local paths +// from LaunchAppOrOpenFile fail the allowlist - a browser blocks them anyway. +const openBookmarkTarget = (rawUrl: string): void => { + const href = getExternalHref(rawUrl); + if (!href) { + console.warn("[BookmarkSidebar] Blocked unsafe URL:", rawUrl); + return; + } + void openExternalTab(href); +}; + export const BookmarkSidebar = ({ visible, thumbnailVisible, @@ -515,12 +528,12 @@ export const BookmarkSidebar = ({ const action = target.action; if (action.type === PdfActionType.URI && action.uri) { event.preventDefault(); - window.open(action.uri, "_blank", "noopener"); + openBookmarkTarget(action.uri); return; } if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) { event.preventDefault(); - window.open(action.path, "_blank", "noopener"); + openBookmarkTarget(action.path); return; } } diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 187a48ad16..c0f9d4b4c4 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -13,6 +13,7 @@ import { useFileActions, } from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; +import { ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar"; @@ -42,6 +43,7 @@ import { import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; +import { FORM_APPLY_EVENT } from "@app/tools/formFill/formFillEvents"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; import { useMeasurementManager } from "@app/hooks/useMeasurementManager"; import { ScaleCalibrationDialog } from "@app/components/viewer/ScaleCalibrationDialog"; @@ -418,7 +420,7 @@ const EmbedPdfViewerContent = ({ return; case "0": event.preventDefault(); - zoomActions.requestZoom("fit-width"); + zoomActions.requestZoom(ZoomMode.FitWidth); return; } } @@ -781,8 +783,8 @@ const EmbedPdfViewerContent = ({ handleFormApply(blob); } }; - window.addEventListener("formfill:apply", handler); - return () => window.removeEventListener("formfill:apply", handler); + window.addEventListener(FORM_APPLY_EVENT, handler); + return () => window.removeEventListener(FORM_APPLY_EVENT, handler); }, [handleFormApply]); // Apply layer visibility changes - reload the modified PDF into the viewer @@ -1236,6 +1238,7 @@ const EmbedPdfViewerContent = ({ showBakedAnnotations={isAnnotationsVisible} enableRedaction={shouldEnableRedaction} enableFormFill={shouldEnableFormFill} + formEditingActive={isFormFillToolActive} isManualRedactionMode={isManualRedactMode} signatureApiRef={signatureApiRef as React.RefObject} annotationApiRef={annotationApiRef as React.RefObject} diff --git a/frontend/editor/src/core/components/viewer/LinkLayer.tsx b/frontend/editor/src/core/components/viewer/LinkLayer.tsx index e1a2aeb8f0..e68c541fd8 100644 --- a/frontend/editor/src/core/components/viewer/LinkLayer.tsx +++ b/frontend/editor/src/core/components/viewer/LinkLayer.tsx @@ -19,6 +19,9 @@ import { import { Z_INDEX_VIEWER_FLOATING_MENU } from "@app/styles/zIndex"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; +import { openExternalTab } from "@app/platform/openExternalTab"; +import { getExternalHref } from "@app/utils/externalUrl"; + // --------------------------------------------------------------------------- // Inline SVG icons (thin-stroke, modern) // --------------------------------------------------------------------------- @@ -401,19 +404,11 @@ export const LinkLayer: React.FC = ({ behavior: "smooth", }); } else if (action.type === PdfActionType.URI) { - const uri = action.uri; - try { - const url = new URL(uri, window.location.href); - if (["http:", "https:", "mailto:"].includes(url.protocol)) { - window.open(uri, "_blank", "noopener,noreferrer"); - } else { - console.warn( - "[LinkLayer] Blocked unsafe URL protocol:", - url.protocol, - ); - } - } catch { - window.open(uri, "_blank", "noopener,noreferrer"); + const href = getExternalHref(action.uri); + if (href) { + void openExternalTab(href); + } else { + console.warn("[LinkLayer] Blocked unsafe URL:", action.uri); } } } @@ -513,6 +508,11 @@ export const LinkLayer: React.FC = ({ const top = annotationLink.rect.origin.y * scale; const width = annotationLink.rect.size.width * scale; const height = annotationLink.rect.size.height * scale; + const externalHref = + annotationLink.target?.type === "action" && + annotationLink.target.action.type === PdfActionType.URI + ? getExternalHref(annotationLink.target.action.uri) + : null; return ( = ({ linkElementRefs.current.delete(annotationLink.id); } }} - href="#" + href={externalHref ?? "#"} + target={externalHref ? "_blank" : undefined} + rel={externalHref ? "noopener noreferrer" : undefined} onClick={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index e1084e2772..07fe867ed2 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -101,6 +101,9 @@ import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrappe import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext"; import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler"; import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay"; +import { FormCreationInteractionLock } from "@app/tools/formFill/FormCreationInteractionLock"; +import { FormFieldCreationOverlay } from "@app/tools/formFill/FormFieldCreationOverlay"; +import { FormFieldEditOverlay } from "@app/tools/formFill/FormFieldEditOverlay"; import { ButtonAppearanceOverlay } from "@app/tools/formFill/ButtonAppearanceOverlay"; import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay"; import { CommentsSidebar } from "@app/components/viewer/CommentsSidebar"; @@ -114,6 +117,8 @@ interface LocalEmbedPDFProps { enableAnnotations?: boolean; enableRedaction?: boolean; enableFormFill?: boolean; + /** Structural create/modify overlays only mount while the Form tool owns the viewer. */ + formEditingActive?: boolean; isManualRedactionMode?: boolean; showBakedAnnotations?: boolean; onSignatureAdded?: (annotation: PdfAnnotationObject) => void; @@ -207,6 +212,7 @@ export function LocalEmbedPDF({ enableAnnotations = false, enableRedaction = false, enableFormFill = false, + formEditingActive = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, @@ -1006,6 +1012,7 @@ export function LocalEmbedPDF({ + @@ -1153,6 +1160,28 @@ export function LocalEmbedPDF({ /> )} + {/* Create-mode: drag to place new fields */} + {enableFormFill && formEditingActive && ( + + )} + + {/* Modify-mode: select / move / resize existing fields */} + {enableFormFill && formEditingActive && ( + + )} + {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} {file && ( ({ copyToClipboard: () => selection.copyToClipboard(), - getSelectedText: () => selection.getSelectedText(), getFormattedSelection: () => selection.getFormattedSelection(), selectAll: async (totalPages: number) => { const docId = activeDocumentId; diff --git a/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx index afd04bf6b2..7f914741f3 100644 --- a/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx +++ b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx @@ -11,6 +11,7 @@ * For widgets without an appearance stream (unsigned fields, or fields whose * PDF writer didn't embed one), we fall back to a translucent badge overlay. */ +import { useStaleBakedFieldNames } from "@app/tools/formFill/FormFillContext"; import React, { useEffect, useMemo, useRef, useState, memo } from "react"; import { renderSignatureFieldAppearances, @@ -114,6 +115,7 @@ function SignatureFieldOverlayInner({ pageWidth, pageHeight, }: SignatureFieldOverlayProps) { + const staleNames = useStaleBakedFieldNames(); const [fields, setFields] = useState([]); useEffect(() => { @@ -135,8 +137,13 @@ function SignatureFieldOverlayInner({ }, [pdfSource]); const pageFields = useMemo( - () => fields.filter((f) => f.pageIndex === pageIndex), - [fields, pageIndex], + // A staged move or delete leaves this bitmap stranded at the original rect, on top of the + // editor chrome, so it is dropped until the edit is applied and the appearance re-extracted. + () => + fields.filter( + (f) => f.pageIndex === pageIndex && !staleNames.has(f.fieldName), + ), + [fields, pageIndex, staleNames], ); if (pageFields.length === 0) return null; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx index ee9016b926..329e0cdfd8 100644 --- a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx +++ b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from "react"; -import { Box, Paper, Text } from "@mantine/core"; +import { Box, Center, Group, Paper, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { formatFileSize } from "@app/utils/fileUtils"; interface HtmlViewerProps { @@ -10,37 +12,79 @@ interface HtmlViewerProps { export function HtmlViewer({ file }: HtmlViewerProps) { const { t } = useTranslation(); + const isMobile = useIsMobile(); const [objectUrl, setObjectUrl] = useState(null); + // Phones render a desktop-width document into ~400px, which reads as a blank + // column, so the iframe is opt-in there. Derived rather than seeded into + // state because useIsMobile resolves after first paint. + const [optedIn, setOptedIn] = useState(false); + const showPreview = !isMobile || optedIn; useEffect(() => { + if (!showPreview) return; const url = URL.createObjectURL(file); setObjectUrl(url); return () => URL.revokeObjectURL(url); - }, [file]); + }, [file, showPreview]); return ( - - {t("viewer.nonPdf.htmlPreviewWarning", { - size: formatFileSize(file.size), - })} - + + + {t("viewer.nonPdf.htmlPreviewWarning", { + size: formatFileSize(file.size), + })} + + {/* Opting in used to be one-way: the only way back was closing and + reopening the file. */} + {isMobile && optedIn && ( + + )} + - {objectUrl && ( -