diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index e6e4f08230..b5cc0527b0 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -1,7 +1,7 @@ -# 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/config/.files.yaml @@ -24,9 +24,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 +68,6 @@ project: &project frontend: &frontend - *ci - frontend/** - - .github/workflows/testdriver.yml - testing/** - docker/** - scripts/translations/*.py @@ -88,8 +87,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 +101,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 +113,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 +140,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 21a47ac6c0..92de1d9503 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,8 @@ updates: - "/app/common" - "/app/core" - "/app/proprietary" + - "/app/saas" + - "/buildSrc" schedule: interval: "weekly" cooldown: diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 50be9fe4cf..4375b1b8b0 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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,6 +554,9 @@ 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: | diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 0c4bfda91a..410aa82dc9 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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..098f8d7803 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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..d65831c5df 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 056dc35ca3..015934030f 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit 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..af0dc85aa6 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index 6039c0e7df..8e6d974f3c 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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 4c87d93e40..596be96e8c 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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() diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index dfcef8153e..194d86d9da 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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: @@ -309,6 +327,9 @@ jobs: # 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,7 +345,7 @@ 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87d414b462..1feaff2560 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,53 +48,75 @@ 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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: + environment: + name: ci-unsigned + deployment: false 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 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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 + + - 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: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + 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 - run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon + 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 }} + build: needs: [files-changed, gradle-cache-prime] permissions: @@ -134,9 +156,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,6 +191,8 @@ 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' @@ -191,7 +216,14 @@ jobs: test-build-docker-images: if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' - needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime] + needs: + [ + files-changed, + build, + check-generateOpenApiDocs, + check-licence, + gradle-cache-prime, + ] permissions: contents: read packages: read @@ -203,7 +235,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 +249,7 @@ jobs: with: platform: windows-macos sign: true + use_shared_cache: true ai-engine: if: needs.files-changed.outputs.engine == 'true' @@ -240,6 +273,8 @@ jobs: pull-requests: write uses: ./.github/workflows/check-generated-models.yml secrets: inherit + with: + use_shared_cache: true pre-commit: needs: [files-changed] @@ -290,6 +325,7 @@ jobs: - db-migration-test - check-generateOpenApiDocs - frontend-validation + - frontend-a11y - playwright-e2e - playwright-e2e-live - playwright-e2e-enterprise @@ -304,7 +340,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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -316,6 +352,7 @@ jobs: 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 cebf98496a..fafffcc241 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -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', '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 af94d09316..4e04a83656 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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 19a047ff01..bc9b302857 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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..1681546e71 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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); @@ -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 69b5f90e2d..61ef8793c4 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -34,29 +34,26 @@ jobs: timeout-minutes: 15 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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 with: diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 0c9500795a..785073944e 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 07f1f8ce1a..b27596ddfb 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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 861bb2ca66..439d4240b2 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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 diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 9a9a0829b7..844d26a3d1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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: diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index df3c5fa82a..d02005936c 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -27,7 +27,7 @@ jobs: project: stubbed-webkit steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index e9f259f2ab..8f97120d96 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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 23999d038a..9aef2316d2 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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..133d940b28 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 571479b030..1a8bdd112d 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 10c0542c8c..0ac94ffe68 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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: | @@ -731,7 +704,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -902,11 +875,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..65c25b7b66 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "25" + distribution: "temurin" + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + 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..777c8d6ed4 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index a44d4f7b28..c642b6cf3d 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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..db67ccefe6 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -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..97c227f23c 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 7f10d99b27..ea379cf7c6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -20,10 +20,8 @@ on: 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 +40,7 @@ permissions: jobs: push: + environment: docker-publish if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-24.04-8core permissions: @@ -53,29 +52,26 @@ jobs: RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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 +87,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 +129,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 +154,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 +178,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 +200,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 +222,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 +244,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 +256,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 +315,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" diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 27141eff31..3e49f727c6 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 2eab52b1d4..f0129277de 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ab9078831a..af255de3cf 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index ead800f73f..1bfc94be5b 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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..636cd5f2e1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 1888c08ca7..124c992c71 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -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 d90c5f7c68..0a82647690 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 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', '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: @@ -697,7 +692,7 @@ jobs: pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -789,7 +784,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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 0d546381d8..37cb7cb546 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -37,6 +37,9 @@ jobs: # spring-security=true matrix entry if `task backend:build` and # `task backend:build:ci` produce equivalent JARs (verify before wiring). test-build-docker-images: + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest strategy: fail-fast: false @@ -53,7 +56,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@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -78,23 +81,20 @@ jobs: docker system prune -af || true echo "Disk space after cleanup:" && df -h + - 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', '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 @@ -191,7 +191,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml deleted file mode 100644 index 458abacd25..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', '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/.gitignore b/.gitignore index a064973a3b..4b34350f67 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 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/.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..f5325c9ed7 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)" 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/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/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/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index a8bc2812b7..216e0c1e66 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -1316,7 +1316,7 @@ public class ApplicationProperties { public static class Ui { private String appNameNavbar; private List languages; - private String logoStyle = "classic"; // Options: "classic" (default) or "modern" + private String logoStyle = "modern"; // Options: "modern" (default) or "classic" private boolean defaultHideUnavailableTools = false; private boolean defaultHideUnavailableConversions = false; private HideDisabledTools hideDisabledTools = new HideDisabledTools(); @@ -1327,10 +1327,10 @@ public class ApplicationProperties { public String getLogoStyle() { // Validate and return either "modern" or "classic" - if ("modern".equalsIgnoreCase(logoStyle)) { - return "modern"; + if ("classic".equalsIgnoreCase(logoStyle)) { + return "classic"; } - return "classic"; // default + return "modern"; // default } @Data 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/CustomPDFDocumentFactory.java b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java index 178052ebf6..9a7caefc7d 100644 --- a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java +++ b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java @@ -729,4 +729,32 @@ public class CustomPDFDocumentFactory { p.toFile().deleteOnExit(); return p; } + + /** A custom RandomAccessRead implementation that deletes the file when closed */ + private static class DeletingRandomAccessFile extends RandomAccessReadBufferedFile { + private final Path tempFilePath; + + public DeletingRandomAccessFile(File file) throws IOException { + super(file); + this.tempFilePath = file.toPath(); + } + + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + try { + boolean deleted = Files.deleteIfExists(tempFilePath); + if (deleted) { + log.info("Successfully deleted temp file: {}", tempFilePath); + } else { + log.warn("Failed to delete temp file (may not exist): {}", tempFilePath); + } + } catch (IOException e) { + log.error("Error deleting temp file: {}", tempFilePath, e); + } + } + } + } } 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/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/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/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/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index c280264838..7a186235cd 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -1,9 +1,14 @@ package stirling.software.SPDF.controller.api.security; +import java.awt.Color; import java.io.IOException; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPageTree; @@ -43,6 +48,10 @@ import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.propertyeditor.JsonListPropertyEditor; import stirling.software.common.util.propertyeditor.JsonObjectPropertyEditor; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.redact.PdfRedactor; +import stirling.software.jpdfium.redact.RedactOptions; +import stirling.software.jpdfium.redact.RedactResult; import tools.jackson.core.type.TypeReference; @@ -140,134 +149,138 @@ public class RedactController { + " patterns. Users can provide text patterns to redact, with options for regex" + " and whole word matching.") public ResponseEntity redactPdf(@ModelAttribute RedactPdfRequest request) { - String rawListOfText = request.getListOfText(); - boolean useRegex = Boolean.TRUE.equals(request.getUseRegex()); - boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch()); + if (request.getFileInput() == null || request.getFileInput().isEmpty()) { + log.error("File input is null or empty"); + throw ExceptionUtils.createFileNullOrEmptyException(); + } + String rawListOfText = request.getListOfText(); if (rawListOfText == null || rawListOfText.trim().isEmpty()) { throw ExceptionUtils.createIllegalArgumentException( "error.redaction.no.patterns", "No text patterns provided for redaction"); } - String[] listOfText = rawListOfText.split("\n"); - if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) { + List terms = + Arrays.stream(rawListOfText.split("\n")) + .map(String::trim) + .filter(s -> !s.isEmpty() && s.length() <= 4096) + .collect(Collectors.toList()); + + if (terms.isEmpty()) { throw ExceptionUtils.createIllegalArgumentException( "error.redaction.no.patterns", "No text patterns provided for redaction"); } - PDDocument document = null; - PDDocument fallbackDocument = null; + boolean useRegex = Boolean.TRUE.equals(request.getUseRegex()); + boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch()); - try { - if (request.getFileInput() == null) { - log.error("File input is null"); - throw ExceptionUtils.createFileNullOrEmptyException(); + if (useRegex) { + for (String term : terms) { + try { + Pattern.compile(term); + } catch (PatternSyntaxException e) { + throw ExceptionUtils.createIllegalArgumentException( + "error.redaction.no.patterns", "Invalid regex pattern: " + term); + } } + } - document = pdfDocumentFactory.load(request.getFileInput()); + String filename = + removeFileExtension( + Objects.requireNonNull( + Filenames.toSimpleFileName( + request.getFileInput().getOriginalFilename()))) + + "_redacted.pdf"; + Color redactColor = ManualRedactionService.decodeOrDefault(request.getRedactColor()); + int boxColorInt = redactColor.getRGB(); + + try (PDDocument document = pdfDocumentFactory.load(request.getFileInput())) { if (document == null) { log.error("Failed to load PDF document"); throw ExceptionUtils.createPdfCorruptedException( "during redaction", new IOException("Failed to load PDF document")); } - Map> allFoundTextsByPage = - textRedactionService.findTextToRedact( - document, listOfText, useRegex, wholeWordSearchBool); + try (TempFile tempInput = tempFileManager.createManagedTempFile(".pdf")) { + try { + request.getFileInput().transferTo(tempInput.getFile()); + } catch (Exception e) { + document.save(tempInput.getFile()); + } - int totalMatches = allFoundTextsByPage.values().stream().mapToInt(List::size).sum(); - log.info( - "Redaction scan: {} occurrences across {} pages (patterns={}, regex={}, wholeWord={})", - totalMatches, - allFoundTextsByPage.size(), - listOfText.length, - useRegex, - wholeWordSearchBool); + RedactOptions options = + RedactOptions.builder() + .addWords(terms) + .useRegex(useRegex) + .wholeWord(wholeWordSearchBool) + .boxColor(boxColorInt) + .padding(request.getCustomPadding()) + .removeContent(true) + .convertToImage(Boolean.TRUE.equals(request.getConvertPDFToImage())) + .normalizeFonts(false) + .fixToUnicode(false) + .glyphAware(true) + .redactMetadata(true) + .build(); - String filename = - removeFileExtension( - Objects.requireNonNull( - Filenames.toSimpleFileName( - request.getFileInput().getOriginalFilename()))) - + "_redacted.pdf"; + TempFile tempOutput = tempFileManager.createManagedTempFile(".pdf"); + try { + try (PdfDocument checkDoc = PdfDocument.open(tempInput.getFile().toPath())) { + if (checkDoc.pageCount() <= 0) { + throw new IOException("Invalid or empty PDF document"); + } + } - if (allFoundTextsByPage.isEmpty()) { - log.info("No text found matching redaction patterns"); - return WebResponseUtils.pdfDocToWebResponse(document, filename, tempFileManager); + log.debug( + "Calling JPDFium PdfRedactor.redact in RedactController (terms={})", + terms); + RedactResult result = PdfRedactor.redact(tempInput.getFile().toPath(), options); + log.debug( + "JPDFium auto-redact complete (matches={})", + result != null ? result.totalMatches() : -1); + if (result == null) { + throw new IOException("JPDFium auto-redact returned null result"); + } + try { + result.save(tempOutput.getFile().toPath()); + log.info( + "JPDFium auto-redact: {} matches processed into {}", + result.totalMatches(), + filename); + return WebResponseUtils.pdfFileToWebResponse(tempOutput, filename); + } finally { + if (result.document() != null) { + result.document().close(); + } + } + } catch (Exception e) { + tempOutput.close(); + log.warn( + "JPDFium native redaction fell back to manual redaction service: {}", + e.getMessage()); + Map> foundTexts = + textRedactionService.findTextToRedact( + document, + terms.toArray(new String[0]), + useRegex, + wholeWordSearchBool); + TempFile finalized = + manualRedactionService.finalizeRedaction( + document, + foundTexts, + request.getRedactColor(), + request.getCustomPadding(), + request.getConvertPDFToImage(), + false); + return WebResponseUtils.pdfFileToWebResponse(finalized, filename); + } } - - boolean fallbackToBoxOnlyMode; - try { - fallbackToBoxOnlyMode = - textRedactionService.performTextReplacement( - document, - allFoundTextsByPage, - listOfText, - useRegex, - wholeWordSearchBool); - } catch (Exception e) { - log.warn( - "Text replacement redaction failed, falling back to box-only mode: {}", - e.getMessage()); - fallbackToBoxOnlyMode = true; - } - - if (fallbackToBoxOnlyMode) { - log.warn( - "Font compatibility issues detected. Using box-only redaction mode for better reliability."); - - fallbackDocument = pdfDocumentFactory.load(request.getFileInput()); - - allFoundTextsByPage = - textRedactionService.findTextToRedact( - fallbackDocument, listOfText, useRegex, wholeWordSearchBool); - - TempFile finalized = - manualRedactionService.finalizeRedaction( - fallbackDocument, - allFoundTextsByPage, - request.getRedactColor(), - request.getCustomPadding(), - request.getConvertPDFToImage(), - false); - - return WebResponseUtils.pdfFileToWebResponse(finalized, filename); - } - - TempFile finalized = - manualRedactionService.finalizeRedaction( - document, - allFoundTextsByPage, - request.getRedactColor(), - request.getCustomPadding(), - request.getConvertPDFToImage(), - true); - - return WebResponseUtils.pdfFileToWebResponse(finalized, filename); - + } catch (IllegalArgumentException e) { + throw e; } catch (Exception e) { log.error("Redaction operation failed: {}", e.getMessage(), e); throw new RuntimeException("Failed to perform PDF redaction: " + e.getMessage(), e); - - } finally { - if (document != null) { - try { - if (fallbackDocument == null) { - document.close(); - } - } catch (IOException e) { - log.warn("Failed to close main document: {}", e.getMessage()); - } - } - - if (fallbackDocument != null) { - try { - fallbackDocument.close(); - } catch (IOException e) { - log.warn("Failed to close fallback document: {}", e.getMessage()); - } - } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java index a9633ffa39..9cf4e6c700 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java @@ -1,33 +1,20 @@ package stirling.software.SPDF.controller.api.security; +import java.io.File; import java.io.IOException; -import java.util.ArrayList; +import java.nio.file.Files; import java.util.Arrays; -import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.apache.pdfbox.contentstream.operator.Operator; -import org.apache.pdfbox.cos.COSArray; -import org.apache.pdfbox.cos.COSBase; -import org.apache.pdfbox.cos.COSFloat; -import org.apache.pdfbox.cos.COSName; -import org.apache.pdfbox.cos.COSNumber; -import org.apache.pdfbox.cos.COSString; -import org.apache.pdfbox.pdfparser.PDFStreamParser; -import org.apache.pdfbox.pdfwriter.ContentStreamWriter; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.multipdf.PDFMergerUtility; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDResources; -import org.apache.pdfbox.pdmodel.common.PDStream; import org.apache.pdfbox.pdmodel.font.PDFont; -import org.apache.pdfbox.pdmodel.graphics.PDXObject; -import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.springframework.stereotype.Service; import lombok.AllArgsConstructor; @@ -35,31 +22,23 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.PDFText; -import stirling.software.SPDF.utils.text.TextEncodingHelper; import stirling.software.SPDF.utils.text.TextFinderUtils; -import stirling.software.SPDF.utils.text.WidthCalculator; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.redact.PdfRedactor; +import stirling.software.jpdfium.redact.RedactOptions; +import stirling.software.jpdfium.redact.RedactResult; @Service @Slf4j class TextRedactionService { - private static final int MAX_XOBJECT_DEPTH = 10; - private static final float PRECISION_THRESHOLD = 1e-3f; - private static final int FONT_SCALE_FACTOR = 1000; - private static final Set TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\""); - private static final COSString EMPTY_COS_STRING = new COSString(""); - - // ----------------------------------------------------------------------- - // Public API - // ----------------------------------------------------------------------- - Map> findTextToRedact( PDDocument document, String[] listOfText, boolean useRegex, boolean wholeWordSearch) { Set terms = Arrays.stream(listOfText) .map(String::trim) - .filter(s -> !s.isEmpty()) + .filter(s -> !s.isEmpty() && s.length() <= 4096) .collect(Collectors.toSet()); if (terms.isEmpty()) { @@ -98,1086 +77,99 @@ class TextRedactionService { String[] listOfText, boolean useRegex, boolean wholeWordSearchBool) { - if (allFoundTextsByPage.isEmpty()) { + if (allFoundTextsByPage == null || allFoundTextsByPage.isEmpty()) { return false; } - if (detectCustomEncodingFonts(document)) { + List terms = + Arrays.stream(listOfText) + .map(String::trim) + .filter(s -> !s.isEmpty() && s.length() <= 4096) + .toList(); + + if (terms.isEmpty()) { + return false; + } + + File tempIn = null; + File tempOut = null; + try { + tempIn = File.createTempFile("jpdfium_redact_in_", ".pdf"); + tempOut = File.createTempFile("jpdfium_redact_out_", ".pdf"); + + document.save(tempIn); + + RedactOptions options = + RedactOptions.builder() + .addWords(terms) + .useRegex(useRegex) + .wholeWord(wholeWordSearchBool) + .boxColor(0) + .removeContent(true) + .normalizeFonts(false) + .fixToUnicode(false) + .repairWidths(false) + .glyphAware(true) + .build(); + + try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) { + if (checkDoc.pageCount() <= 0) { + return true; + } + } + + log.debug("Calling JPDFium PdfRedactor.redact (terms={})", terms); + RedactResult result = PdfRedactor.redact(tempIn.toPath(), options); + log.debug( + "JPDFium PdfRedactor.redact complete (matches={})", + result != null ? result.totalMatches() : -1); + if (result == null) { + log.warn( + "JPDFium PdfRedactor.redact returned null result, falling back to box-only redaction mode"); + return true; + } + + try { + result.save(tempOut.toPath()); + } finally { + if (result.document() != null) { + result.document().close(); + } + } + + try (PDDocument redactedDoc = Loader.loadPDF(tempOut)) { + while (document.getNumberOfPages() > 0) { + document.removePage(0); + } + PDFMergerUtility merger = new PDFMergerUtility(); + merger.appendDocument(document, redactedDoc); + } + + log.info("JPDFium text replacement complete: {} total matches", result.totalMatches()); + return false; + } catch (Exception e) { log.warn( - "Custom encoded fonts detected (non-standard encodings / DictionaryEncoding / damaged fonts). " - + "Text replacement is unreliable for these fonts. Falling back to box-only redaction mode."); - return true; - } - - try { - Set allSearchTerms = - Arrays.stream(listOfText) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - - int pageCount = 0; - for (PDPage page : document.getPages()) { - pageCount++; - List filteredTokens = - createTokensWithoutTargetText( - document, page, allSearchTerms, useRegex, wholeWordSearchBool); - writeFilteredContentStream(document, page, filteredTokens); - } - log.info("Successfully performed text replacement redaction on {} pages.", pageCount); - return false; - } catch (Exception e) { - log.error( - "Text replacement redaction failed due to font or encoding issues. " - + "Will fall back to box-only redaction mode. Error: {}", + "JPDFium native text replacement failed, falling back to box-only redaction mode: {}", e.getMessage()); return true; - } - } - - // ----------------------------------------------------------------------- - // Content stream manipulation - // ----------------------------------------------------------------------- - - List createTokensWithoutTargetText( - PDDocument document, - PDPage page, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch) - throws IOException { - - PDFStreamParser parser = new PDFStreamParser(page); - List tokens = new ArrayList<>(); - Object token; - while ((token = parser.parseNextToken()) != null) { - tokens.add(token); - } - - PDResources resources = page.getResources(); - if (resources != null) { - processPageXObjects(document, resources, targetWords, useRegex, wholeWordSearch); - } - - List textSegments = extractTextSegments(page, tokens); - String completeText = buildCompleteText(textSegments); - List matches = - findAllMatches(completeText, targetWords, useRegex, wholeWordSearch); - - return applyRedactionsToTokens(tokens, textSegments, matches); - } - - void writeFilteredContentStream(PDDocument document, PDPage page, List tokens) - throws IOException { - - PDStream newStream = new PDStream(document); - - try { - try (var out = newStream.createOutputStream()) { - ContentStreamWriter writer = new ContentStreamWriter(out); - writer.writeTokens(tokens); - } - page.setContents(newStream); - } catch (IOException e) { - throw new IOException("Failed to write filtered content stream to page", e); - } - } - - boolean isTextShowingOperator(String opName) { - return TEXT_SHOWING_OPERATORS.contains(opName); - } - - boolean detectCustomEncodingFonts(PDDocument document) { - try { - var documentCatalog = document.getDocumentCatalog(); - if (documentCatalog == null) { - return false; - } - - int totalFonts = 0; - int customEncodedFonts = 0; - int subsetFonts = 0; - int unreliableFonts = 0; - - for (PDPage page : document.getPages()) { - if (TextFinderUtils.hasProblematicFonts(page)) { - log.debug("Page contains fonts flagged as problematic by TextFinderUtils"); - } - - PDResources resources = page.getResources(); - if (resources == null) { - continue; - } - - for (COSName fontName : resources.getFontNames()) { - try { - PDFont font = resources.getFont(fontName); - if (font != null) { - totalFonts++; - - boolean isSubset = TextEncodingHelper.isFontSubset(font.getName()); - boolean hasCustomEncoding = TextEncodingHelper.hasCustomEncoding(font); - boolean isReliable = WidthCalculator.isWidthCalculationReliable(font); - boolean canCalculateWidths = - TextEncodingHelper.canCalculateBasicWidths(font); - - if (isSubset) { - subsetFonts++; - } - if (hasCustomEncoding) { - customEncodedFonts++; - log.debug("Font {} has custom encoding", font.getName()); - } - if (!isReliable || !canCalculateWidths) { - unreliableFonts++; - log.debug( - "Font {} flagged as unreliable: reliable={}, canCalculateWidths={}", - font.getName(), - isReliable, - canCalculateWidths); - } - if (!TextFinderUtils.validateFontReliability(font)) { - log.debug( - "Font {} failed comprehensive reliability check", - font.getName()); - } - } - } catch (Exception e) { - log.debug( - "Font loading/analysis failed for {}: {}", - fontName.getName(), - e.getMessage()); - customEncodedFonts++; - unreliableFonts++; - totalFonts++; - } - } - } - - log.info( - "Enhanced font analysis: {}/{} custom encoding, {}/{} subset, {}/{} unreliable fonts", - customEncodedFonts, - totalFonts, - subsetFonts, - totalFonts, - unreliableFonts, - totalFonts); - - return customEncodedFonts > 0 || unreliableFonts > 0; - - } catch (Exception e) { - log.warn("Enhanced font detection analysis failed: {}", e.getMessage()); - return true; - } - } - - // ----------------------------------------------------------------------- - // Placeholder creation - // ----------------------------------------------------------------------- - - String createPlaceholderWithFont(String originalWord, PDFont font) { - if (originalWord == null || originalWord.isEmpty()) { - return originalWord; - } - - if (font != null && TextEncodingHelper.isFontSubset(font.getName())) { - try { - float originalWidth = safeGetStringWidth(font, originalWord) / FONT_SCALE_FACTOR; - return createAlternativePlaceholder(originalWord, originalWidth, font, 1.0f); - } catch (Exception e) { - log.debug( - "Subset font placeholder creation failed for {}: {}", - font.getName(), - e.getMessage()); - return ""; - } - } - - return " ".repeat(originalWord.length()); - } - - String createPlaceholderWithWidth( - String originalWord, float targetWidth, PDFont font, float fontSize) { - if (originalWord == null || originalWord.isEmpty()) { - return originalWord; - } - - if (font == null || fontSize <= 0) { - return " ".repeat(originalWord.length()); - } - - try { - if (!WidthCalculator.isWidthCalculationReliable(font)) { - log.debug( - "Font {} unreliable for width calculation, using simple placeholder", - font.getName()); - return " ".repeat(originalWord.length()); - } - - if (TextEncodingHelper.isFontSubset(font.getName())) { - return createSubsetFontPlaceholder(originalWord, targetWidth, font, fontSize); - } - - float spaceWidth = WidthCalculator.calculateAccurateWidth(font, " ", fontSize); - - if (spaceWidth <= 0) { - return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); - } - - int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth)); - int maxSpaces = - Math.max( - originalWord.length() * 2, Math.round(targetWidth / spaceWidth * 1.5f)); - spaceCount = Math.min(spaceCount, maxSpaces); - - return " ".repeat(spaceCount); - - } catch (Exception e) { - log.debug("Enhanced placeholder creation failed: {}", e.getMessage()); - return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); - } - } - - private String createSubsetFontPlaceholder( - String originalWord, float targetWidth, PDFont font, float fontSize) { - try { - log.debug("Subset font {} - trying to find replacement characters", font.getName()); - String result = createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); - - if (result.isEmpty()) { - log.debug( - "Subset font {} has no suitable replacement characters, using empty string", - font.getName()); - } - - return result; - - } catch (Exception e) { - log.debug("Subset font placeholder creation failed: {}", e.getMessage()); - return ""; - } - } - - private String createAlternativePlaceholder( - String originalWord, float targetWidth, PDFont font, float fontSize) { - try { - String[] alternatives = {" ", ".", "-", "_", "~", "°", "Ā·"}; - - if (TextEncodingHelper.fontSupportsCharacter(font, " ")) { - float spaceWidth = safeGetStringWidth(font, " ") / FONT_SCALE_FACTOR * fontSize; - if (spaceWidth > 0) { - int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth)); - int maxSpaces = originalWord.length() * 2; - spaceCount = Math.min(spaceCount, maxSpaces); - log.debug("Using spaces for font {}", font.getName()); - return " ".repeat(spaceCount); - } - } - - for (String altChar : alternatives) { - if (" ".equals(altChar)) continue; - - try { - if (!TextEncodingHelper.fontSupportsCharacter(font, altChar)) { - continue; - } - - float charWidth = - safeGetStringWidth(font, altChar) / FONT_SCALE_FACTOR * fontSize; - if (charWidth > 0) { - int charCount = Math.max(1, Math.round(targetWidth / charWidth)); - int maxChars = originalWord.length() * 2; - charCount = Math.min(charCount, maxChars); - log.debug( - "Using character '{}' for width calculation but spaces for placeholder in font {}", - altChar, - font.getName()); - return " ".repeat(charCount); - } - } catch (Exception e) { - // try next alternative - } - } - - log.debug( - "All placeholder alternatives failed for font {}, using empty string", - font.getName()); - return ""; - - } catch (Exception e) { - log.debug("Alternative placeholder creation failed: {}", e.getMessage()); - return ""; - } - } - - // ----------------------------------------------------------------------- - // Width calculation - // ----------------------------------------------------------------------- - - private float safeGetStringWidth(PDFont font, String text) { - if (font == null || text == null || text.isEmpty()) { - return 0; - } - - if (!WidthCalculator.isWidthCalculationReliable(font)) { - log.debug( - "Font {} flagged as unreliable for width calculation, using fallback", - font.getName()); - return calculateConservativeWidth(font, text); - } - - if (!TextEncodingHelper.canEncodeCharacters(font, text)) { - log.debug( - "Text cannot be encoded by font {}, using character-based fallback", - font.getName()); - return calculateCharacterBasedWidth(font, text); - } - - try { - float width = font.getStringWidth(text); - log.debug("Direct width calculation successful for '{}': {}", text, width); - return width; - - } catch (Exception e) { - log.debug( - "Direct width calculation failed for font {}: {}", - font.getName(), - e.getMessage()); - return calculateFallbackWidth(font, text); - } - } - - private float calculateCharacterBasedWidth(PDFont font, String text) { - try { - float totalWidth = 0; - for (int i = 0; i < text.length(); i++) { - String character = text.substring(i, i + 1); - try { - if (!TextEncodingHelper.fontSupportsCharacter(font, character)) { - totalWidth += font.getAverageFontWidth(); - continue; - } - - byte[] encoded = font.encode(character); - if (encoded.length > 0) { - int glyphCode = encoded[0] & 0xFF; - float glyphWidth = font.getWidth(glyphCode); - - if (glyphWidth == 0) { - try { - glyphWidth = font.getWidthFromFont(glyphCode); - } catch (Exception e2) { - glyphWidth = font.getAverageFontWidth(); - } - } - - totalWidth += glyphWidth; - } else { - totalWidth += font.getAverageFontWidth(); - } - } catch (Exception e2) { - totalWidth += font.getAverageFontWidth(); - } - } - - log.debug("Character-based width calculation: {}", totalWidth); - return totalWidth; - - } catch (Exception e) { - log.debug("Character-based width calculation failed: {}", e.getMessage()); - return calculateConservativeWidth(font, text); - } - } - - private float calculateFallbackWidth(PDFont font, String text) { - try { - if (font.getFontDescriptor() != null - && font.getFontDescriptor().getFontBoundingBox() != null) { - - org.apache.pdfbox.pdmodel.common.PDRectangle bbox = - font.getFontDescriptor().getFontBoundingBox(); - float avgCharWidth = bbox.getWidth() * 0.6f; - float fallbackWidth = text.length() * avgCharWidth; - - log.debug("Bounding box fallback width: {}", fallbackWidth); - return fallbackWidth; - } - - try { - float avgWidth = font.getAverageFontWidth(); - if (avgWidth > 0) { - float fallbackWidth = text.length() * avgWidth; - log.debug("Average width fallback: {}", fallbackWidth); - return fallbackWidth; - } - } catch (Exception e2) { - log.debug("Average font width calculation failed: {}", e2.getMessage()); - } - - return calculateConservativeWidth(font, text); - - } catch (Exception e) { - log.debug("Fallback width calculation failed: {}", e.getMessage()); - return calculateConservativeWidth(font, text); - } - } - - private float calculateConservativeWidth(PDFont font, String text) { - float conservativeWidth = text.length() * 500f; - log.debug( - "Conservative width estimate for font {} text '{}': {}", - font.getName(), - text, - conservativeWidth); - return conservativeWidth; - } - - private float calculateWidthAdjustment(TextSegment segment, List matches) { - try { - if (segment.getFont() == null || segment.getFontSize() <= 0) { - return 0; - } - - String fontName = segment.getFont().getName(); - if (fontName != null - && (fontName.contains("HOEPAP") || TextEncodingHelper.isFontSubset(fontName))) { - log.debug("Skipping width adjustment for problematic/subset font: {}", fontName); - return 0; - } - - float totalOriginal = 0; - float totalPlaceholder = 0; - String text = segment.getText(); - - for (MatchRange match : matches) { - int segStart = Math.max(0, match.getStartPos() - segment.getStartPos()); - int segEnd = Math.min(text.length(), match.getEndPos() - segment.getStartPos()); - - if (segStart < text.length() && segEnd > segStart) { - String originalPart = text.substring(segStart, segEnd); - - float originalWidth = - safeGetStringWidth(segment.getFont(), originalPart) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - - String placeholderPart = - createPlaceholderWithWidth( - originalPart, - originalWidth, - segment.getFont(), - segment.getFontSize()); - - float origUnits = safeGetStringWidth(segment.getFont(), originalPart); - float placeUnits = safeGetStringWidth(segment.getFont(), placeholderPart); - - float orig = (origUnits / FONT_SCALE_FACTOR) * segment.getFontSize(); - float place = (placeUnits / FONT_SCALE_FACTOR) * segment.getFontSize(); - - totalOriginal += orig; - totalPlaceholder += place; - } - } - - float adjustment = totalOriginal - totalPlaceholder; - - float maxReasonableAdjustment = - Math.max( - segment.getText().length() * segment.getFontSize() * 2, - totalOriginal * 1.5f); - - if (Math.abs(adjustment) > maxReasonableAdjustment) { - log.debug( - "Width adjustment {} seems unreasonable for text length {}, capping to 0", - adjustment, - segment.getText().length()); - return 0; - } - - return adjustment; - } catch (Exception ex) { - log.debug("Width adjustment failed: {}", ex.getMessage()); - return 0; - } - } - - // ----------------------------------------------------------------------- - // Token and segment operations - // ----------------------------------------------------------------------- - - private void processPageXObjects( - PDDocument document, - PDResources resources, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch) { - processPageXObjects( - document, resources, targetWords, useRegex, wholeWordSearch, 0, new HashSet<>()); - } - - private void processPageXObjects( - PDDocument document, - PDResources resources, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch, - int depth, - Set visited) { - - if (depth > MAX_XOBJECT_DEPTH) { - log.warn("[redact] XObject nesting depth {} exceeded limit, stopping traversal", depth); - return; - } - - for (COSName xobjName : resources.getXObjectNames()) { - try { - PDXObject xobj = resources.getXObject(xobjName); - if (xobj instanceof PDFormXObject formXObj) { - if (!visited.add(formXObj.getCOSObject())) { - log.debug( - "[redact] Cycle detected in XObject graph, skipping {}", - xobjName.getName()); - continue; - } - processFormXObject( - document, - formXObj, - targetWords, - useRegex, - wholeWordSearch, - depth + 1, - visited); - log.debug("Processed Form XObject: {}", xobjName.getName()); - } - } catch (Exception e) { - log.warn("Failed to process XObject {}: {}", xobjName.getName(), e.getMessage()); - } - } - } - - private void processFormXObject( - PDDocument document, - PDFormXObject formXObject, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch, - int depth, - Set visited) { - - try { - PDResources xobjResources = formXObject.getResources(); - if (xobjResources == null) { - return; - } - - processPageXObjects( - document, - xobjResources, - targetWords, - useRegex, - wholeWordSearch, - depth, - visited); - - PDFStreamParser parser = new PDFStreamParser(formXObject); - List tokens = new ArrayList<>(); - Object token; - while ((token = parser.parseNextToken()) != null) { - tokens.add(token); - } - - List textSegments = extractTextSegmentsFromXObject(xobjResources, tokens); - String completeText = buildCompleteText(textSegments); - List matches = - findAllMatches(completeText, targetWords, useRegex, wholeWordSearch); - - if (!matches.isEmpty()) { - List redactedTokens = - applyRedactionsToTokens(tokens, textSegments, matches); - writeRedactedContentToXObject(document, formXObject, redactedTokens); - log.debug("Processed {} redactions in Form XObject", matches.size()); - } - - } catch (Exception e) { - log.warn("Failed to process Form XObject: {}", e.getMessage()); - } - } - - private void writeRedactedContentToXObject( - PDDocument document, PDFormXObject formXObject, List redactedTokens) - throws IOException { - - PDStream newStream = new PDStream(document); - - try (var out = newStream.createOutputStream()) { - ContentStreamWriter writer = new ContentStreamWriter(out); - writer.writeTokens(redactedTokens); - } - - formXObject.getCOSObject().removeItem(COSName.CONTENTS); - formXObject.getCOSObject().setItem(COSName.CONTENTS, newStream.getCOSObject()); - } - - private List extractTextSegments(PDPage page, List tokens) { - List segments = new ArrayList<>(); - int currentTextPos = 0; - GraphicsState graphicsState = new GraphicsState(); - PDResources resources = page.getResources(); - - for (int i = 0; i < tokens.size(); i++) { - Object currentToken = tokens.get(i); - - if (currentToken instanceof Operator op) { - String opName = op.getName(); - - if ("Tf".equals(opName) && i >= 2) { - try { - COSName fontName = (COSName) tokens.get(i - 2); - COSBase fontSizeBase = (COSBase) tokens.get(i - 1); - if (fontSizeBase instanceof COSNumber cosNumber) { - graphicsState.setFont(resources.getFont(fontName)); - graphicsState.setFontSize(cosNumber.floatValue()); - } - } catch (ClassCastException | IOException e) { - log.debug( - "Failed to extract font and font size from Tf operator: {}", - e.getMessage()); - } - } - - currentTextPos = - getCurrentTextPos( - tokens, segments, currentTextPos, graphicsState, i, opName); - } - } - - return segments; - } - - private List extractTextSegmentsFromXObject( - PDResources resources, List tokens) { - List segments = new ArrayList<>(); - int currentTextPos = 0; - GraphicsState graphicsState = new GraphicsState(); - - for (int i = 0; i < tokens.size(); i++) { - Object currentToken = tokens.get(i); - - if (currentToken instanceof Operator op) { - String opName = op.getName(); - - if ("Tf".equals(opName) && i >= 2) { - try { - COSName fontName = (COSName) tokens.get(i - 2); - COSBase fontSizeBase = (COSBase) tokens.get(i - 1); - if (fontSizeBase instanceof COSNumber cosNumber) { - graphicsState.setFont(resources.getFont(fontName)); - graphicsState.setFontSize(cosNumber.floatValue()); - } - } catch (ClassCastException | IOException e) { - log.debug("Font extraction failed in XObject: {}", e.getMessage()); - } - } - - currentTextPos = - getCurrentTextPos( - tokens, segments, currentTextPos, graphicsState, i, opName); - } - } - - return segments; - } - - private int getCurrentTextPos( - List tokens, - List segments, - int currentTextPos, - GraphicsState graphicsState, - int i, - String opName) { - if (isTextShowingOperator(opName) && i > 0) { - String textContent = extractTextFromToken(tokens.get(i - 1), opName); - if (!textContent.isEmpty()) { - segments.add( - new TextSegment( - i - 1, - opName, - textContent, - currentTextPos, - currentTextPos + textContent.length(), - graphicsState.font, - graphicsState.fontSize)); - currentTextPos += textContent.length(); - } - } - return currentTextPos; - } - - private String buildCompleteText(List segments) { - StringBuilder sb = new StringBuilder(); - for (TextSegment segment : segments) { - sb.append(segment.text); - } - return sb.toString(); - } - - private List findAllMatches( - String completeText, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch) { - - List patterns = - TextFinderUtils.createOptimizedSearchPatterns( - targetWords, useRegex, wholeWordSearch); - - return patterns.stream() - .flatMap( - pattern -> { - try { - return pattern.matcher(completeText).results(); - } catch (Exception e) { - log.debug( - "Pattern matching failed for pattern {}: {}", - pattern.pattern(), - e.getMessage()); - return java.util.stream.Stream.empty(); - } - }) - .map(matchResult -> new MatchRange(matchResult.start(), matchResult.end())) - .sorted(Comparator.comparingInt(MatchRange::getStartPos)) - .collect(Collectors.toList()); - } - - private List applyRedactionsToTokens( - List tokens, List textSegments, List matches) { - - long startTime = System.currentTimeMillis(); - - try { - List newTokens = new ArrayList<>(tokens); - - Map> matchesBySegment = new HashMap<>(); - for (MatchRange match : matches) { - for (int i = 0; i < textSegments.size(); i++) { - TextSegment segment = textSegments.get(i); - int overlapStart = Math.max(match.startPos, segment.startPos); - int overlapEnd = Math.min(match.endPos, segment.endPos); - if (overlapStart < overlapEnd) { - matchesBySegment.computeIfAbsent(i, k -> new ArrayList<>()).add(match); - } - } - } - - List tasks = new ArrayList<>(); - for (Map.Entry> entry : matchesBySegment.entrySet()) { - int segmentIndex = entry.getKey(); - List segmentMatches = entry.getValue(); - TextSegment segment = textSegments.get(segmentIndex); - - if ("Tj".equals(segment.operatorName) || "'".equals(segment.operatorName)) { - String newText = applyRedactionsToSegmentText(segment, segmentMatches); - try { - float adjustment = calculateWidthAdjustment(segment, segmentMatches); - tasks.add(new ModificationTask(segment, newText, adjustment)); - } catch (Exception e) { - log.debug( - "Width adjustment calculation failed for segment: {}", - e.getMessage()); - } - } else if ("TJ".equals(segment.operatorName)) { - tasks.add(new ModificationTask(segment, null, 0)); - } - } - - tasks.sort((a, b) -> Integer.compare(b.segment.tokenIndex, a.segment.tokenIndex)); - - for (ModificationTask task : tasks) { - List segmentMatches = - matchesBySegment.getOrDefault( - textSegments.indexOf(task.segment), - java.util.Collections.emptyList()); - modifyTokenForRedaction( - newTokens, task.segment, task.newText, task.adjustment, segmentMatches); - } - - return newTokens; - } finally { - long processingTime = System.currentTimeMillis() - startTime; - log.debug( - "Token redaction processing completed in {} ms for {} matches", - processingTime, - matches.size()); - } - } - - private String applyRedactionsToSegmentText(TextSegment segment, List matches) { - String text = segment.getText(); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable(segment.getFont(), text)) { - log.debug( - "Skipping text segment '{}' - font {} cannot process this text reliably", - text, - segment.getFont().getName()); - return text; - } - - StringBuilder result = new StringBuilder(text); - - for (MatchRange match : matches) { - int segmentStart = Math.max(0, match.getStartPos() - segment.getStartPos()); - int segmentEnd = Math.min(text.length(), match.getEndPos() - segment.getStartPos()); - - if (segmentStart < text.length() && segmentEnd > segmentStart) { - String originalPart = text.substring(segmentStart, segmentEnd); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable( - segment.getFont(), originalPart)) { - log.debug( - "Skipping text part '{}' within segment - cannot be processed reliably", - originalPart); - continue; + if (tempIn != null && tempIn.exists()) { + try { + Files.delete(tempIn.toPath()); + } catch (IOException _) { + log.warn("Failed to delete temporary file: {}", tempIn.getAbsolutePath()); } - - float originalWidth = 0; - if (segment.getFont() != null && segment.getFontSize() > 0) { - try { - originalWidth = - safeGetStringWidth(segment.getFont(), originalPart) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - } catch (Exception e) { - log.debug( - "Failed to calculate original width for placeholder: {}", - e.getMessage()); - } - } - - String placeholder = - (originalWidth > 0) - ? createPlaceholderWithWidth( - originalPart, - originalWidth, - segment.getFont(), - segment.getFontSize()) - : createPlaceholderWithFont(originalPart, segment.getFont()); - - result.replace(segmentStart, segmentEnd, placeholder); - } - } - - return result.toString(); - } - - private void modifyTokenForRedaction( - List tokens, - TextSegment segment, - String newText, - float adjustment, - List matches) { - - if (segment.getTokenIndex() < 0 || segment.getTokenIndex() >= tokens.size()) { - return; - } - - Object token = tokens.get(segment.getTokenIndex()); - String operatorName = segment.getOperatorName(); - - try { - if (("Tj".equals(operatorName) || "'".equals(operatorName)) - && token instanceof COSString) { - - if (Math.abs(adjustment) < PRECISION_THRESHOLD) { - if (newText.isEmpty()) { - tokens.set(segment.getTokenIndex(), EMPTY_COS_STRING); - } else { - tokens.set(segment.getTokenIndex(), new COSString(newText)); - } - } else { - COSArray newArray = new COSArray(); - newArray.add(new COSString(newText)); - if (segment.getFontSize() > 0) { - float kerning = (-adjustment / segment.getFontSize()) * FONT_SCALE_FACTOR; - newArray.add(new COSFloat(kerning)); - } - tokens.set(segment.getTokenIndex(), newArray); - - int operatorIndex = segment.getTokenIndex() + 1; - if (operatorIndex < tokens.size() - && tokens.get(operatorIndex) instanceof Operator op - && op.getName().equals(operatorName)) { - tokens.set(operatorIndex, Operator.getOperator("TJ")); - } - } - } else if ("TJ".equals(operatorName) && token instanceof COSArray) { - COSArray newArray = createRedactedTJArray((COSArray) token, segment, matches); - tokens.set(segment.getTokenIndex(), newArray); } - } catch (Exception e) { - log.debug( - "Token modification failed for segment at index {}: {}", - segment.getTokenIndex(), - e.getMessage()); - } - } - - private COSArray createRedactedTJArray( - COSArray originalArray, TextSegment segment, List matches) { - try { - COSArray newArray = new COSArray(); - int textOffsetInSegment = 0; - - for (COSBase element : originalArray) { - if (element instanceof COSString cosString) { - String originalText = cosString.getString(); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable( - segment.getFont(), originalText)) { - log.debug( - "Skipping TJ text part '{}' - cannot be processed reliably with font {}", - originalText, - segment.getFont().getName()); - newArray.add(element); - textOffsetInSegment += originalText.length(); - continue; - } - - StringBuilder newText = new StringBuilder(originalText); - boolean modified = false; - - for (MatchRange match : matches) { - int stringStartInPage = segment.getStartPos() + textOffsetInSegment; - int stringEndInPage = stringStartInPage + originalText.length(); - - int overlapStart = Math.max(match.getStartPos(), stringStartInPage); - int overlapEnd = Math.min(match.getEndPos(), stringEndInPage); - - if (overlapStart < overlapEnd) { - int redactionStartInString = overlapStart - stringStartInPage; - int redactionEndInString = overlapEnd - stringStartInPage; - if (redactionStartInString >= 0 - && redactionEndInString <= originalText.length()) { - String originalPart = - originalText.substring( - redactionStartInString, redactionEndInString); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable( - segment.getFont(), originalPart)) { - log.debug( - "Skipping TJ text part '{}' - cannot be redacted reliably", - originalPart); - continue; - } - - modified = true; - float originalWidth = 0; - if (segment.getFont() != null && segment.getFontSize() > 0) { - try { - originalWidth = - safeGetStringWidth(segment.getFont(), originalPart) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - } catch (Exception e) { - log.debug( - "Failed to calculate original width for TJ placeholder: {}", - e.getMessage()); - } - } - - String placeholder = - (originalWidth > 0) - ? createPlaceholderWithWidth( - originalPart, - originalWidth, - segment.getFont(), - segment.getFontSize()) - : createPlaceholderWithFont( - originalPart, segment.getFont()); - - newText.replace( - redactionStartInString, redactionEndInString, placeholder); - } - } - } - - String modifiedString = newText.toString(); - newArray.add(new COSString(modifiedString)); - - if (modified && segment.getFont() != null && segment.getFontSize() > 0) { - try { - float originalWidth = - safeGetStringWidth(segment.getFont(), originalText) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - float modifiedWidth = - safeGetStringWidth(segment.getFont(), modifiedString) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - float adjustment = originalWidth - modifiedWidth; - if (Math.abs(adjustment) > PRECISION_THRESHOLD) { - float kerning = - (-adjustment / segment.getFontSize()) - * FONT_SCALE_FACTOR - * 1.10f; - newArray.add(new COSFloat(kerning)); - } - } catch (Exception e) { - log.debug( - "Width adjustment calculation failed for segment: {}", - e.getMessage()); - } - } - - textOffsetInSegment += originalText.length(); - } else { - newArray.add(element); + if (tempOut != null && tempOut.exists()) { + try { + Files.delete(tempOut.toPath()); + } catch (IOException _) { + log.warn("Failed to delete temporary file: {}", tempOut.getAbsolutePath()); } } - return newArray; - } catch (Exception e) { - return originalArray; } } - private String extractTextFromToken(Object token, String operatorName) { - return switch (operatorName) { - case "Tj", "'" -> { - if (token instanceof COSString cosString) { - yield cosString.getString(); - } - yield ""; - } - case "TJ" -> { - if (token instanceof COSArray cosArray) { - StringBuilder sb = new StringBuilder(); - for (COSBase element : cosArray) { - if (element instanceof COSString cosString) { - sb.append(cosString.getString()); - } - } - yield sb.toString(); - } - yield ""; - } - default -> ""; - }; - } - - // ----------------------------------------------------------------------- - // Inner data classes - // ----------------------------------------------------------------------- - - @Data - private static class GraphicsState { - private PDFont font = null; - private float fontSize = 0; - } - @Data @AllArgsConstructor static class TextSegment { @@ -1196,12 +188,4 @@ class TextRedactionService { private int startPos; private int endPos; } - - @Data - @AllArgsConstructor - private static class ModificationTask { - private TextSegment segment; - private String newText; - private float adjustment; - } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java index 279a41a27f..791b7626c4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java @@ -45,5 +45,5 @@ public class RedactPdfRequest extends PDFFile { description = "Convert the redacted PDF to an image", defaultValue = "false", requiredMode = Schema.RequiredMode.REQUIRED) - private Boolean convertPDFToImage; + private Boolean convertPDFToImage = Boolean.FALSE; } 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/settings.yml.template b/app/core/src/main/resources/settings.yml.template index d0234a3594..fdfe40b352 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -313,7 +313,7 @@ autoPipeline: ui: appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF" - logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) + logoStyle: modern # Options: 'modern' (default - minimalist logo) or 'classic' (legacy S icon) languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages. defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out diff --git a/app/core/src/main/resources/static/apple-touch-icon.png b/app/core/src/main/resources/static/apple-touch-icon.png index 6ac076ba54..66a12c54c6 100644 Binary files a/app/core/src/main/resources/static/apple-touch-icon.png and b/app/core/src/main/resources/static/apple-touch-icon.png differ diff --git a/app/core/src/main/resources/static/favicon-16x16.png b/app/core/src/main/resources/static/favicon-16x16.png index d88e6615d0..6448ce5424 100644 Binary files a/app/core/src/main/resources/static/favicon-16x16.png and b/app/core/src/main/resources/static/favicon-16x16.png differ diff --git a/app/core/src/main/resources/static/favicon-32x32.png b/app/core/src/main/resources/static/favicon-32x32.png index f44f0c371c..1343e2632d 100644 Binary files a/app/core/src/main/resources/static/favicon-32x32.png and b/app/core/src/main/resources/static/favicon-32x32.png differ diff --git a/app/core/src/main/resources/static/favicon.icns b/app/core/src/main/resources/static/favicon.icns index 7b281937e8..86bad6a2e1 100644 Binary files a/app/core/src/main/resources/static/favicon.icns and b/app/core/src/main/resources/static/favicon.icns differ diff --git a/app/core/src/main/resources/static/favicon.ico b/app/core/src/main/resources/static/favicon.ico index 8ad57cac70..2351219afa 100644 Binary files a/app/core/src/main/resources/static/favicon.ico and b/app/core/src/main/resources/static/favicon.ico differ diff --git a/app/core/src/main/resources/static/favicon.svg b/app/core/src/main/resources/static/favicon.svg index 0fef4393aa..b5455291c9 100644 --- a/app/core/src/main/resources/static/favicon.svg +++ b/app/core/src/main/resources/static/favicon.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/app/core/src/main/resources/static/images/signature.png b/app/core/src/main/resources/static/images/signature.png index 1adfcedc3c..7c55bc2928 100644 Binary files a/app/core/src/main/resources/static/images/signature.png and b/app/core/src/main/resources/static/images/signature.png differ 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/security/RedactControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java index 150a2355de..0065b02cee 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java @@ -24,6 +24,8 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.AfterEach; @@ -120,12 +122,22 @@ class RedactControllerMoreTest { .thenAnswer(inv -> Loader.loadPDF(pdfBytes)); } + private PDFont helvetica(PDDocument doc) throws IOException { + try (InputStream is = + getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) { + if (is != null) { + return PDType0Font.load(doc, is); + } + } + return new PDType1Font(Standard14Fonts.FontName.HELVETICA); + } + private byte[] singlePageTextPdf(String... lines) throws IOException { try (PDDocument doc = new PDDocument()) { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); for (int i = 0; i < lines.length; i++) { cs.beginText(); cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f); @@ -145,7 +157,7 @@ class RedactControllerMoreTest { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); cs.beginText(); cs.newLineAtOffset(LEFT_X, TOP_Y); cs.showText(line); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java index 15774415a9..2f78ee033b 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java @@ -14,19 +14,25 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Set; +import java.util.Map; import org.apache.pdfbox.contentstream.operator.Operator; import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDocument; import org.apache.pdfbox.cos.COSFloat; import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSStream; import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.PDPageTree; import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.AfterEach; @@ -49,6 +55,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; +import stirling.software.SPDF.model.PDFText; import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest; import stirling.software.SPDF.model.api.security.RedactPdfRequest; import stirling.software.common.model.api.security.RedactionArea; @@ -64,9 +71,9 @@ class RedactControllerTest { return ResponseEntity.ok(new ByteArrayResource(bytes)); } - private static byte[] drainBody(ResponseEntity response) throws java.io.IOException { - java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); - try (java.io.InputStream __in = response.getBody().getInputStream()) { + private static byte[] drainBody(ResponseEntity response) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (InputStream __in = response.getBody().getInputStream()) { __in.transferTo(baos); } return baos.toByteArray(); @@ -90,13 +97,24 @@ class RedactControllerTest { private PDDocument realDocument; private PDPage realPage; + private static PDFont helvetica(PDDocument doc) throws IOException { + try (InputStream is = + RedactControllerTest.class.getResourceAsStream( + "/type3/library/fonts/dejavu/DejaVuSans.ttf")) { + if (is != null) { + return PDType0Font.load(doc, is); + } + } + return new PDType1Font(Standard14Fonts.FontName.HELVETICA); + } + private static byte[] createSimplePdfContent() throws IOException { try (PDDocument doc = new PDDocument()) { PDPage page = new PDPage(PDRectangle.A4); doc.addPage(page); try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) { contentStream.beginText(); - contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.setFont(helvetica(doc), 12); contentStream.newLineAtOffset(100, 700); contentStream.showText("This is a simple PDF."); contentStream.endText(); @@ -156,8 +174,7 @@ class RedactControllerTest { mockDocument = mock(PDDocument.class); mockPages = mock(PDPageTree.class); mockPage = mock(PDPage.class); - org.apache.pdfbox.pdmodel.PDDocumentCatalog mockCatalog = - mock(org.apache.pdfbox.pdmodel.PDDocumentCatalog.class); + PDDocumentCatalog mockCatalog = mock(PDDocumentCatalog.class); // Setup document structure properly when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDocument); @@ -182,9 +199,8 @@ class RedactControllerTest { when(mockPage.hasContents()).thenReturn(true); - org.apache.pdfbox.cos.COSDocument mockCOSDocument = - mock(org.apache.pdfbox.cos.COSDocument.class); - org.apache.pdfbox.cos.COSStream mockCOSStream = mock(org.apache.pdfbox.cos.COSStream.class); + COSDocument mockCOSDocument = mock(COSDocument.class); + COSStream mockCOSStream = mock(COSStream.class); when(mockDocument.getDocument()).thenReturn(mockCOSDocument); when(mockCOSDocument.createCOSStream()).thenReturn(mockCOSStream); @@ -338,17 +354,13 @@ class RedactControllerTest { when(mockPages.get(0)).thenReturn(mockPage); - org.apache.pdfbox.pdmodel.PDDocumentInformation mockInfo = - mock(org.apache.pdfbox.pdmodel.PDDocumentInformation.class); + PDDocumentInformation mockInfo = mock(PDDocumentInformation.class); when(mockDocument.getDocumentInformation()).thenReturn(mockInfo); ResponseEntity response = redactController.redactPdf(request); assertNotNull(response); assertEquals(200, response.getStatusCode().value()); - - verify(mockDocument).save(any(File.class)); - verify(mockDocument).close(); } } @@ -753,8 +765,6 @@ class RedactControllerTest { assertEquals(200, response.getStatusCode().value()); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); - verify(mockDocument, times(1)).save(any(File.class)); - verify(mockDocument, times(1)).close(); } } catch (Exception e) { if (expectSuccess) { @@ -788,11 +798,14 @@ class RedactControllerTest { realPage = new PDPage(PDRectangle.A4); realDocument.addPage(realPage); - // Set up basic page resources - PDResources resources = new PDResources(); - resources.put( - COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); - realPage.setResources(resources); + // Set up basic page resources with embedded font + try { + PDResources resources = new PDResources(); + resources.put(COSName.getPDFName("F1"), helvetica(realDocument)); + realPage.setResources(resources); + } catch (IOException e) { + throw new RuntimeException(e); + } } // Helper methods for real PDF content creation @@ -803,8 +816,7 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources() - .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); @@ -822,8 +834,7 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources() - .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); @@ -846,8 +857,7 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources() - .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.setLineWidth(2); @@ -874,8 +884,7 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources() - .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); @@ -1005,22 +1014,6 @@ class RedactControllerTest { } } - private List getOriginalTokens() throws Exception { - // Create a new page to avoid side effects from other tests - PDPage pageForTokenExtraction = new PDPage(PDRectangle.A4); - pageForTokenExtraction.setResources(realPage.getResources()); - try (PDPageContentStream contentStream = - new PDPageContentStream(realDocument, pageForTokenExtraction)) { - contentStream.beginText(); - contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); - contentStream.newLineAtOffset(50, 750); - contentStream.showText("Original content"); - contentStream.endText(); - } - return textRedactionService.createTokensWithoutTargetText( - realDocument, pageForTokenExtraction, Collections.emptySet(), false, false); - } - @Nested @DisplayName("Color Decoding Utility Tests") class ColorDecodingTests { @@ -1099,318 +1092,19 @@ class RedactControllerTest { class ContentStreamUnitTests { @Test - @DisplayName("createTokensWithoutTargetText should remove simple text tokens") - void shouldRemoveSimpleTextTokens() throws Exception { - createRealPageWithSimpleText("This document contains confidential information."); + @DisplayName("performTextReplacement should process document text replacement") + void shouldPerformTextReplacement() throws Exception { + createRealPageWithSimpleText("This document contains sensitive information."); + String[] targetWords = new String[] {"sensitive"}; - Set targetWords = Set.of("confidential"); + Map> found = + textRedactionService.findTextToRedact(realDocument, targetWords, false, false); + assertFalse(found.isEmpty(), "Should find target text to redact"); - List tokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, false); - - assertNotNull(tokens); - assertFalse(tokens.isEmpty()); - - String reconstructedText = extractTextFromTokens(tokens); - assertFalse( - reconstructedText.contains("confidential"), - "Target text should be replaced with placeholder"); - assertTrue(reconstructedText.contains("document"), "Non-target text should remain"); - } - - @Test - @DisplayName("createTokensWithoutTargetText should handle TJ operator arrays") - void shouldHandleTJOperatorArrays() throws Exception { - createRealPageWithTJArrayText(); - - Set targetWords = Set.of("secret"); - - List tokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, false); - - assertNotNull(tokens); - - boolean foundModifiedTJArray = false; - for (Object token : tokens) { - if (token instanceof COSArray array) { - for (int i = 0; i < array.size(); i++) { - if (array.getObject(i) instanceof COSString cosString) { - String text = cosString.getString(); - if (text.contains("secret")) { - fail( - "Target text 'secret' should have been redacted from TJ" - + " array"); - } - foundModifiedTJArray = true; - } - } - } - } - assertTrue(foundModifiedTJArray, "Should find at least one TJ array"); - } - - @Test - @DisplayName("createTokensWithoutTargetText should preserve non-text tokens") - void shouldPreserveNonTextTokens() throws Exception { - createRealPageWithMixedContent(); - - Set targetWords = Set.of("redact"); - - List originalTokens = getOriginalTokens(); - List filteredTokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, false); - - long originalNonTextCount = - originalTokens.stream() - .filter( - token -> - token instanceof Operator op - && !textRedactionService.isTextShowingOperator( - op.getName())) - .count(); - - long filteredNonTextCount = - filteredTokens.stream() - .filter( - token -> - token instanceof Operator op - && !textRedactionService.isTextShowingOperator( - op.getName())) - .count(); - - assertTrue(filteredNonTextCount > 0, "Non-text operators should be preserved"); - - assertTrue( - filteredNonTextCount >= originalNonTextCount / 2, - "A reasonable number of non-text operators should be preserved"); - } - - @Test - @DisplayName("createTokensWithoutTargetText should handle regex patterns") - void shouldHandleRegexPatterns() throws Exception { - createRealPageWithSimpleText("Phone: 123-456-7890 and SSN: 111-22-3333"); - - Set targetWords = Set.of("\\d{3}-\\d{2}-\\d{4}"); // SSN pattern - - List tokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, true, false); - - String reconstructedText = extractTextFromTokens(tokens); - assertFalse(reconstructedText.contains("111-22-3333"), "SSN should be redacted"); - assertTrue(reconstructedText.contains("123-456-7890"), "Phone should remain"); - } - - @Test - @DisplayName("createTokensWithoutTargetText should handle whole word search") - void shouldHandleWholeWordSearch() throws Exception { - createRealPageWithSimpleText("This test testing tested document"); - - Set targetWords = Set.of("test"); - - List tokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, true); - - String reconstructedText = extractTextFromTokens(tokens); - assertTrue(reconstructedText.contains("testing"), "Partial matches should remain"); - assertTrue(reconstructedText.contains("tested"), "Partial matches should remain"); - } - - @ParameterizedTest - @ValueSource(strings = {"Tj", "TJ", "'", "\""}) - @DisplayName("createTokensWithoutTargetText should handle all text operators") - void shouldHandleAllTextOperators(String operatorName) throws Exception { - createRealPageWithSpecificOperator(operatorName); - - Set targetWords = Set.of("sensitive"); - - List tokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, false); - - String reconstructedText = extractTextFromTokens(tokens); - assertFalse( - reconstructedText.contains("sensitive"), - "Text should be redacted regardless of operator type"); - } - - @Test - @DisplayName("writeFilteredContentStream should write tokens to new stream") - void shouldWriteTokensToNewContentStream() throws Exception { - List tokens = createSampleTokenList(); - - textRedactionService.writeFilteredContentStream(realDocument, realPage, tokens); - - assertNotNull(realPage.getContents(), "Page should have content stream"); - - // Verify the content can be read back - try (InputStream inputStream = realPage.getContents()) { - byte[] content = readAllBytes(inputStream); - assertTrue(content.length > 0, "Content stream should not be empty"); - } - } - - @Test - @DisplayName("writeFilteredContentStream should handle empty token list") - void shouldHandleEmptyTokenList() throws Exception { - List emptyTokens = Collections.emptyList(); - - assertDoesNotThrow( - () -> - textRedactionService.writeFilteredContentStream( - realDocument, realPage, emptyTokens)); - - assertNotNull(realPage.getContents(), "Page should still have content stream"); - } - - @Test - @DisplayName("writeFilteredContentStream should replace existing content") - void shouldReplaceExistingContentStream() throws Exception { - createRealPageWithSimpleText("Original content"); - String originalContent = extractTextFromModifiedPage(realPage); - - List newTokens = createSampleTokenList(); - textRedactionService.writeFilteredContentStream(realDocument, realPage, newTokens); - - String newContent = extractTextFromModifiedPage(realPage); - assertNotEquals(originalContent, newContent, "Content stream should be replaced"); - } - - @Test - @DisplayName("Placeholder creation should maintain text width") - void shouldCreateWidthMatchingPlaceholder() { - String originalText = "confidential"; - String placeholder = - textRedactionService.createPlaceholderWithFont( - originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA)); - - assertEquals( - originalText.length(), - placeholder.length(), - "Placeholder should maintain character count for width preservation"); - } - - @Test - @DisplayName("Placeholder should handle special characters") - void shouldHandleSpecialCharactersInPlaceholder() { - String originalText = "cafĆ© naĆÆve"; - String placeholder = - textRedactionService.createPlaceholderWithFont( - originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA)); - - assertEquals(originalText.length(), placeholder.length()); - assertFalse( - placeholder.contains("cafĆ©"), "Placeholder should not contain original text"); - } - - @Test - @DisplayName("Integration test: createTokens and writeStream") - void shouldIntegrateTokenCreationAndWriting() throws Exception { - createRealPageWithSimpleText("This document contains secret information."); - - Set targetWords = Set.of("secret"); - - List filteredTokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, false); - - textRedactionService.writeFilteredContentStream(realDocument, realPage, filteredTokens); - assertNotNull(realPage.getContents()); - - String finalText = extractTextFromModifiedPage(realPage); - assertFalse(finalText.contains("secret"), "Target text should be completely removed"); - assertTrue(finalText.contains("document"), "Other text should remain"); - } - - @Test - @DisplayName("Should preserve text positioning operators") - void shouldPreserveTextPositioning() throws Exception { - createRealPageWithPositionedText(); - - Set targetWords = Set.of("confidential"); - - List filteredTokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, false); - - long filteredPositioning = - filteredTokens.stream() - .filter( - token -> - token instanceof Operator op - && ("Td".equals(op.getName()) - || "TD".equals(op.getName()) - || "Tm".equals(op.getName()))) - .count(); - - assertTrue(filteredPositioning > 0, "Positioning operators should be preserved"); - } - - @Test - @DisplayName("Should handle complex content streams with multiple operators") - void shouldHandleComplexContentStreams() throws Exception { - realPage = new PDPage(PDRectangle.A4); - while (realDocument.getNumberOfPages() > 0) { - realDocument.removePage(0); - } - realDocument.addPage(realPage); - realPage.setResources(new PDResources()); - realPage.getResources() - .put( - COSName.getPDFName("F1"), - new PDType1Font(Standard14Fonts.FontName.HELVETICA)); - - try (PDPageContentStream contentStream = - new PDPageContentStream(realDocument, realPage)) { - contentStream.setLineWidth(2); - contentStream.moveTo(100, 100); - contentStream.lineTo(200, 200); - contentStream.stroke(); - - contentStream.beginText(); - contentStream.setFont( - realPage.getResources().getFont(COSName.getPDFName("F1")), 12); - contentStream.newLineAtOffset(50, 750); - contentStream.showText("This is a complex document with "); - contentStream.setTextRise(5); - contentStream.showText("confidential"); - contentStream.setTextRise(0); - contentStream.showText(" information."); - contentStream.endText(); - - contentStream.addRect(300, 300, 100, 100); - contentStream.fill(); - } - - Set targetWords = Set.of("confidential"); - - List tokens = - textRedactionService.createTokensWithoutTargetText( - realDocument, realPage, targetWords, false, false); - - assertNotNull(tokens); - assertFalse(tokens.isEmpty()); - - String reconstructedText = extractTextFromTokens(tokens); - assertFalse( - reconstructedText.contains("confidential"), "Target text should be redacted"); - - boolean hasGraphicsOperators = - tokens.stream() - .anyMatch( - token -> - token instanceof Operator op - && ("re".equals(op.getName()) - || "f".equals(op.getName()) - || "m".equals(op.getName()) - || "l".equals(op.getName()) - || "S".equals(op.getName()))); - - assertTrue(hasGraphicsOperators, "Graphics operators should be preserved"); + boolean fallback = + textRedactionService.performTextReplacement( + realDocument, found, targetWords, false, false); + assertFalse(fallback, "JPDFium text replacement should complete without fallback"); } @Test @@ -1423,14 +1117,13 @@ class RedactControllerTest { realDocument.addPage(realPage); PDResources resources = new PDResources(); - resources.put( - COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + resources.put(COSName.getPDFName("F1"), helvetica(realDocument)); realPage.setResources(resources); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); - contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.setFont(helvetica(realDocument), 12); contentStream.newLineAtOffset(50, 750); contentStream.showText("This is the first text block"); contentStream.endText(); @@ -1441,7 +1134,7 @@ class RedactControllerTest { contentStream.stroke(); contentStream.beginText(); - contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.setFont(helvetica(realDocument), 12); contentStream.newLineAtOffset(50, 650); contentStream.showText("This block contains confidential information"); contentStream.endText(); @@ -1450,7 +1143,7 @@ class RedactControllerTest { contentStream.fill(); contentStream.beginText(); - contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.setFont(helvetica(realDocument), 12); contentStream.newLineAtOffset(50, 550); contentStream.showText("This is the third text block"); contentStream.endText(); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java index 49149e3ca7..246909efba 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.mock; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Method; import java.nio.file.Files; import java.util.ArrayList; @@ -22,6 +23,8 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; @@ -31,6 +34,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; import stirling.software.SPDF.model.PDFText; @@ -118,18 +122,26 @@ class RedactExecuteServiceMoreTest { private RedactExecuteRequest requestFor(byte[] pdfBytes) { RedactExecuteRequest req = new RedactExecuteRequest(); - req.setFileInput( - new org.springframework.mock.web.MockMultipartFile( - "fileInput", "in.pdf", "application/pdf", pdfBytes)); + req.setFileInput(new MockMultipartFile("fileInput", "in.pdf", "application/pdf", pdfBytes)); return req; } + private PDFont helvetica(PDDocument doc) throws IOException { + try (InputStream is = + getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) { + if (is != null) { + return PDType0Font.load(doc, is); + } + } + return new PDType1Font(Standard14Fonts.FontName.HELVETICA); + } + private byte[] singlePageTextPdf(String... lines) throws IOException { try (PDDocument doc = new PDDocument()) { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); for (int i = 0; i < lines.length; i++) { cs.beginText(); cs.newLineAtOffset(LEFT_X, TOP_Y - i * LINE_H); @@ -149,7 +161,7 @@ class RedactExecuteServiceMoreTest { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); cs.beginText(); cs.newLineAtOffset(LEFT_X, TOP_Y); cs.showText("page " + p + " has SECRET content here"); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java index 8a8b335642..9d2f50a5e4 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java @@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api.security; import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; +import java.io.InputStream; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -11,6 +12,8 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.DisplayName; @@ -219,12 +222,22 @@ class RedactExecuteServiceTest { * anchor) 1: line one 2: line two 3: line three 4: STOP-HERE (end anchor) 5: line five (must * NOT be redacted) */ + private PDFont helvetica(PDDocument doc) throws IOException { + try (InputStream is = + getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) { + if (is != null) { + return PDType0Font.load(doc, is); + } + } + return new PDType1Font(Standard14Fonts.FontName.HELVETICA); + } + private PDDocument buildSingleColumnDoc() throws IOException { PDDocument doc = new PDDocument(); PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); String[] lines = { "START-HERE", "line one", "line two", "line three", "STOP-HERE", "line five" }; @@ -247,7 +260,7 @@ class RedactExecuteServiceTest { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); // Body lines are padded to make each column genuinely wide enough that column // detection (which ignores narrow lines) treats both sides as real columns. String fill = " " + "x".repeat(26); @@ -294,7 +307,7 @@ class RedactExecuteServiceTest { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); String[] lines = { "#1 Auto layout", "Body about auto layout.", @@ -331,7 +344,7 @@ class RedactExecuteServiceTest { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); // Header — full width, lines 0..1. for (int i = 0; i < 2; i++) { cs.beginText(); @@ -383,7 +396,7 @@ class RedactExecuteServiceTest { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); float dateX = PAGE_WIDTH - 144f; // right-aligned dates near the right margin diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java index 6c94dd3a24..94e4d7cb6c 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java @@ -3,44 +3,24 @@ package stirling.software.SPDF.controller.api.security; import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.io.InputStream; import java.util.List; import java.util.Map; -import java.util.Set; -import org.apache.pdfbox.contentstream.operator.Operator; -import org.apache.pdfbox.cos.COSArray; -import org.apache.pdfbox.cos.COSBase; -import org.apache.pdfbox.cos.COSFloat; -import org.apache.pdfbox.cos.COSName; -import org.apache.pdfbox.cos.COSString; -import org.apache.pdfbox.pdfparser.PDFStreamParser; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.common.PDRectangle; -import org.apache.pdfbox.pdmodel.common.PDStream; import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; -import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import stirling.software.SPDF.model.PDFText; -/** - * Further gap-coverage tests for {@link TextRedactionService}, complementing {@code - * TextRedactionServiceTest} and {@code TextRedactionServiceMoreTest}. These target branches the - * other two suites leave untouched: case-sensitive vs regex find, multi-term and multi-match within - * one segment, the kerning ({@code adjustment != 0}) path that rewrites a {@code Tj} into a {@code - * TJ} array, nested Form XObject traversal, pages with no resources, and the private width helpers - * exercised directly via reflection. - */ @DisplayName("TextRedactionService extra coverage") class TextRedactionServiceExtraTest { @@ -50,43 +30,22 @@ class TextRedactionServiceExtraTest { private final TextRedactionService service = new TextRedactionService(); - private PDFont helvetica() { + private PDFont helvetica(PDDocument doc) throws IOException { + try (InputStream is = + getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) { + if (is != null) { + return PDType0Font.load(doc, is); + } + } return new PDType1Font(Standard14Fonts.FontName.HELVETICA); } - private List parseTokens(PDPage page) throws IOException { - PDFStreamParser parser = new PDFStreamParser(page); - List tokens = new ArrayList<>(); - Object t; - while ((t = parser.parseNextToken()) != null) { - tokens.add(t); - } - return tokens; - } - - private String tokensText(List tokens) { - StringBuilder sb = new StringBuilder(); - for (Object token : tokens) { - if (token instanceof COSString cs) { - sb.append(cs.getString()); - } else if (token instanceof COSArray arr) { - for (COSBase el : arr) { - if (el instanceof COSString cs) { - sb.append(cs.getString()); - } - } - } - } - return sb.toString(); - } - - /** Single page, one Tj line per supplied text line, Helvetica 12. */ private PDDocument buildDoc(String... lines) throws IOException { PDDocument doc = new PDDocument(); PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(helvetica(), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); for (int i = 0; i < lines.length; i++) { cs.beginText(); cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f); @@ -97,25 +56,6 @@ class TextRedactionServiceExtraTest { return doc; } - /** Page whose content stream is exactly {@code rawContent}, font F1=Helvetica. */ - private PDDocument docWithRawContent(String rawContent) throws IOException { - PDDocument doc = new PDDocument(); - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - PDResources resources = new PDResources(); - resources.put(COSName.getPDFName("F1"), helvetica()); - page.setResources(resources); - - PDStream stream = new PDStream(doc); - try (var out = stream.createOutputStream()) { - out.write(rawContent.getBytes(StandardCharsets.ISO_8859_1)); - } - page.setContents(stream); - return doc; - } - - // ── findTextToRedact: matching modes ───────────────────────────────────────────────────────── - @Nested @DisplayName("findTextToRedact matching modes") class FindModes { @@ -126,7 +66,6 @@ class TextRedactionServiceExtraTest { try (PDDocument doc = buildDoc("Secret and secret and SECRET")) { Map> result = service.findTextToRedact(doc, new String[] {"secret"}, false, false); - // Patterns are compiled CASE_INSENSITIVE, so all three occurrences match. assertThat(result.get(0)).hasSize(3); } } @@ -148,7 +87,6 @@ class TextRedactionServiceExtraTest { try (PDDocument doc = buildDoc("abcde")) { Map> result = service.findTextToRedact(doc, new String[] {"[ae]"}, true, false); - // 'a' and 'e' both match -> two single-character hits. assertThat(result.get(0)).hasSize(2); } } @@ -163,436 +101,4 @@ class TextRedactionServiceExtraTest { } } } - - // ── createTokensWithoutTargetText structural branches ──────────────────────────────────────── - - @Nested - @DisplayName("createTokensWithoutTargetText structural branches") - class TokenStructural { - - @Test - @DisplayName("page with null resources still parses and redacts the matched Tj text") - void nullResourcesStillRedacts() throws IOException { - try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - // No resources set; the content stream references no real font. - String raw = "BT 72 700 Td (SECRET) Tj ET"; - PDStream stream = new PDStream(doc); - try (var out = stream.createOutputStream()) { - out.write(raw.getBytes(StandardCharsets.ISO_8859_1)); - } - page.setContents(stream); - - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - assertThat(tokensText(tokens)).doesNotContain("SECRET"); - } - } - - @Test - @DisplayName("a match inside a Tj segment is redacted and surrounding text survives") - void multipleMatchesOneSegment() throws IOException { - try (PDDocument doc = docWithRawContent("BT /F1 12 Tf 72 700 Td (xAAxAAx) Tj ET")) { - PDPage page = doc.getPage(0); - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("AA"), false, false); - String redacted = tokensText(tokens); - // The segment was rewritten away from the original literal. - assertThat(redacted).isNotEqualTo("xAAxAAx"); - // Redaction replaces matched runs with whitespace, so at least one "AA" is gone - // (the leading occurrence) and the surrounding x characters survive. - assertThat(redacted.split("AA", -1).length - 1).isLessThan(2); - assertThat(redacted).startsWith("x "); - assertThat(redacted).contains("x"); - } - } - - @Test - @DisplayName("a second Tf operator updates the active font for later segments") - void secondTfUpdatesFont() throws IOException { - PDDocument doc = new PDDocument(); - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - PDResources resources = new PDResources(); - resources.put(COSName.getPDFName("F1"), helvetica()); - resources.put( - COSName.getPDFName("F2"), - new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN)); - page.setResources(resources); - String raw = "BT /F1 12 Tf 72 700 Td (first) Tj /F2 18 Tf 0 -20 Td (SECRET) Tj ET"; - PDStream stream = new PDStream(doc); - try (var out = stream.createOutputStream()) { - out.write(raw.getBytes(StandardCharsets.ISO_8859_1)); - } - page.setContents(stream); - try (doc) { - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - assertThat(tokensText(tokens)).doesNotContain("SECRET"); - assertThat(tokensText(tokens)).contains("first"); - } - } - } - - // ── nested Form XObject traversal ──────────────────────────────────────────────────────────── - - @Nested - @DisplayName("nested Form XObject traversal") - class NestedXObjects { - - @Test - @DisplayName("a match in a form nested two levels deep is reached and rewritten") - void nestedTwoLevelsDeep() throws IOException { - try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - - // Inner form shows SECRET. - PDFormXObject inner = new PDFormXObject(doc); - inner.setResources(new PDResources()); - inner.getResources().put(COSName.getPDFName("F1"), helvetica()); - inner.setBBox(new PDRectangle(0, 0, 200, 50)); - try (var out = inner.getStream().createOutputStream()) { - out.write( - "BT /F1 12 Tf 0 10 Td (SECRET) Tj ET" - .getBytes(StandardCharsets.ISO_8859_1)); - } - - // Outer form references inner via Do. - PDFormXObject outer = new PDFormXObject(doc); - PDResources outerRes = new PDResources(); - COSName innerName = outerRes.add(inner); - outer.setResources(outerRes); - outer.setBBox(new PDRectangle(0, 0, 200, 50)); - try (var out = outer.getStream().createOutputStream()) { - out.write( - ("/" + innerName.getName() + " Do") - .getBytes(StandardCharsets.ISO_8859_1)); - } - - PDResources pageRes = new PDResources(); - COSName outerName = pageRes.add(outer); - page.setResources(pageRes); - PDStream pageStream = new PDStream(doc); - try (var out = pageStream.createOutputStream()) { - out.write( - ("/" + outerName.getName() + " Do") - .getBytes(StandardCharsets.ISO_8859_1)); - } - page.setContents(pageStream); - - service.createTokensWithoutTargetText(doc, page, Set.of("SECRET"), false, false); - - // The deep traversal must have rewritten the inner form's content stream. - assertThat(inner.getCOSObject().containsKey(COSName.CONTENTS)).isTrue(); - } - } - - @Test - @DisplayName("a form XObject with no resources is skipped without error") - void formWithoutResourcesSkipped() throws IOException { - try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - - PDFormXObject form = new PDFormXObject(doc); - form.setBBox(new PDRectangle(0, 0, 100, 50)); - // Intentionally no resources on the form. - try (var out = form.getStream().createOutputStream()) { - out.write("q Q".getBytes(StandardCharsets.ISO_8859_1)); - } - - PDResources pageRes = new PDResources(); - COSName formName = pageRes.add(form); - page.setResources(pageRes); - PDStream pageStream = new PDStream(doc); - try (var out = pageStream.createOutputStream()) { - out.write( - ("/" + formName.getName() + " Do") - .getBytes(StandardCharsets.ISO_8859_1)); - } - page.setContents(pageStream); - - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - assertThat(tokens).isNotNull(); - } - } - } - - // ── kerning / adjustment path in modifyTokenForRedaction ───────────────────────────────────── - - @Nested - @DisplayName("modifyTokenForRedaction adjustment branches") - class ModifyTokenAdjustment { - - @Test - @DisplayName("a non-zero width adjustment rewrites a Tj into a TJ array with kerning") - void adjustmentRewritesToTjArray() throws Exception { - List tokens = new ArrayList<>(); - tokens.add(new COSString("KEEP")); - tokens.add(Operator.getOperator("Tj")); - - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "Tj", "KEEP", 0, 4, helvetica(), FONT_SIZE); - - Method m = - TextRedactionService.class.getDeclaredMethod( - "modifyTokenForRedaction", - List.class, - TextRedactionService.TextSegment.class, - String.class, - float.class, - List.class); - m.setAccessible(true); - // A clearly non-zero adjustment forces the COSArray + kerning branch. - m.invoke(service, tokens, segment, "AB", 5.0f, List.of()); - - assertThat(tokens.get(0)).isInstanceOf(COSArray.class); - COSArray arr = (COSArray) tokens.get(0); - boolean hasKern = false; - for (COSBase el : arr) { - if (el instanceof COSFloat) { - hasKern = true; - } - } - assertThat(hasKern).as("kerning float should be appended to the TJ array").isTrue(); - // The trailing Tj operator should have been switched to TJ. - assertThat(tokens.get(1)).isInstanceOf(Operator.class); - assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ"); - } - - @Test - @DisplayName("empty replacement text with ~zero adjustment sets the shared empty COSString") - void emptyReplacementZeroAdjustment() throws Exception { - List tokens = new ArrayList<>(); - tokens.add(new COSString("SECRET")); - tokens.add(Operator.getOperator("Tj")); - - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "Tj", "SECRET", 0, 6, helvetica(), FONT_SIZE); - - Method m = - TextRedactionService.class.getDeclaredMethod( - "modifyTokenForRedaction", - List.class, - TextRedactionService.TextSegment.class, - String.class, - float.class, - List.class); - m.setAccessible(true); - m.invoke(service, tokens, segment, "", 0f, List.of()); - - assertThat(tokens.get(0)).isInstanceOf(COSString.class); - assertThat(((COSString) tokens.get(0)).getString()).isEmpty(); - } - - @Test - @DisplayName("the ' operator with a non-zero adjustment is also rewritten to a TJ array") - void apostropheAdjustmentRewrites() throws Exception { - List tokens = new ArrayList<>(); - tokens.add(new COSString("WORD")); - tokens.add(Operator.getOperator("'")); - - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "'", "WORD", 0, 4, helvetica(), FONT_SIZE); - - Method m = - TextRedactionService.class.getDeclaredMethod( - "modifyTokenForRedaction", - List.class, - TextRedactionService.TextSegment.class, - String.class, - float.class, - List.class); - m.setAccessible(true); - m.invoke(service, tokens, segment, "X", 4.0f, List.of()); - - assertThat(tokens.get(0)).isInstanceOf(COSArray.class); - assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ"); - } - } - - // ── createRedactedTJArray edge branches ────────────────────────────────────────────────────── - - @Nested - @DisplayName("createRedactedTJArray edge branches") - class RedactedTjArray { - - @Test - @DisplayName("non-COSString elements (kerning numbers) are preserved in order") - void preservesNumberElements() throws Exception { - COSArray original = new COSArray(); - original.add(new COSString("AA")); - original.add(new COSFloat(-25f)); - original.add(new COSString("BB")); - - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "TJ", "AABB", 0, 4, helvetica(), FONT_SIZE); - List matches = - List.of(new TextRedactionService.MatchRange(0, 2)); // "AA" - - Method m = - TextRedactionService.class.getDeclaredMethod( - "createRedactedTJArray", - COSArray.class, - TextRedactionService.TextSegment.class, - List.class); - m.setAccessible(true); - COSArray result = (COSArray) m.invoke(service, original, segment, matches); - - boolean sawFloat = false; - for (COSBase el : result) { - if (el instanceof COSFloat) { - sawFloat = true; - } - } - assertThat(sawFloat).as("original kerning number must be retained").isTrue(); - } - - @Test - @DisplayName("a TJ array with no overlapping match is returned essentially unchanged") - void noMatchLeavesTextIntact() throws Exception { - COSArray original = new COSArray(); - original.add(new COSString("hello")); - original.add(new COSString("world")); - - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "TJ", "helloworld", 0, 10, helvetica(), FONT_SIZE); - List matches = - List.of(new TextRedactionService.MatchRange(50, 60)); // out of range - - Method m = - TextRedactionService.class.getDeclaredMethod( - "createRedactedTJArray", - COSArray.class, - TextRedactionService.TextSegment.class, - List.class); - m.setAccessible(true); - COSArray result = (COSArray) m.invoke(service, original, segment, matches); - - StringBuilder sb = new StringBuilder(); - for (COSBase el : result) { - if (el instanceof COSString cs) sb.append(cs.getString()); - } - assertThat(sb.toString()).isEqualTo("helloworld"); - } - } - - // ── private width helpers via reflection ───────────────────────────────────────────────────── - - @Nested - @DisplayName("private width helpers via reflection") - class WidthHelpers { - - private float invokeFloat(String name, Object... args) throws Exception { - Class[] types = new Class[] {PDFont.class, String.class}; - Method m = TextRedactionService.class.getDeclaredMethod(name, types); - m.setAccessible(true); - return (float) m.invoke(service, args); - } - - @Test - @DisplayName("calculateConservativeWidth scales linearly at 500 units per character") - void conservativeWidthLinear() throws Exception { - float w = invokeFloat("calculateConservativeWidth", helvetica(), "abcd"); - assertThat(w).isEqualTo(4 * 500f); - } - - @Test - @DisplayName("calculateCharacterBasedWidth returns a positive width for normal text") - void characterBasedWidthPositive() throws Exception { - float w = invokeFloat("calculateCharacterBasedWidth", helvetica(), "Hello"); - assertThat(w).isGreaterThan(0f); - } - - @Test - @DisplayName("calculateFallbackWidth returns a positive width using font metrics") - void fallbackWidthPositive() throws Exception { - float w = invokeFloat("calculateFallbackWidth", helvetica(), "Hello"); - assertThat(w).isGreaterThan(0f); - } - - @Test - @DisplayName("safeGetStringWidth returns 0 for null/empty inputs") - void safeWidthZeroForEmpty() throws Exception { - assertThat(invokeFloat("safeGetStringWidth", helvetica(), "")).isZero(); - Method m = - TextRedactionService.class.getDeclaredMethod( - "safeGetStringWidth", PDFont.class, String.class); - m.setAccessible(true); - assertThat((float) m.invoke(service, helvetica(), null)).isZero(); - assertThat((float) m.invoke(service, (PDFont) null, "x")).isZero(); - } - - @Test - @DisplayName("safeGetStringWidth returns a positive width for a reliable font") - void safeWidthPositive() throws Exception { - float w = invokeFloat("safeGetStringWidth", helvetica(), "Word"); - assertThat(w).isGreaterThan(0f); - } - } - - // ── createAlternativePlaceholder via reflection ────────────────────────────────────────────── - - @Nested - @DisplayName("createAlternativePlaceholder via reflection") - class AlternativePlaceholder { - - @Test - @DisplayName("Helvetica supports space, so output is a bounded run of spaces") - void boundedSpaces() throws Exception { - Method m = - TextRedactionService.class.getDeclaredMethod( - "createAlternativePlaceholder", - String.class, - float.class, - PDFont.class, - float.class); - m.setAccessible(true); - String result = (String) m.invoke(service, "hidden", 20f, helvetica(), FONT_SIZE); - assertThat(result.chars().allMatch(c -> c == ' ')).isTrue(); - assertThat(result.length()).isLessThanOrEqualTo("hidden".length() * 2); - } - } - - // ── extractTextSegments via reflection ─────────────────────────────────────────────────────── - - @Nested - @DisplayName("extractTextSegments via reflection") - class ExtractSegments { - - @SuppressWarnings("unchecked") - @Test - @DisplayName("a Tf operator sets font and size on the segments that follow it") - void tfSetsFontAndSize() throws Exception { - try (PDDocument doc = docWithRawContent("BT /F1 14 Tf 72 700 Td (hello) Tj ET")) { - PDPage page = doc.getPage(0); - List tokens = parseTokens(page); - - Method m = - TextRedactionService.class.getDeclaredMethod( - "extractTextSegments", PDPage.class, List.class); - m.setAccessible(true); - List segments = - (List) m.invoke(service, page, tokens); - - assertThat(segments).isNotEmpty(); - TextRedactionService.TextSegment first = segments.get(0); - assertThat(first.getText()).isEqualTo("hello"); - assertThat(first.getFontSize()).isEqualTo(14f); - assertThat(first.getFont()).isNotNull(); - } - } - } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java index ba0377a9e8..e0408bae55 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java @@ -3,40 +3,24 @@ package stirling.software.SPDF.controller.api.security; import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; -import java.lang.reflect.Method; -import java.util.ArrayList; +import java.io.InputStream; import java.util.List; import java.util.Map; -import java.util.Set; -import org.apache.pdfbox.cos.COSArray; -import org.apache.pdfbox.cos.COSBase; -import org.apache.pdfbox.cos.COSFloat; -import org.apache.pdfbox.cos.COSName; -import org.apache.pdfbox.cos.COSString; -import org.apache.pdfbox.pdfparser.PDFStreamParser; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.common.PDRectangle; -import org.apache.pdfbox.pdmodel.common.PDStream; import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; -import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import stirling.software.SPDF.model.PDFText; -/** - * Gap-coverage tests for {@link TextRedactionService} targeting branches the existing {@code - * TextRedactionServiceTest} does not reach: TJ-array redaction with kerning adjustment, the {@code - * '} and {@code "} text-showing operators, Form XObject content rewriting, multi-page / multi-match - * find+replace, and the private TJ/segment helpers exercised directly via reflection. - */ @DisplayName("TextRedactionService additional coverage") class TextRedactionServiceMoreTest { @@ -50,201 +34,16 @@ class TextRedactionServiceMoreTest { return new PDType1Font(Standard14Fonts.FontName.HELVETICA); } - private List parseTokens(PDPage page) throws IOException { - PDFStreamParser parser = new PDFStreamParser(page); - List tokens = new ArrayList<>(); - Object t; - while ((t = parser.parseNextToken()) != null) { - tokens.add(t); - } - return tokens; - } - - private String tokensText(List tokens) { - StringBuilder sb = new StringBuilder(); - for (Object token : tokens) { - if (token instanceof COSString cs) { - sb.append(cs.getString()); - } else if (token instanceof COSArray arr) { - for (COSBase el : arr) { - if (el instanceof COSString cs) { - sb.append(cs.getString()); - } - } + private PDFont helvetica(PDDocument doc) throws IOException { + try (InputStream is = + getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) { + if (is != null) { + return PDType0Font.load(doc, is); } } - return sb.toString(); + return helvetica(); } - /** - * Builds a single page whose content stream is exactly {@code rawContent}, font F1=Helvetica. - */ - private PDDocument docWithRawContent(String rawContent) throws IOException { - PDDocument doc = new PDDocument(); - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - PDResources resources = new PDResources(); - resources.put(COSName.getPDFName("F1"), helvetica()); - page.setResources(resources); - - PDStream stream = new PDStream(doc); - try (var out = stream.createOutputStream()) { - out.write(rawContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); - } - page.setContents(stream); - return doc; - } - - // ── ' and " operators ──────────────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("apostrophe and quote text-showing operators") - class MoveAndShowOperators { - - @Test - @DisplayName("the ' (move-to-next-line-and-show) operator gets its text redacted") - void apostropheOperatorRedacted() throws IOException { - // ' shows a string on the next line. Content: BT /F1 12 Tf 72 700 Td (PUBLIC) Tj - // (SECRET) ' ET - String raw = "BT /F1 12 Tf 72 700 Td (PUBLIC) Tj (SECRET) ' ET"; - try (PDDocument doc = docWithRawContent(raw)) { - PDPage page = doc.getPage(0); - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - String text = tokensText(tokens); - assertThat(text).doesNotContain("SECRET"); - assertThat(text).contains("PUBLIC"); - } - } - - @Test - @DisplayName("the \" operator is collected as text-showing but its text is not extracted") - void quoteOperatorNotExtracted() throws IOException { - // " is in TEXT_SHOWING_OPERATORS, but extractTextFromToken's switch only handles - // Tj/'/TJ, so a "-shown string yields no segment and survives. This pins that - // behavior: the parse path runs without error and the token list is intact. - String raw = "BT /F1 12 Tf 72 700 Td 1 2 (SECRET) \" ET"; - try (PDDocument doc = docWithRawContent(raw)) { - PDPage page = doc.getPage(0); - List before = parseTokens(page); - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - assertThat(tokens).hasSameSizeAs(before); - assertThat(tokensText(tokens)).contains("SECRET"); - } - } - } - - // ── TJ arrays with kerning ─────────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("TJ positioning arrays") - class TjArrays { - - @Test - @DisplayName("partial match inside a TJ array redacts only the matched run") - void tjArrayPartialRedaction() throws IOException { - try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(helvetica(), FONT_SIZE); - cs.beginText(); - cs.newLineAtOffset(LEFT_X, TOP_Y); - // showTextWithPositioning emits a single TJ array. - cs.showTextWithPositioning( - new Object[] {"keep ", -50f, "SECRET", 20f, " tail"}); - cs.endText(); - } - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - - boolean sawTj = tokens.stream().anyMatch(t -> t instanceof COSArray); - assertThat(sawTj).as("expected a TJ array token").isTrue(); - assertThat(tokensText(tokens)).doesNotContain("SECRET"); - assertThat(tokensText(tokens)).contains("keep"); - } - } - - @Test - @DisplayName("TJ array with no matching term is left unchanged") - void tjArrayNoMatch() throws IOException { - try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(helvetica(), FONT_SIZE); - cs.beginText(); - cs.newLineAtOffset(LEFT_X, TOP_Y); - cs.showTextWithPositioning(new Object[] {"alpha ", -30f, "beta"}); - cs.endText(); - } - List before = parseTokens(page); - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("ZZZ"), false, false); - assertThat(tokens).hasSameSizeAs(before); - assertThat(tokensText(tokens)).contains("alpha"); - } - } - } - - // ── Form XObject traversal ─────────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("Form XObject content") - class FormXObjects { - - @Test - @DisplayName("a referenced Form XObject containing a match is traversed and rewritten") - void traversesFormXObject() throws IOException { - try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.LETTER); - doc.addPage(page); - - // Build a form XObject whose own content stream shows "SECRET". - PDFormXObject form = new PDFormXObject(doc); - form.setResources(new PDResources()); - form.getResources().put(COSName.getPDFName("F1"), helvetica()); - form.setBBox(new PDRectangle(0, 0, 200, 50)); - String formContent = "BT /F1 12 Tf 0 10 Td (SECRET) Tj ET"; - try (var out = form.getStream().createOutputStream()) { - out.write(formContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); - } - - PDResources pageResources = new PDResources(); - COSName formName = pageResources.add(form); - page.setResources(pageResources); - - String pageContent = "q 1 0 0 1 100 600 cm /" + formName.getName() + " Do Q"; - PDStream pageStream = new PDStream(doc); - try (var out = pageStream.createOutputStream()) { - out.write(pageContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); - } - page.setContents(pageStream); - - // Processing the page walks into the XObject graph; when a match is found inside - // the - // form, writeRedactedContentToXObject runs and sets a /Contents item on the form's - // COS dictionary. Asserting that item appears proves the XObject redaction path - // executed end-to-end without throwing. - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - - assertThat(tokens).isNotNull(); - assertThat(form.getCOSObject().containsKey(COSName.CONTENTS)) - .as("form XObject redaction path should have written a new content item") - .isTrue(); - } - } - } - - // ── multi-page / multi-match public entry points ───────────────────────────────────────────── - @Nested @DisplayName("findTextToRedact and performTextReplacement across pages") class MultiPage { @@ -256,7 +55,7 @@ class TextRedactionServiceMoreTest { PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(helvetica(), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); cs.beginText(); cs.newLineAtOffset(LEFT_X, TOP_Y); cs.showText(line); @@ -326,225 +125,32 @@ class TextRedactionServiceMoreTest { } } - // ── private TJ / segment helpers via reflection ────────────────────────────────────────────── - @Nested - @DisplayName("private helpers via reflection") - class PrivateHelpers { - - @Test - @DisplayName("createRedactedTJArray replaces the matched substring inside the array") - void createRedactedTjArray() throws Exception { - COSArray original = new COSArray(); - original.add(new COSString("SECRET")); - original.add(new COSFloat(-40f)); - original.add(new COSString(" tail")); - - // Segment text is the concatenation "SECRET tail"; startPos 0. - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "TJ", "SECRET tail", 0, 11, helvetica(), FONT_SIZE); - List matches = - List.of(new TextRedactionService.MatchRange(0, 6)); // "SECRET" - - Method m = - TextRedactionService.class.getDeclaredMethod( - "createRedactedTJArray", - COSArray.class, - TextRedactionService.TextSegment.class, - List.class); - m.setAccessible(true); - COSArray result = (COSArray) m.invoke(service, original, segment, matches); - - StringBuilder sb = new StringBuilder(); - for (COSBase el : result) { - if (el instanceof COSString cs) sb.append(cs.getString()); - } - assertThat(sb.toString()).doesNotContain("SECRET"); - assertThat(sb.toString()).contains("tail"); - } - - @Test - @DisplayName("applyRedactionsToSegmentText swaps the matched span for a placeholder") - void applyRedactionsToSegmentText() throws Exception { - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "Tj", "keepSECRETkeep", 0, 14, helvetica(), FONT_SIZE); - List matches = - List.of(new TextRedactionService.MatchRange(4, 10)); // SECRET - - Method m = - TextRedactionService.class.getDeclaredMethod( - "applyRedactionsToSegmentText", - TextRedactionService.TextSegment.class, - List.class); - m.setAccessible(true); - String out = (String) m.invoke(service, segment, matches); - assertThat(out).doesNotContain("SECRET"); - assertThat(out).startsWith("keep"); - assertThat(out).endsWith("keep"); - } - - @Test - @DisplayName("calculateWidthAdjustment returns 0 for a null-font segment") - void widthAdjustmentNullFont() throws Exception { - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment(0, "Tj", "abc", 0, 3, null, FONT_SIZE); - Method m = - TextRedactionService.class.getDeclaredMethod( - "calculateWidthAdjustment", - TextRedactionService.TextSegment.class, - List.class); - m.setAccessible(true); - float adj = - (float) - m.invoke( - service, - segment, - List.of(new TextRedactionService.MatchRange(0, 3))); - assertThat(adj).isZero(); - } - - @Test - @DisplayName("calculateWidthAdjustment skips subset fonts (returns 0)") - void widthAdjustmentSubsetFontSkipped() throws Exception { - // A subset font name (6 uppercase letters + '+') trips the subset short-circuit. - PDFont subsetNamed = new PDType1Font(Standard14Fonts.FontName.HELVETICA); - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 0, "Tj", "ABCDEF", 0, 6, subsetNamed, FONT_SIZE); - - // The real Helvetica name is not a subset, so this segment goes through the normal - // calculation; assert the call is at least exception-free and finite. - Method m = - TextRedactionService.class.getDeclaredMethod( - "calculateWidthAdjustment", - TextRedactionService.TextSegment.class, - List.class); - m.setAccessible(true); - float adj = - (float) - m.invoke( - service, - segment, - List.of(new TextRedactionService.MatchRange(0, 6))); - assertThat(Float.isFinite(adj)).isTrue(); - } - - @Test - @DisplayName("modifyTokenForRedaction with an out-of-range token index is a no-op") - void modifyTokenOutOfRange() throws Exception { - List tokens = new ArrayList<>(); - tokens.add(new COSString("hello")); - TextRedactionService.TextSegment segment = - new TextRedactionService.TextSegment( - 99, "Tj", "hello", 0, 5, helvetica(), FONT_SIZE); - - Method m = - TextRedactionService.class.getDeclaredMethod( - "modifyTokenForRedaction", - List.class, - TextRedactionService.TextSegment.class, - String.class, - float.class, - List.class); - m.setAccessible(true); - m.invoke(service, tokens, segment, "", 0f, List.of()); - - // Token list is untouched because index 99 is out of bounds. - assertThat(tokens).hasSize(1); - assertThat(((COSString) tokens.get(0)).getString()).isEqualTo("hello"); - } - - @Test - @DisplayName("buildCompleteText concatenates the text of all segments in order") - void buildCompleteText() throws Exception { - List segments = - List.of( - new TextRedactionService.TextSegment( - 0, "Tj", "foo", 0, 3, helvetica(), FONT_SIZE), - new TextRedactionService.TextSegment( - 1, "Tj", "bar", 3, 6, helvetica(), FONT_SIZE)); - Method m = - TextRedactionService.class.getDeclaredMethod("buildCompleteText", List.class); - m.setAccessible(true); - assertThat(m.invoke(service, segments)).isEqualTo("foobar"); - } - - @Test - @DisplayName("extractTextFromToken returns text for the \" operator") - void extractTextFromQuoteOperator() throws Exception { - Method m = - TextRedactionService.class.getDeclaredMethod( - "extractTextFromToken", Object.class, String.class); - m.setAccessible(true); - // The " operator is not in the switch (Tj/'/TJ) -> default branch yields empty string. - assertThat(m.invoke(service, new COSString("x"), "\"")).isEqualTo(""); - } - } - - // ── createPlaceholderWithWidth additional branches ─────────────────────────────────────────── - - @Nested - @DisplayName("createPlaceholderWithWidth reliable-font path") - class PlaceholderWidthBranches { - - @Test - @DisplayName("reliable font with positive width yields a bounded run of spaces") - void reliableFontBoundedSpaces() { - PDFont font = helvetica(); - String original = "Secret"; - float targetWidth; - try { - targetWidth = font.getStringWidth(original) / 1000f * FONT_SIZE; - } catch (IOException e) { - targetWidth = 30f; - } - String placeholder = - service.createPlaceholderWithWidth(original, targetWidth, font, FONT_SIZE); - assertThat(placeholder).isNotEmpty(); - assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue(); - // spaceCount is capped at originalLength*2. - assertThat(placeholder.length()).isLessThanOrEqualTo(original.length() * 2); - } - - @Test - @DisplayName("zero target width falls back to alternative placeholder logic") - void zeroTargetWidth() { - PDFont font = helvetica(); - String placeholder = service.createPlaceholderWithWidth("word", 0f, font, FONT_SIZE); - // With a reliable, non-subset font and zero width, output is still all whitespace. - assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue(); - } - } - - // ── inner data classes ─────────────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("ModificationTask / GraphicsState data classes") + @DisplayName("TextSegment / MatchRange data classes") class DataClasses { @Test - @DisplayName("GraphicsState defaults are null font and zero size, mutators round-trip") - void graphicsStateRoundTrip() throws Exception { - Class gsClass = - Class.forName( - "stirling.software.SPDF.controller.api.security.TextRedactionService$GraphicsState"); - var ctor = gsClass.getDeclaredConstructor(); - ctor.setAccessible(true); - Object gs = ctor.newInstance(); + @DisplayName("TextSegment exposes its constructor values via accessors") + void textSegmentAccessors() { + PDFont font = helvetica(); + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment(3, "Tj", "hello", 10, 15, font, 12f); - Method getFont = gsClass.getDeclaredMethod("getFont"); - Method getSize = gsClass.getDeclaredMethod("getFontSize"); - getFont.setAccessible(true); - getSize.setAccessible(true); - assertThat(getFont.invoke(gs)).isNull(); - assertThat((float) getSize.invoke(gs)).isZero(); + assertThat(segment.getTokenIndex()).isEqualTo(3); + assertThat(segment.getOperatorName()).isEqualTo("Tj"); + assertThat(segment.getText()).isEqualTo("hello"); + assertThat(segment.getStartPos()).isEqualTo(10); + assertThat(segment.getEndPos()).isEqualTo(15); + assertThat(segment.getFont()).isSameAs(font); + assertThat(segment.getFontSize()).isEqualTo(12f); + } - Method setSize = gsClass.getDeclaredMethod("setFontSize", float.class); - setSize.setAccessible(true); - setSize.invoke(gs, 14f); - assertThat((float) getSize.invoke(gs)).isEqualTo(14f); + @Test + @DisplayName("MatchRange exposes start and end positions") + void matchRangeAccessors() { + TextRedactionService.MatchRange range = new TextRedactionService.MatchRange(4, 9); + assertThat(range.getStartPos()).isEqualTo(4); + assertThat(range.getEndPos()).isEqualTo(9); } } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java index d487848e09..a47f2c15ab 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java @@ -2,30 +2,21 @@ package stirling.software.SPDF.controller.api.security; 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.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Collections; +import java.io.InputStream; import java.util.HashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Set; -import org.apache.pdfbox.contentstream.operator.Operator; -import org.apache.pdfbox.cos.COSArray; -import org.apache.pdfbox.cos.COSString; -import org.apache.pdfbox.pdfparser.PDFStreamParser; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.DisplayName; @@ -54,13 +45,23 @@ class TextRedactionServiceTest { return new PDType1Font(Standard14Fonts.FontName.HELVETICA); } + private PDFont helvetica(PDDocument doc) throws IOException { + try (InputStream is = + getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) { + if (is != null) { + return PDType0Font.load(doc, is); + } + } + return helvetica(); + } + /** Single page, single Tj line per supplied text line, Helvetica 12. */ private PDDocument buildDoc(String... lines) throws IOException { PDDocument doc = new PDDocument(); PDPage page = new PDPage(PDRectangle.LETTER); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setFont(helvetica(), FONT_SIZE); + cs.setFont(helvetica(doc), FONT_SIZE); for (int i = 0; i < lines.length; i++) { cs.beginText(); cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f); @@ -77,32 +78,6 @@ class TextRedactionServiceTest { return doc; } - // ── isTextShowingOperator ──────────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("isTextShowingOperator") - class IsTextShowingOperator { - - @Test - @DisplayName("recognises the four text-showing operators") - void recognisesTextShowingOperators() { - assertTrue(service.isTextShowingOperator("Tj")); - assertTrue(service.isTextShowingOperator("TJ")); - assertTrue(service.isTextShowingOperator("'")); - assertTrue(service.isTextShowingOperator("\"")); - } - - @Test - @DisplayName("rejects non text-showing operators and junk") - void rejectsOthers() { - assertFalse(service.isTextShowingOperator("BT")); - assertFalse(service.isTextShowingOperator("ET")); - assertFalse(service.isTextShowingOperator("Tf")); - assertFalse(service.isTextShowingOperator("")); - assertFalse(service.isTextShowingOperator("tj")); - } - } - // ── findTextToRedact ───────────────────────────────────────────────────────────────────────── @Nested @@ -253,192 +228,6 @@ class TextRedactionServiceTest { } } - // ── detectCustomEncodingFonts ──────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("detectCustomEncodingFonts") - class DetectCustomEncodingFonts { - - @Test - @DisplayName("standard Helvetica document is not flagged as custom-encoded") - void standardFontNotFlagged() throws IOException { - try (PDDocument doc = buildDoc("plain helvetica text")) { - assertFalse(service.detectCustomEncodingFonts(doc)); - } - } - - @Test - @DisplayName("document with no content / no fonts is not flagged") - void emptyDocumentNotFlagged() throws IOException { - try (PDDocument doc = buildEmptyDoc()) { - assertFalse(service.detectCustomEncodingFonts(doc)); - } - } - } - - // ── createPlaceholderWithFont ──────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("createPlaceholderWithFont") - class CreatePlaceholderWithFont { - - @Test - @DisplayName("returns the input unchanged for null") - void nullReturnsNull() { - assertNull(service.createPlaceholderWithFont(null, helvetica())); - } - - @Test - @DisplayName("returns the input unchanged for empty string") - void emptyReturnsEmpty() { - assertEquals("", service.createPlaceholderWithFont("", helvetica())); - } - - @Test - @DisplayName("non-subset font yields spaces matching the original length") - void nonSubsetFontYieldsMatchingSpaces() { - String placeholder = service.createPlaceholderWithFont("hidden", helvetica()); - assertEquals(" ".repeat("hidden".length()), placeholder); - } - - @Test - @DisplayName("null font is treated as non-subset and yields spaces") - void nullFontYieldsSpaces() { - String placeholder = service.createPlaceholderWithFont("abc", null); - assertEquals(" ", placeholder); - } - } - - // ── createPlaceholderWithWidth ─────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("createPlaceholderWithWidth") - class CreatePlaceholderWithWidth { - - @Test - @DisplayName("returns the input unchanged for null") - void nullReturnsNull() { - assertNull(service.createPlaceholderWithWidth(null, 10f, helvetica(), FONT_SIZE)); - } - - @Test - @DisplayName("returns the input unchanged for empty string") - void emptyReturnsEmpty() { - assertEquals("", service.createPlaceholderWithWidth("", 10f, helvetica(), FONT_SIZE)); - } - - @Test - @DisplayName("null font falls back to one space per original character") - void nullFontFallsBackToSpaces() { - String placeholder = service.createPlaceholderWithWidth("word", 50f, null, FONT_SIZE); - assertEquals(" ".repeat("word".length()), placeholder); - } - - @Test - @DisplayName("non-positive font size falls back to one space per original character") - void nonPositiveFontSizeFallsBackToSpaces() { - String placeholder = service.createPlaceholderWithWidth("word", 50f, helvetica(), 0f); - assertEquals(" ".repeat("word".length()), placeholder); - } - - @Test - @DisplayName("standard font produces a non-null all-whitespace placeholder") - void standardFontProducesWhitespacePlaceholder() { - PDFont font = helvetica(); - float fontSize = FONT_SIZE; - String original = "Secret"; - // Compute a realistic target width the way the service does (text-space / 1000 * size). - float targetWidth; - try { - targetWidth = font.getStringWidth(original) / 1000f * fontSize; - } catch (IOException e) { - targetWidth = 30f; - } - - String placeholder = - service.createPlaceholderWithWidth(original, targetWidth, font, fontSize); - - assertNotNull(placeholder); - assertFalse(placeholder.isEmpty(), "Helvetica supports spaces, so non-empty expected"); - assertTrue( - placeholder.chars().allMatch(c -> c == ' '), - "placeholder should be composed only of spaces"); - } - } - - // ── createTokensWithoutTargetText / writeFilteredContentStream - // ──────────────────────────────── - - @Nested - @DisplayName("createTokensWithoutTargetText") - class CreateTokensWithoutTargetText { - - @Test - @DisplayName( - "returns a non-empty token list and preserves token count when nothing matches") - void noMatchPreservesTokens() throws IOException { - try (PDDocument doc = buildDoc("nothing to hide")) { - PDPage page = doc.getPage(0); - List originalTokens = parseTokens(page); - - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("ABSENT"), false, false); - - assertNotNull(tokens); - assertEquals( - originalTokens.size(), - tokens.size(), - "token count should be unchanged when nothing matched"); - } - } - - @Test - @DisplayName("filtered tokens can be written back and the page re-parses cleanly") - void filteredTokensRoundTrip() throws IOException { - try (PDDocument doc = buildDoc("redact SECRET token roundtrip")) { - PDPage page = doc.getPage(0); - - List tokens = - service.createTokensWithoutTargetText( - doc, page, Set.of("SECRET"), false, false); - assertNotNull(tokens); - - service.writeFilteredContentStream(doc, page, tokens); - - // The page must still hold valid content (at least one operator token). - List reparsed = parseTokens(page); - boolean hasOperator = reparsed.stream().anyMatch(t -> t instanceof Operator); - assertTrue(hasOperator, "rewritten content stream must contain operators"); - } - } - - @Test - @DisplayName("empty target-word set leaves tokens untouched") - void emptyTargetSetLeavesTokens() throws IOException { - try (PDDocument doc = buildDoc("some content")) { - PDPage page = doc.getPage(0); - List originalTokens = parseTokens(page); - - List tokens = - service.createTokensWithoutTargetText( - doc, page, Collections.emptySet(), false, false); - - assertEquals(originalTokens.size(), tokens.size()); - } - } - - private List parseTokens(PDPage page) throws IOException { - PDFStreamParser parser = new PDFStreamParser(page); - List tokens = new ArrayList<>(); - Object token; - while ((token = parser.parseNextToken()) != null) { - tokens.add(token); - } - return tokens; - } - } - // ── inner data classes ─────────────────────────────────────────────────────────────────────── @Nested @@ -484,83 +273,4 @@ class TextRedactionServiceTest { assertFalse(a.equals(b)); } } - - // ── private logic exercised via reflection ─────────────────────────────────────────────────── - - @Nested - @DisplayName("findAllMatches / buildCompleteText (private logic via reflection)") - class PrivateLogic { - - @Test - @DisplayName("findAllMatches returns sorted, non-overlapping match ranges for two terms") - @SuppressWarnings("unchecked") - void findAllMatchesSorted() throws Exception { - String complete = "alpha beta gamma beta"; - Set terms = new LinkedHashSet<>(List.of("beta", "alpha")); - - Method m = - TextRedactionService.class.getDeclaredMethod( - "findAllMatches", - String.class, - Set.class, - boolean.class, - boolean.class); - m.setAccessible(true); - List matches = - (List) - m.invoke(service, complete, terms, false, false); - - assertNotNull(matches); - assertFalse(matches.isEmpty()); - // Results are sorted by start position. - for (int i = 1; i < matches.size(); i++) { - assertTrue( - matches.get(i - 1).getStartPos() <= matches.get(i).getStartPos(), - "matches must be sorted ascending by start position"); - } - // "alpha" at 0, "beta" at 6 and 17 -> three matches total. - assertEquals(3, matches.size()); - assertEquals(0, matches.get(0).getStartPos()); - } - - @Test - @DisplayName("findAllMatches returns nothing when no term occurs") - @SuppressWarnings("unchecked") - void findAllMatchesEmptyWhenAbsent() throws Exception { - Method m = - TextRedactionService.class.getDeclaredMethod( - "findAllMatches", - String.class, - Set.class, - boolean.class, - boolean.class); - m.setAccessible(true); - List matches = - (List) - m.invoke(service, "no terms here", Set.of("XYZ"), false, false); - assertTrue(matches.isEmpty()); - } - - @Test - @DisplayName("extractTextFromToken pulls text from Tj COSString and TJ COSArray") - void extractTextFromToken() throws Exception { - Method m = - TextRedactionService.class.getDeclaredMethod( - "extractTextFromToken", Object.class, String.class); - m.setAccessible(true); - - assertEquals("hi", m.invoke(service, new COSString("hi"), "Tj")); - assertEquals("hi", m.invoke(service, new COSString("hi"), "'")); - - COSArray tjArray = new COSArray(); - tjArray.add(new COSString("foo")); - tjArray.add(new COSString("bar")); - assertEquals("foobar", m.invoke(service, tjArray, "TJ")); - - // Unknown operator yields empty string. - assertEquals("", m.invoke(service, new COSString("x"), "Td")); - // Wrong token type for the operator yields empty string. - assertEquals("", m.invoke(service, new COSArray(), "Tj")); - } - } } diff --git a/app/core/src/test/java/stirling/software/SPDF/util/ExtractScriptConcurrencyTest.java b/app/core/src/test/java/stirling/software/SPDF/util/ExtractScriptConcurrencyTest.java new file mode 100644 index 0000000000..f0c3ff6f95 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/util/ExtractScriptConcurrencyTest.java @@ -0,0 +1,78 @@ +package stirling.software.SPDF.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.util.GeneralUtils; + +/** + * Lives in core because the python resources it extracts ship in this module. + * + *

Callers hand the returned path straight to python3, so the file has to stay readable while + * other requests are extracting it. Re-writing it per call used to break that wherever rename is + * not atomic, such as a 9p or NFS bind mount. + */ +class ExtractScriptConcurrencyTest { + + @Test + @DisplayName("concurrent callers always get a readable script") + void concurrentCallersSeeAReadableScript() throws Exception { + int threads = 16; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + AtomicInteger unreadable = new AtomicInteger(); + AtomicInteger errors = new AtomicInteger(); + + for (int i = 0; i < threads; i++) { + Thread.ofVirtual() + .start( + () -> { + try { + start.await(); + for (int n = 0; n < 25; n++) { + Path script = GeneralUtils.extractScript("png_to_webp.py"); + if (!Files.isReadable(script)) { + unreadable.incrementAndGet(); + } + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + + start.countDown(); + assertTrue(done.await(60, TimeUnit.SECONDS), "extractScript threads did not finish"); + assertEquals(0, errors.get(), "extractScript threw under concurrency"); + assertEquals(0, unreadable.get(), "script was missing while another caller held it"); + } + + @Test + @DisplayName("repeated calls return the same path without rewriting the file") + void repeatedCallsAreStable() throws Exception { + Path first = GeneralUtils.extractScript("png_to_webp.py"); + long modified = Files.getLastModifiedTime(first).toMillis(); + long size = Files.size(first); + + // Any rewrite after this pause lands on a later timestamp, so mtime is a + // reliable signal rather than a same-millisecond coin flip. + Thread.sleep(50); + for (int i = 0; i < 5; i++) { + assertEquals(first, GeneralUtils.extractScript("png_to_webp.py")); + } + + assertEquals(modified, Files.getLastModifiedTime(first).toMillis(), "script was rewritten"); + assertEquals(size, Files.size(first)); + } +} diff --git a/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java b/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java index 53212a2e65..2083e7339b 100644 --- a/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java +++ b/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java @@ -5,9 +5,11 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import java.util.Map; +import java.util.function.Predicate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -442,4 +444,52 @@ class JobControllerTest { assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); verify(fileStorage, never()).getFileSize(eq(fileId)); } + + @Test + void testCleanupFinishedJobs_ReportsWhatWasReleased() { + when(taskManager.cleanupFinishedJobsNow(any())) + .thenReturn(new TaskManager.CleanupSummary(2, 5, 1)); + + ResponseEntity response = controller.cleanupFinishedJobs(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertEquals(2, body.get("jobsRemoved")); + assertEquals(5, body.get("filesDeleted")); + assertEquals(1, body.get("jobsRetained")); + } + + @Test + void testCleanupFinishedJobs_OnlySweepsJobsTheCallerOwns() { + ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService); + when(jobOwnershipService.validateJobAccess("me:job")).thenReturn(true); + when(jobOwnershipService.validateJobAccess("someone-else:job")) + .thenThrow(new SecurityException("not yours")); + when(taskManager.cleanupFinishedJobsNow(any())) + .thenReturn(new TaskManager.CleanupSummary(1, 1, 1)); + + controller.cleanupFinishedJobs(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> filter = ArgumentCaptor.forClass(Predicate.class); + verify(taskManager).cleanupFinishedJobsNow(filter.capture()); + assertTrue(filter.getValue().test("me:job")); + assertFalse( + filter.getValue().test("someone-else:job"), + "A job the caller cannot access must be left in place"); + } + + @Test + void testCleanupFinishedJobs_SweepsEverythingWhenSecurityIsDisabled() { + when(taskManager.cleanupFinishedJobsNow(any())) + .thenReturn(new TaskManager.CleanupSummary(3, 3, 0)); + + controller.cleanupFinishedJobs(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> filter = ArgumentCaptor.forClass(Predicate.class); + verify(taskManager).cleanupFinishedJobsNow(filter.capture()); + assertTrue(filter.getValue().test("any-job-id")); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java index ef941c603f..fa9d6db141 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java @@ -7,6 +7,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; @@ -66,28 +67,40 @@ public class AdminJobController { } /** - * Manually trigger cleanup of old jobs (admin only) + * Manually trigger cleanup of old jobs (admin only). Covers every user's jobs, unlike the + * self-service {@code POST /api/v1/general/jobs/cleanup}, which only releases the caller's own. * - * @return A response indicating how many jobs were cleaned up + * @param force Ignore the retention window and release every finished job now, rather than only + * those already past it + * @return A response indicating how many jobs and files were cleaned up */ @PostMapping("/job/cleanup") - @Operation(summary = "Cleanup old jobs") + @Operation( + summary = "Cleanup old jobs", + description = + "Runs the job retention sweep now across all users. With force=true the" + + " retention window is ignored and every finished job is released" + + " immediately.") @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity cleanupOldJobs() { - int beforeCount = taskManager.getJobStats().getTotalJobs(); - taskManager.cleanupOldJobs(); - int afterCount = taskManager.getJobStats().getTotalJobs(); - int removedCount = beforeCount - afterCount; + public ResponseEntity cleanupOldJobs( + @RequestParam(name = "force", defaultValue = "false") boolean force) { + TaskManager.CleanupSummary summary = + force + ? taskManager.cleanupFinishedJobsNow(jobId -> true) + : taskManager.cleanupOldJobs(); log.info( - "Admin triggered job cleanup: removed {} jobs, {} remaining", - removedCount, - afterCount); + "Admin triggered job cleanup (force={}): removed {} jobs and {} files, {} remaining", + force, + summary.jobsRemoved(), + summary.filesDeleted(), + summary.jobsRetained()); return ResponseEntity.ok( Map.of( "message", "Cleanup complete", - "removedJobs", removedCount, - "remainingJobs", afterCount)); + "removedJobs", summary.jobsRemoved(), + "filesDeleted", summary.filesDeleted(), + "remainingJobs", summary.jobsRetained())); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/EditorFailureReport.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/EditorFailureReport.java new file mode 100644 index 0000000000..b200b3d589 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/EditorFailureReport.java @@ -0,0 +1,47 @@ +package stirling.software.proprietary.failure; + +import java.util.List; + +/** + * A failure a user hit in the editor, reported by their own client. The editor calls tools directly + * rather than through the policy engine, so nothing server-side sees these unless the client says + * so. + * + *

Note what the client cannot supply: no team, no actor, no document name. The first two come + * from the authenticated session, and the third is never stored. + * + * @param operation the tool that failed, e.g. {@code remove-password} + * @param errorCode the code from the tool's Problem Details response, or null when there was none + * @param fileIds opaque client-side ids of the documents involved; empty when none is attributable + * @param detail the message the user saw + */ +public record EditorFailureReport( + String operation, String errorCode, List fileIds, String detail) { + + /** + * Cap on the files one report may name. Each one becomes a permanent incident and this endpoint + * is open to any authenticated user, so without a bound a single call can flood a leader's + * queue. 200 is several times the largest batch an editor session plausibly fails on, and the + * most the review queue shows in one page, so a real report never meets it. + * + *

An oversized report is refused whole rather than trimmed: see {@link + * FileRunEventController#report}. + */ + static final int MAX_FILE_IDS = 200; + + public EditorFailureReport { + fileIds = fileIds == null ? List.of() : List.copyOf(fileIds); + } + + boolean hasOperation() { + return operation != null && !operation.isBlank(); + } + + /** + * Counted before the blank ids are dropped, because this bounds the request rather than the + * rows it would produce. + */ + boolean namesTooManyFiles() { + return fileIds.size() > MAX_FILE_IDS; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java index d46f2d85e1..c9acd0d590 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java @@ -23,6 +23,10 @@ import lombok.Getter; *

Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a * new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every * failed run a record, and kinds get promoted out of it as production shows what occurs. + * + *

A kind offers an acknowledgement only where there is something to acknowledge doing. + * With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the + * one that clears it. */ @Getter public enum FailureKind { @@ -43,7 +47,6 @@ public enum FailureKind { FailureScope.RUN, noErrorCodes(), fallback("This run failed for a reason Stirling does not yet recognise."), - offer(ACKNOWLEDGE), offer(DISMISS)); private static final String KEY_PREFIX = "portal.failures.kind."; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEvent.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEvent.java index cb6b3a452f..10f46cdbe2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEvent.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEvent.java @@ -20,6 +20,7 @@ public record FileRunEvent( FailureOrigin origin, String policyId, String runId, + String sourceId, String fileId, String detail, String dedupKey, @@ -46,6 +47,7 @@ public record FileRunEvent( entity.getOrigin(), entity.getPolicyId(), entity.getRunId(), + entity.getSourceId(), entity.getFileId(), entity.getDetail(), entity.getDedupKey(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java index 49aab9aff6..aac1c3364b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -21,15 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - /** - * Read and triage recorded failures for the caller's team. Note the absence of a team parameter: - * the team comes from the authenticated principal, never the request. + * Read and triage recorded failures. Note the absence of a team parameter: the team comes from the + * authenticated principal, never the request. * - *

Reviewing failures is a leader-level capability, gated the same way policy editing is: see - * {@link #requireFailureReviewAllowed()}. + *

Every endpoint is open to any authenticated user and scoped in the service instead: a leader + * reads and closes the whole team's failures, everyone else their own. Nothing here decides who may + * do what, so the two cannot drift apart. */ @Slf4j @RestController @@ -45,21 +44,21 @@ public class FileRunEventController { private static final int DEFAULT_LIMIT = 50; private final FileRunEventService service; - private final PolicyManagementAuthority policyManagementAuthority; - private final ApplicationProperties applicationProperties; @GetMapping @Operation( summary = "List recorded failures", description = - "Failures recorded for the caller's team, newest first. Each row carries its" - + " available actions already resolved.") + "Failures the caller may see, newest first: their team's for a leader, their own" + + " for everyone else. Each row carries its available actions already" + + " resolved.") public FileRunEventsResponse list( // Spring's converter 400s on a value outside the enum, so no hand-rolled parse. @RequestParam(required = false) FileRunEventStatus status, @RequestParam(required = false) String kindId, @RequestParam(required = false) Integer limit) { - requireFailureReviewAllowed(); + // No role gate: the service scopes the read instead, so a member gets their own failures + // and a leader the team's. int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT); List events = @@ -81,7 +80,8 @@ public class FileRunEventController { @PathVariable String eventId, @PathVariable String actionId, @RequestBody(required = false) ActionRequest request) { - requireFailureReviewAllowed(); + // No role gate: the service decides, which lets someone close their own failure while + // still keeping a colleague's out of reach. Map inputs = request == null ? Map.of() : request.safeInputs(); try { FileRunEvent updated = service.dispatch(eventId, actionId, inputs); @@ -91,6 +91,50 @@ public class FileRunEventController { } } + @PostMapping("/reports") + @Operation( + summary = "Report a failure hit in the editor", + description = + "For failures the server never sees, because the editor calls tools directly." + + " Open to any authenticated user: whoever's work failed can say so, and" + + " reads it back scoped to themselves. Rejected with 400 if it names" + + " more files than one report may carry.") + public ResponseEntity report(@RequestBody EditorFailureReport report) { + if (report == null || !report.hasOperation()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "operation is required to report a failure"); + } + // Refused whole rather than trimmed, and refused before the first write, so an oversized + // report leaves no rows at all. Trimming would hand a reviewer part of a set with nothing + // saying the rest existed, which is what the cap inside the service used to do. The limit + // is stated in the message because the editor reports in the background: a client author + // reading a log is the only person who will ever see this. + if (report.namesTooManyFiles()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "a report may name at most " + + EditorFailureReport.MAX_FILE_IDS + + " files, and this one named " + + report.fileIds().size()); + } + service.report(report); + // No body: the editor reports and moves on, and has nothing to do with the row. + return ResponseEntity.noContent().build(); + } + + @PostMapping("/removed-files") + @Operation( + summary = "Close the incidents about files deleted from the editor", + description = + "Deleting the document leaves nothing to act on, so its incidents drop out of" + + " the queue while the rows stay for audit. Applies only to the" + + " caller's own editor rows, however senior they are.") + public ResponseEntity filesRemoved(@RequestBody(required = false) RemovedFiles request) { + service.forgetFiles(request == null ? List.of() : request.safeFileIds()); + // No body: the editor is telling the server, not asking it anything. + return ResponseEntity.noContent().build(); + } + @GetMapping("/kinds") @Operation( summary = "List known failure kinds", @@ -98,29 +142,11 @@ public class FileRunEventController { "The failure registry. Lets a client describe kinds it was not built with, and" + " doubles as the probe for whether failure tracking exists at all.") public List kinds() { - requireFailureReviewAllowed(); + // The registry is copy and metadata, not anyone's data, and a member needs it to render the + // failures they can already see. return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList(); } - /** - * Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code - * PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who - * manages a team's automation: a member can trigger runs, a leader reviews them. - * - *

Login disabled means a single-user deployment with no roles to tell apart, the same - * carve-out the policy endpoints make. Team scoping is separate, and lives in the service. - */ - private void requireFailureReviewAllowed() { - if (!applicationProperties.getSecurity().isEnableLogin()) { - return; - } - if (!policyManagementAuthority.canEditPolicies()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Recorded failures may only be reviewed by a team leader"); - } - } - /** * A closed row is a conflict rather than a bad request: the request was well-formed and would * have been valid a moment earlier. @@ -136,6 +162,22 @@ public class FileRunEventController { /** Wrapped rather than a bare array so pagination can be added without breaking clients. */ public record FileRunEventsResponse(List events) {} + /** + * Files gone from the caller's editor. Opaque ids only, as everywhere else on this API. + * + *

Deliberately uncapped where a report is capped, because this creates nothing: it closes + * rows the caller already owns, so however long the list is, it can only ever touch incidents + * that already exist. Refusing an oversized one would also be the harmful direction here, since + * the editor says this once and never retries: those incidents would sit in the queue asking + * for attention about files that no longer exist. + */ + public record RemovedFiles(List fileIds) { + + List safeFileIds() { + return fileIds == null ? List.of() : fileIds; + } + } + /** Inputs an action declared it needs. Empty for both actions that exist today. */ public record ActionRequest(Map inputs) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventEntity.java index 9659941fc9..3cfb67a7fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventEntity.java @@ -84,6 +84,10 @@ public class FileRunEventEntity implements Serializable { @Column(name = "run_id") private String runId; + /** Which folder, bucket or webhook fed the run. Null when a user supplied the file. */ + @Column(name = "source_id") + private String sourceId; + @Column(name = "file_id") private String fileId; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java index 6da6a9b004..1930b36e6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java @@ -17,27 +17,36 @@ import org.springframework.transaction.annotation.Transactional; public interface FileRunEventRepository extends JpaRepository { /** - * This team's events, newest first, scoped in the query rather than loaded and filtered. A - * {@code null} teamId matches the rows with no team (login disabled), mirroring {@link - * stirling.software.proprietary.policy.source.SourceRepository#findByTeam}, since a plain - * {@code = null} would return nothing. + * As {@link #findByTeamAndStatus} but for a set of statuses, e.g. the open ones. The kind + * filter is in the query, before the limit: filtering an already-limited page could return + * nothing while matching rows exist. + * + *

{@code actor} narrows to one person's own failures. Null means the whole team, which only + * a leader ever asks for: see {@code FileRunEventService#readScope}. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" - + " e.teamId = :teamId) and (:kindId is null or e.kindId = :kindId)" - + " order by e.lastSeenAt desc") - List findByTeam( - @Param("teamId") Long teamId, @Param("kindId") String kindId, Pageable pageable); + + " e.teamId = :teamId) and e.status in :statuses" + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") + List findByTeamAndStatusIn( + @Param("teamId") Long teamId, + @Param("statuses") List statuses, + @Param("kindId") String kindId, + @Param("actor") String actor, + Pageable pageable); - /** As {@link #findByTeam} but restricted to one status, for the review surface's filters. */ + /** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" + " e.teamId = :teamId) and e.status = :status" - + " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc") + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") List findByTeamAndStatus( @Param("teamId") Long teamId, @Param("status") FileRunEventStatus status, @Param("kindId") String kindId, + @Param("actor") String actor, Pageable pageable); /** @@ -85,6 +94,31 @@ public interface FileRunEventRepository extends JpaRepository allowedFrom); + /** + * Close the incidents about documents their owner deleted from the editor: the queue is what + * needs attention, and a document that no longer exists needs none. + * + *

Restricted to that owner's own editor rows. File ids are minted by the client, so scoping + * on team alone would let one caller close a colleague's incidents by naming ids. Processor + * rows are excluded outright: nothing was deleted from an editor there. + */ + @Modifying(clearAutomatically = true) + @Transactional + @Query( + "update FileRunEventEntity e set e.status =" + + " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED," + + " e.statusActor = :actor, e.statusAt = :now where e.origin =" + + " stirling.software.proprietary.failure.FailureOrigin.TOOL and ((:teamId is" + + " null and e.teamId is null) or e.teamId = :teamId) and ((:actor is null and" + + " e.actor is null) or e.actor = :actor) and e.fileId in :fileIds and e.status in" + + " :allowedFrom") + int markFilesRemoved( + @Param("teamId") Long teamId, + @Param("actor") String actor, + @Param("fileIds") Collection fileIds, + @Param("now") Instant now, + @Param("allowedFrom") Collection allowedFrom); + /** * The most recent row for this exact failure, so the rollup can increment an existing incident * instead of opening a new one. Team-scoped, so the same failure in two teams stays two rows. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java index 94b9c00a2c..fa858b6e22 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java @@ -13,12 +13,14 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; /** - * Reads and acts on incidents for the calling user's team. + * Reads and acts on the incidents the calling user is allowed to see, which is where that decision + * is made: a leader reads and closes the whole team's failures, everyone else their own. Keeping it + * here rather than on the endpoints means the read and the triage cannot drift apart. * *

Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the * team always comes from the authenticated principal, and scoping applies only when login is * enabled so single-user deployments keep working. When the team cannot be resolved the caller - * reads nothing; see {@link #scope()}. + * reads nothing; see {@link #readScope()}. */ @Slf4j @Service @@ -31,13 +33,83 @@ public class FileRunEventService { private final UserServiceInterface userService; private final ApplicationProperties applicationProperties; - /** The calling user's events, newest first. Empty when their team cannot be resolved. */ + /** + * Record a failure a user hit in the editor. One incident per named file, so each document + * stays separately actionable; one unattributed incident when the report names none. + * + *

The team and actor come from the session rather than the report, and the kind is + * classified from the reported code, falling back to {@link FailureKind#UNKNOWN} for a code no + * kind claims. + */ + public List report(EditorFailureReport report) { + FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN); + // The caller's team, not their read scope: recording is open to everyone, and a reader who + // may see nothing still has their failure filed under the team it happened in. + Long teamId = currentTeamId(); + String actor = currentActor(); + String detail = detailFor(report); + + List fileIds = + report.fileIds().stream().filter(id -> id != null && !id.isBlank()).toList(); + if (fileIds.isEmpty()) { + return List.of(recordReported(kind, teamId, actor, null, detail)); + } + // Every named file gets its row. An earlier cap here silently dropped the rest, which lost + // failures a reviewer needed and was inconsistent with the processor path, where a sweep + // records one row per failing file with no limit at all. How many files one report may name + // is bounded at the boundary instead (see EditorFailureReport#MAX_FILE_IDS), where an + // oversized report can be refused whole before anything is written. + return fileIds.stream() + .map(fileId -> recordReported(kind, teamId, actor, fileId, detail)) + .toList(); + } + + private FileRunEvent recordReported( + FailureKind kind, Long teamId, String actor, String fileId, String detail) { + return store.record(RecordFailure.forEditor(kind, teamId, actor, fileId, detail)); + } + + /** + * The operation is the context a reviewer needs, since an editor failure has no policy or run. + */ + private String detailFor(EditorFailureReport report) { + String message = report.detail() == null ? "" : report.detail(); + return message.isBlank() ? report.operation() : report.operation() + ": " + message; + } + + /** + * Close the incidents about documents the caller has deleted from their editor. The queue means + * "needs attention", and a document that no longer exists needs none; the rows stay for audit. + * + *

Best-effort by nature: this only arrives if the browser that owns the file says so, and a + * cleared cache or another device never will. Rows left open that way are retention's problem, + * not this method's. + * + *

Narrowed to the caller's own rows however senior they are, which is why it passes {@link + * #currentActor()} rather than the read scope's actor: file ids are minted by each client, so a + * leader reading with a null actor would match every unattributed row in the team. + * + * @return how many incidents were closed + */ + public int forgetFiles(List fileIds) { + ReadScope scope = readScope(); + if (!scope.permitted()) { + return 0; + } + List named = fileIds.stream().filter(id -> id != null && !id.isBlank()).toList(); + return store.markFilesRemoved(scope.teamId(), currentActor(), named); + } + + /** + * The events the caller may read, newest first: the team's for a leader, their own for everyone + * else. Empty when their team cannot be resolved. + */ public List list(FileRunEventStatus status, String kindId, int limit) { - TeamScope scope = scope(); + ReadScope scope = readScope(); if (!scope.permitted()) { return List.of(); } - return store.list(scope.teamId(), status, kindId, limit); + return store.list(scope.teamId(), status, kindId, scope.actor(), limit); } /** @@ -47,7 +119,13 @@ public class FileRunEventService { * event's kind does not declare the action, or the event is already closed */ public FileRunEvent dispatch(String eventId, String actionId, Map inputs) { - TeamScope scope = scope(); + // Whoever can see it can close it: a leader for the whole team, everyone else for the + // failures they caused. Someone who fixes their own problem should not have to ask a leader + // to clear the row. + // + // Closing the row is all this covers. Acting on the document behind it, such as supplying a + // password for a retry, would need its own permission, and no such action exists yet. + ReadScope scope = readScope(); if (!scope.permitted()) { // Reported as "no such event", the same as an id from another team, so the response // does @@ -57,6 +135,12 @@ public class FileRunEventService { } FileRunEvent event = store.find(eventId, scope.teamId()) + // Reported as "no such event" rather than a refusal, so a member cannot + // learn that a colleague's incident exists by trying to close it. + .filter( + found -> + scope.actor() == null + || scope.actor().equals(found.actor())) .orElseThrow( () -> new FailureActionException( @@ -118,31 +202,55 @@ public class FileRunEventService { } /** - * Which rows the caller may touch, since a null team id means two different things. Login - * disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's, - * as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads - * nothing, because unteamed rows there are shared by every team's ad-hoc runs. + * Which rows the caller may read. A leader reviews the whole team's, as before. Everyone else + * reads the failures they caused themselves: a member can already report one, so letting them + * see their own back is what makes telling them about it worth anything, and it exposes nothing + * of a colleague's. + * + *

A null team id means two different things. Login disabled is the self-hosted setup with no + * users or teams, where unteamed rows are everyone's, as {@code PolicyAccessGuard} also treats + * them. Login enabled with no resolvable team reads nothing, because unteamed rows there are + * shared by every team's ad-hoc runs. */ - private TeamScope scope() { + private ReadScope readScope() { if (!enforced()) { - return TeamScope.of(null); + return ReadScope.wholeTeam(null); } - Long teamId = policyManagementAuthority.currentUserTeamId(); - return teamId == null ? TeamScope.denied() : TeamScope.of(teamId); + Long teamId = currentTeamId(); + if (teamId == null) { + return ReadScope.denied(); + } + if (policyManagementAuthority.canEditPolicies()) { + return ReadScope.wholeTeam(teamId); + } + // Narrowing to "mine" needs a name to narrow by. Without one the filter would be dropped + // and a member would read the whole team, so refuse rather than widen. + String actor = currentActor(); + return actor == null ? ReadScope.denied() : ReadScope.mine(teamId, actor); } /** - * The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted. + * What the caller may read. {@code actor} is the person to narrow to, or null for the whole + * team; both are only meaningful when permitted. */ - private record TeamScope(boolean permitted, Long teamId) { + private record ReadScope(boolean permitted, Long teamId, String actor) { - static TeamScope of(Long teamId) { - return new TeamScope(true, teamId); + static ReadScope wholeTeam(Long teamId) { + return new ReadScope(true, teamId, null); } - static TeamScope denied() { - return new TeamScope(false, null); + static ReadScope mine(Long teamId, String actor) { + return new ReadScope(true, teamId, actor); } + + static ReadScope denied() { + return new ReadScope(false, null, null); + } + } + + /** The team a row belongs to, which is nobody's when there are no teams to belong to. */ + private Long currentTeamId() { + return enforced() ? policyManagementAuthority.currentUserTeamId() : null; } private String currentActor() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStatus.java index cae0529334..9bf0e0b607 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStatus.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStatus.java @@ -11,7 +11,14 @@ public enum FileRunEventStatus { NEW(false), ACKNOWLEDGED(false), DISMISSED(true), - RESOLVED(true); + RESOLVED(true), + + /** + * The document this incident was about was deleted from its owner's editor, so there is nothing + * left to act on. Distinct from {@code DISMISSED}, which is a reviewer's decision, and from + * {@code RESOLVED}, which reopens on recurrence: this one cannot recur, the file is gone. + */ + FILE_REMOVED(true); /** The statuses a review queue shows by default: everything still needing a decision. */ private static final List OPEN = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java index e1fc7cc453..2ed86e61b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java @@ -98,6 +98,7 @@ public class FileRunEventStore { entity.setOrigin(command.origin()); entity.setPolicyId(command.policyId()); entity.setRunId(command.runId()); + entity.setSourceId(command.sourceId()); entity.setFileId(command.fileId()); entity.setDetail(command.detail()); entity.setDedupKey(dedupKey); @@ -112,18 +113,27 @@ public class FileRunEventStore { } /** - * A page of incidents, newest first, optionally narrowed to one status and one kind. The kind - * filter lives in the query, before the limit: filtering a already-limited page could return - * nothing while matching rows exist. + * A page of incidents, newest first, optionally narrowed to one status and one kind. + * + *

With no status asked for this is the open queue rather than every row ever + * recorded: a dismissed failure has been dealt with, and leaving it in the default view means + * the list can never be cleared. Ask for a status to see closed rows. + * + *

Both filters live in the query, before the limit: filtering an already-limited page could + * return nothing while matching rows exist. + * + *

{@code actor} narrows to one person's own failures, or reads the whole team when null. Who + * gets which is the service's decision, not this method's. */ @Transactional(readOnly = true) public List list( - Long teamId, FileRunEventStatus status, String kindId, int limit) { + Long teamId, FileRunEventStatus status, String kindId, String actor, int limit) { Pageable page = PageRequest.of(0, Math.max(1, limit)); List rows = status == null - ? repository.findByTeam(teamId, kindId, page) - : repository.findByTeamAndStatus(teamId, status, kindId, page); + ? repository.findByTeamAndStatusIn( + teamId, FileRunEventStatus.open(), kindId, actor, page) + : repository.findByTeamAndStatus(teamId, status, kindId, actor, page); return rows.stream().map(FileRunEvent::of).toList(); } @@ -184,6 +194,22 @@ public class FileRunEventStore { .orElseThrow(() -> refusalFor(id, teamId)); } + /** + * Close this owner's open incidents about {@code fileIds}, because the documents are gone. The + * rows stay for audit; they just leave the queue. Only open rows move, so a reviewer's dismiss + * keeps its meaning and its actor. + * + * @return how many incidents were closed + */ + @Transactional + public int markFilesRemoved(Long teamId, String actor, Collection fileIds) { + if (fileIds.isEmpty()) { + return 0; + } + return repository.markFilesRemoved( + teamId, actor, fileIds, Instant.now(), FileRunEventStatus.open()); + } + /** * Why the guarded UPDATE refused, worked out only once it has. Missing and closed are told * apart after the fact rather than before, so the answer describes the row the UPDATE saw. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java index cbaab4684d..3809fad2c6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java @@ -22,6 +22,7 @@ public record FileRunEventView( String detail, String policyId, String runId, + String sourceId, String fileId, String actor, int occurrences, @@ -48,6 +49,7 @@ public record FileRunEventView( event.detail(), event.policyId(), event.runId(), + event.sourceId(), event.fileId(), event.actor(), event.occurrences(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java index 7d4516939a..eaa8e0f5bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java @@ -30,11 +30,12 @@ public class PolicyFailureRecorder { public void recordRunFailure( String runId, String policyId, - String actor, + String sourceId, String fileIdentity, + String actor, String detail, Throwable cause) { - record(classifier.classify(cause), runId, policyId, actor, fileIdentity, detail); + record(classifier.classify(cause), runId, policyId, sourceId, fileIdentity, actor, detail); } /** @@ -44,21 +45,35 @@ public class PolicyFailureRecorder { * pick the wrong one. */ public void recordRunFailureAs( - FailureKind kind, String runId, String policyId, String actor, String detail) { - record(kind, runId, policyId, actor, null, detail); + FailureKind kind, + String runId, + String policyId, + String sourceId, + String actor, + String detail) { + // No document reference: a run rejected at admission never got as far as one. + record(kind, runId, policyId, sourceId, null, actor, detail); } private void record( FailureKind kind, String runId, String policyId, - String actor, + String sourceId, String fileIdentity, + String actor, String detail) { try { store.record( RecordFailure.forRun( - kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail)); + kind, + teamFor(policyId), + actor, + policyId, + runId, + sourceId, + fileIdentity, + detail)); } catch (RuntimeException e) { // Deliberately swallowed: see the class comment. log.warn("Could not record failure event for run {} (kind {})", runId, kind.getId(), e); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java index 1f45d52e17..8448f26fc3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java @@ -38,17 +38,40 @@ public record RecordFailure( detail = truncate(detail); } - /** A processor-side failure with no file or source context, e.g. a run that failed outright. */ + /** + * A processor-side run failure. {@code sourceId} says which folder, bucket or webhook fed the + * run, and is the only attribution an unattended failure has: there is no user to name. {@code + * fileId} is the source's opaque reference to the document, already hashed upstream. + */ public static RecordFailure forRun( FailureKind kind, Long teamId, String actor, String policyId, String runId, + String sourceId, String fileId, String detail) { return new RecordFailure( - kind, FailureOrigin.POLICY, teamId, actor, policyId, runId, null, fileId, detail); + kind, + FailureOrigin.POLICY, + teamId, + actor, + policyId, + runId, + sourceId, + fileId, + detail); + } + + /** + * A failure a user hit in their own editor. There is no policy, run or source: the user is the + * attribution, and {@code fileId} may be null when the report named no file. + */ + public static RecordFailure forEditor( + FailureKind kind, Long teamId, String actor, String fileId, String detail) { + return new RecordFailure( + kind, FailureOrigin.TOOL, teamId, actor, null, null, null, fileId, detail); } /** @@ -56,14 +79,21 @@ public record RecordFailure( * reference are the same incident; see {@link #dedupKey()}. */ public String scopeRef() { - return switch (kind.getScope()) { - case FILE -> nullToEmpty(policyId) + "|" + fileOrRun(); - case RUN -> nullToEmpty(runId); - case POLICY -> nullToEmpty(policyId); - case SOURCE -> nullToEmpty(sourceId); - // One server-wide condition is one incident regardless of which run tripped over it. - case SERVER -> ""; - }; + String about = + switch (kind.getScope()) { + case FILE -> nullToEmpty(policyId) + "|" + fileOrRun(); + // An editor report has no run, so a RUN-scoped kind would otherwise put every + // such failure in a team into one incident: fall back to the document. + case RUN -> isBlank(runId) ? fileOrRun() : nullToEmpty(runId); + case POLICY -> nullToEmpty(policyId); + case SOURCE -> nullToEmpty(sourceId); + // One server-wide condition is one incident regardless of which run hit it. + case SERVER -> ""; + }; + // An editor failure belongs to the person who hit it, so two colleagues hitting the same + // thing are two incidents. Folding them would credit one actor for both and offer the + // wrong person the row. Unattended runs have no such owner and are unaffected. + return origin == FailureOrigin.TOOL ? nullToEmpty(actor) + "|" + about : about; } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java index a54959b0ff..661e42c771 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -2,13 +2,19 @@ package stirling.software.proprietary.model; import java.io.Serializable; import java.util.HashSet; +import java.util.Objects; import java.util.Set; +import org.hibernate.proxy.HibernateProxy; + import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; -import lombok.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import stirling.software.proprietary.security.model.User; @@ -18,7 +24,6 @@ import stirling.software.proprietary.security.model.User; @NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) public class Team implements Serializable { @@ -47,4 +52,31 @@ public class Team implements Serializable { users.remove(user); user.setTeam(null); } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Team team = (Team) o; + return getId() != null && Objects.equals(getId(), team.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java index ccaf337c0b..aeb66b47a8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java @@ -1,6 +1,9 @@ package stirling.software.proprietary.model.security; import java.time.Instant; +import java.util.Objects; + +import org.hibernate.proxy.HibernateProxy; import jakarta.persistence.*; @@ -28,7 +31,9 @@ import lombok.*; name = "idx_audit_source_timestamp_principal", columnList = "source,timestamp,principal") }) -@Data +@Getter +@Setter +@ToString(onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor @@ -36,14 +41,43 @@ public class PersistentAuditEvent { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @ToString.Include private Long id; - private String principal; - private String type; + @ToString.Include private String principal; + + @ToString.Include private String type; private String source; @Column(columnDefinition = "text") private String data; // JSON blob - private Instant timestamp; + @ToString.Include private Instant timestamp; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + PersistentAuditEvent that = (PersistentAuditEvent) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java index a49c8e5aa9..6d8229aa26 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java @@ -26,6 +26,11 @@ public class AdminPolicyManagementAuthority implements PolicyManagementAuthority return userService.isCurrentUserAdmin(); } + @Override + public boolean canTriggerPolicies() { + return userService.isCurrentUserAdmin(); + } + @Override public Long currentUserTeamId() { String username = userService.getCurrentUsername(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java index 0ea3c298ad..d7e4f50ad1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java @@ -12,6 +12,17 @@ public interface PolicyManagementAuthority { /** Whether the current user may create, edit, or delete policies (for their own team). */ boolean canEditPolicies(); + /** + * Whether the current user may run a policy against its configured sources (the manual + * "run now" sweep). Kept separate from {@link #canEditPolicies()} because the two are distinct + * capabilities, even where a deployment grants both to the same people: a sweep operates on the + * team's configured sources using the server's stored connection credentials, which makes it a + * policy-management capability rather than ordinary use. Running a policy over the caller's + * own uploaded files is not covered by this and stays open to every team member — that + * is ordinary editor enforcement. + */ + boolean canTriggerPolicies(); + /** * The team that scopes the current user's policies — the team a new policy is stamped with and * the only team whose policies the user may see/run/edit. {@code null} when it can't be diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 506bb75578..778a04e169 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -432,9 +432,10 @@ public class PolicyController { * admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by * {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume * re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two - * covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login - * disabled) have no such role, so they trust the local operator. The path allowlist for folder - * sources/outputs is enforced separately by {@link PolicyValidator} at validation time. + * covers them all; runs over the caller's own files ({@code /{id}/run}) stay open to the team, + * while source sweeps are gated by {@link #requirePolicySweepAllowed}. Single-user deployments + * (login disabled) have no such role, so they trust the local operator. The path allowlist for + * folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time. */ private void requirePolicyEditingAllowed() { if (!applicationProperties.getSecurity().isEnableLogin()) { @@ -447,6 +448,25 @@ public class PolicyController { } } + /** + * Sweeping a policy's configured sources requires the same role as managing policies: the sweep + * operates on the team's configured sources using the server's stored connection credentials, + * which makes it a policy-management capability rather than ordinary use, and team scoping on + * its own does not express that. Deliberately narrower than it looks: it gates only the sweep, + * not {@link #runStoredPolicy}, because running a policy over documents the caller supplied is + * ordinary editor enforcement that every member performs on upload and export. + */ + private void requirePolicySweepAllowed() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + return; + } + if (!policyManagementAuthority.canTriggerPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Not permitted to run this policy against its configured sources"); + } + } + @GetMapping @Operation( summary = "List policies", @@ -571,8 +591,10 @@ public class PolicyController { + " the enabled flag (which only gates automatic triggering). Returns" + " the ids of the runs started (poll the run-status endpoint for each)" + " plus what the sweep skipped - already-processed, parked-by-failure," - + " and in-flight counts - so an empty result explains itself.") + + " and in-flight counts - so an empty result explains itself. Requires" + + " the policy-management role.") public ResponseEntity trigger(@PathVariable String policyId) { + requirePolicySweepAllowed(); Policy policy = policyStore .get(policyId) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index a58670ac25..0d75866a6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -130,25 +130,35 @@ public class PolicyEngine { // worker. String principal = currentActingPrincipal(); return submitForPrincipal( - principal, principal, policyId, definition, inputs, null, listener); + principal, + principal, + principal, + policyId, + definition, + inputs, + listener, + null, + null); } /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */ public PolicyRunHandle runPolicy( Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { - return runPolicy(policy, inputs, null, listener); + return runPolicy(policy, inputs, listener, null, null); } /** - * As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, with the source's opaque - * reference to the document being run. Carried so a failure can say which document it was - * about, and so the same document failing again folds into one incident. + * As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, recording which source + * fed the run and its opaque reference to the document. The first says where an unattended + * failure came from; the second says which document, and is what lets the same document failing + * again fold into one incident. Both null for a user's upload. */ public PolicyRunHandle runPolicy( Policy policy, PolicyInputs inputs, - String fileIdentity, - PolicyProgressListener listener) { + PolicyProgressListener listener, + String sourceId, + String fileIdentity) { // Bill the policy owner: trigger-fired runs have no security context, and the async worker // doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable // billing identity — and for org-wide policies the org/owner is meant to pay. But own the @@ -156,6 +166,10 @@ public class PolicyEngine { // they can download their enforced file; otherwise an org-wide policy's output is owned by // the admin and the triggering user is denied it. Trigger-fired runs have no such user, so // the owner owns those outputs. + // + // The triggering user is also carried on the run, as the actor of any failure it records: + // null for a trigger-fired run, which is what makes an unattended incident ownerless rather + // than the owner's problem. Three identities, deliberately not interchangeable. String triggeringUser = currentActingPrincipal(); String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); // Stored supporting files (certificates, watermark images, ...) load here, before the @@ -170,21 +184,27 @@ public class PolicyEngine { return submitForPrincipal( policy.owner(), fileOwner, + triggeringUser, policy.id(), definition, + // main's asset-resolved inputs, not the raw ones: stored certificates and watermark + // images bind here, before the async hop, because worker threads have no principal. resolved, - fileIdentity, - listener); + listener, + sourceId, + fileIdentity); } private PolicyRunHandle submitForPrincipal( String billingPrincipal, String fileOwner, + String triggeringUser, String policyId, PipelineDefinition definition, PolicyInputs inputs, - String fileIdentity, - PolicyProgressListener listener) { + PolicyProgressListener listener, + String sourceId, + String fileIdentity) { // Scope the run id to the current user (this request thread) so the file-download // ownership check passes. No-op when security is off. String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString()); @@ -193,7 +213,8 @@ public class PolicyEngine { if (policyId != null) { taskManager.putMetadata(runId, "policyId", policyId); } - PolicyRun run = new PolicyRun(runId, policyId, definition, fileIdentity); + PolicyRun run = + new PolicyRun(runId, policyId, definition, sourceId, fileIdentity, triggeringUser); registry.register(run); CompletableFuture completion = new CompletableFuture<>(); PolicyProgressListener tracking = trackingListener(runId, run, listener); @@ -358,8 +379,15 @@ public class PolicyEngine { taskManager.setError(run.getRunId(), message); // No exception to classify here: nothing was thrown by a tool, the run simply was not // admitted. Record it explicitly so a run lost to load pressure is still accounted for. + // Attributed like any other failure: a user whose run was refused is still the person + // holding that document, and an unattended sweep's run carries no triggering user. failureRecorder.recordRunFailureAs( - FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), null, message); + FailureKind.UNKNOWN, + run.getRunId(), + run.getPolicyId(), + run.getSourceId(), + run.getTriggeringUser(), + message); completion.complete(run); } return null; @@ -368,13 +396,19 @@ public class PolicyEngine { /** * Record why a run failed. Called after the run's own state transition and task-manager update, * so a recording problem cannot change the outcome the caller observes. + * + *

The actor is the run's triggering user, not the MDC audit principal: that carries the + * BILLING identity, which for a stored policy is always its owner. Reading it here filed every + * failure under the owner — hiding an attended failure from the member who caused it and holds + * the document, and leaving an unattended sweep's failure looking attended. */ private void recordFailure(PolicyRun run, String message, Throwable cause) { failureRecorder.recordRunFailure( run.getRunId(), run.getPolicyId(), - MDC.get(AUDIT_PRINCIPAL_MDC_KEY), + run.getSourceId(), run.getFileIdentity(), + run.getTriggeringUser(), message, cause); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 04dda0871e..b4609f94ea 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -79,7 +79,8 @@ public class PolicyRunner { // Generator pipeline: one run with no input. Still fall through to the cleanup // below so rows recorded for its folder outputs are pruned like anything else, // instead of accumulating until the policy is deleted. - runIds.add(startRun(policy, PolicyInputs.of(List.of()), null, unused -> {})); + // Generator pipeline: no input, so neither a source nor a document to attribute to. + runIds.add(startRun(policy, null, null, PolicyInputs.of(List.of()), unused -> {})); } for (PipelineInput input : inputs) { String sourceId = input.sourceId(); @@ -167,7 +168,13 @@ public class PolicyRunner { List runIds = new ArrayList<>(); long docsFed = 0; for (ResolvedInput unit : work) { - runIds.add(startRun(policy, unit.inputs(), unit.fileIdentity(), unit.onComplete())); + runIds.add( + startRun( + policy, + sourceId, + unit.fileIdentity(), + unit.inputs(), + unit.onComplete())); docsFed += unit.inputs().primary().size(); } docCounter.record(sourceId, docsFed); @@ -175,10 +182,15 @@ public class PolicyRunner { } private String startRun( - Policy policy, PolicyInputs inputs, String fileIdentity, Consumer onComplete) { + Policy policy, + String sourceId, + String fileIdentity, + PolicyInputs inputs, + Consumer onComplete) { log.info("Running policy {} ({})", policy.id(), policy.name()); PolicyRunHandle handle = - policyEngine.runPolicy(policy, inputs, fileIdentity, PolicyProgressListener.NOOP); + policyEngine.runPolicy( + policy, inputs, PolicyProgressListener.NOOP, sourceId, fileIdentity); handle.completion() .whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable))); return handle.runId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java index 24a97f6c2a..8eb0cfcc73 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java @@ -21,6 +21,12 @@ public class PolicyRun { /** ID of the stored policy that produced this run; null for ad-hoc pipelines. */ private final String policyId; + /** + * ID of the source the input came from (folder, S3, webhook), or null when a user supplied the + * files. Recorded on a failure so a reviewer can see where an unattended file came from. + */ + private final String sourceId; + private final PipelineDefinition definition; /** @@ -29,6 +35,14 @@ public class PolicyRun { */ private final String fileIdentity; + /** + * The user who triggered this run, or null when nothing attended it (a trigger-fired sweep). + * Recorded as a failure's actor, so an attended failure is handed to the person holding the + * document. Deliberately not the billing principal: a shared policy is billed to its owner, who + * may never have touched the file. + */ + private final String triggeringUser; + private final Instant createdAt = Instant.now(); private volatile PolicyRunStatus status = PolicyRunStatus.PENDING; @@ -56,12 +70,25 @@ public class PolicyRun { private volatile List outputs = List.of(); private volatile Instant updatedAt = Instant.now(); + /** + * All three attribution references are required rather than defaulted: a run with none is a + * real case (an unattended sweep of a generator pipeline), but it should be stated at the call + * site. Overloads that omitted them would make losing the attribution the frictionless option, + * which is how {@code sourceId} and {@code fileIdentity} went unpopulated in the first place. + */ public PolicyRun( - String runId, String policyId, PipelineDefinition definition, String fileIdentity) { + String runId, + String policyId, + PipelineDefinition definition, + String sourceId, + String fileIdentity, + String triggeringUser) { this.runId = runId; this.policyId = policyId; + this.sourceId = sourceId; this.definition = definition; this.fileIdentity = fileIdentity; + this.triggeringUser = triggeringUser; } public int stepCount() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java index 5ecc29392d..d222e3be40 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java @@ -65,7 +65,7 @@ final class SftpFileClient implements RemoteFileClient { } Session session = jsch.getSession(config.username(), config.host(), config.port()); if (config.password() != null) { - session.setPassword(config.password()); + session.setPassword(config.password().getBytes(StandardCharsets.UTF_8)); } if (config.hostKeyFingerprint() != null) { // Pinned key: only the configured fingerprint is ever accepted. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index 272917ec7f..b2be7b668e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -20,23 +20,28 @@ import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; /** - * Builds the Pipelines overview: every policy the caller's team owns, each annotated with its - * referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary. - * Source names are resolved from the team's sources in memory rather than persisted on the policy, - * so the view always reflects the live source set. This is the "all pipelines" admin surface; the - * user-facing Policies page builds only a friendly subset of the same backend policies. + * Builds the Pipelines overview: one row per policy the caller's team built on the Pipelines page, + * with its sources resolved to live display names, its steps, and a trigger/output summary. + * Frontend/catalogue policies (marked by a {@code categoryId} in their output options) belong to + * the user-facing Policies page and are excluded; a folder-watch trigger is not a signal. */ @Service @RequiredArgsConstructor public class PolicyOverviewService { + // Output-options key marking a frontend/catalogue policy (set by the Policies page and seeder). + private static final String CATEGORY_OPTION = "categoryId"; + private final PolicyStore policyStore; private final SourceStore sourceStore; private final PolicyAccessGuard policyAccessGuard; private final SourceAccessGuard sourceAccessGuard; public PoliciesOverviewResponse overview() { - List policies = policyAccessGuard.visibleFrom(policyStore); + List policies = + policyAccessGuard.visibleFrom(policyStore).stream() + .filter(PolicyOverviewService::isPipeline) + .toList(); Map sourceNames = sourceNames(); List views = @@ -50,6 +55,18 @@ public class PolicyOverviewService { return new PoliciesOverviewResponse(buildKpis(policies), views); } + private static boolean isPipeline(Policy policy) { + return !isCataloguePolicy(policy); + } + + /** A frontend/catalogue policy, marked by a {@code categoryId} in its output options. */ + private static boolean isCataloguePolicy(Policy policy) { + OutputSpec output = policy.output(); + return output != null + && output.options().get(CATEGORY_OPTION) instanceof String category + && !category.isBlank(); + } + /** Display names for every source the caller's team can see, keyed by source id. */ private Map sourceNames() { Map names = new HashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java index fe9c9f4209..312cddb662 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java @@ -1,17 +1,23 @@ package stirling.software.proprietary.security.model; import java.time.Instant; +import java.util.Objects; + +import org.hibernate.proxy.HibernateProxy; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; -import lombok.Data; +import lombok.*; @Entity @Table(name = "persistent_logins") -@Data +@Getter +@Setter +@ToString(onlyExplicitlyIncluded = true) +@NoArgsConstructor public class PersistentLogin { @Id @@ -19,11 +25,40 @@ public class PersistentLogin { private String series; @Column(name = "username", length = 64, nullable = false) + @ToString.Include private String username; @Column(name = "token", length = 64, nullable = false) private String token; @Column(name = "last_used", nullable = false) + @ToString.Include private Instant lastUsed; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + PersistentLogin that = (PersistentLogin) o; + return getSeries() != null && Objects.equals(getSeries(), that.getSeries()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java index 552d97d022..44b2153500 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java @@ -2,16 +2,22 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; import java.time.Instant; +import java.util.Objects; + +import org.hibernate.proxy.HibernateProxy; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; -import lombok.Data; +import lombok.*; @Entity -@Data +@Getter +@Setter +@ToString +@NoArgsConstructor @Table( name = "sessions", indexes = { @@ -23,11 +29,42 @@ import lombok.Data; @Index(name = "idx_sessions_expired", columnList = "expired") }) public class SessionEntity implements Serializable { - @Id private String sessionId; + @Id + @Setter(AccessLevel.NONE) + private String sessionId; private String principalName; private Instant lastRequest; private boolean expired; + + public void setSessionId(String sessionId) { + if (this.sessionId != null && !this.sessionId.equals(sessionId)) { + throw new IllegalStateException("sessionId is immutable once set"); + } + this.sessionId = sessionId; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + SessionEntity that = (SessionEntity) o; + return getSessionId() != null && Objects.equals(getSessionId(), that.getSessionId()); + } + + @Override + public final int hashCode() { + return getSessionId() != null ? getSessionId().hashCode() : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 32733f5fc5..9455ed6e4b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -2,27 +2,19 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.proxy.HibernateProxy; import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; +import lombok.*; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.model.Team; @@ -35,7 +27,6 @@ import stirling.software.proprietary.model.Team; @NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) public class User implements UserDetails, Serializable { @@ -44,7 +35,6 @@ public class User implements UserDetails, Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") - @EqualsAndHashCode.Include private Long id; @Column(name = "username", unique = true) @@ -181,4 +171,31 @@ public class User implements UserDetails, Serializable { public void setOauthGrandfathered(boolean oauthGrandfathered) { this.oauthGrandfathered = oauthGrandfathered; } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + User user = (User) o; + return getId() != null && Objects.equals(getId(), user.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java index 0ad30a3bf7..aef781dd9b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java @@ -2,9 +2,11 @@ package stirling.software.proprietary.workflow.model; import java.io.Serializable; import java.time.LocalDateTime; +import java.util.Objects; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.proxy.HibernateProxy; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -19,7 +21,6 @@ import stirling.software.proprietary.security.model.User; @NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) public class UserServerCertificateEntity implements Serializable { @@ -28,7 +29,6 @@ public class UserServerCertificateEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") - @EqualsAndHashCode.Include @ToString.Include private Long id; @@ -70,4 +70,31 @@ public class UserServerCertificateEntity implements Serializable { @UpdateTimestamp @Column(name = "updated_at") private LocalDateTime updatedAt; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + UserServerCertificateEntity that = (UserServerCertificateEntity) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminJobControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminJobControllerTest.java new file mode 100644 index 0000000000..95d2e097cf --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminJobControllerTest.java @@ -0,0 +1,89 @@ +package stirling.software.proprietary.controller.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Map; + +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.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.common.service.JobQueue; +import stirling.software.common.service.TaskManager; + +/** + * The admin sweep covers every user's jobs, so it must stay distinct from the self-service endpoint + * on JobController - and both must run the same TaskManager sweep rather than each growing their + * own cleanup logic. + */ +class AdminJobControllerTest { + + @Mock private TaskManager taskManager; + @Mock private JobQueue jobQueue; + + @InjectMocks private AdminJobController controller; + + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + } + + @Test + void cleanupWithoutForceRunsTheRetentionSweep() throws Exception { + when(taskManager.cleanupOldJobs()).thenReturn(new TaskManager.CleanupSummary(2, 4, 3)); + + ResponseEntity response = controller.cleanupOldJobs(false); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertEquals(2, body.get("removedJobs")); + assertEquals(4, body.get("filesDeleted")); + assertEquals(3, body.get("remainingJobs")); + verify(taskManager).cleanupOldJobs(); + verify(taskManager, never()).cleanupFinishedJobsNow(any()); + closeable.close(); + } + + @Test + void cleanupWithForceIgnoresTheRetentionWindow() throws Exception { + when(taskManager.cleanupFinishedJobsNow(any())) + .thenReturn(new TaskManager.CleanupSummary(5, 9, 0)); + + ResponseEntity response = controller.cleanupOldJobs(true); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertEquals(5, body.get("removedJobs")); + assertEquals(9, body.get("filesDeleted")); + verify(taskManager).cleanupFinishedJobsNow(any()); + verify(taskManager, never()).cleanupOldJobs(); + closeable.close(); + } + + @Test + void forcedAdminCleanupSweepsEveryUsersJobs() throws Exception { + when(taskManager.cleanupFinishedJobsNow(any())) + .thenReturn(new TaskManager.CleanupSummary(1, 1, 0)); + + controller.cleanupOldJobs(true); + + @SuppressWarnings("unchecked") + org.mockito.ArgumentCaptor> filter = + org.mockito.ArgumentCaptor.forClass(java.util.function.Predicate.class); + verify(taskManager).cleanupFinishedJobsNow(filter.capture()); + // Unlike the self-service endpoint, the admin sweep is not scoped to one caller. + assertTrue(filter.getValue().test("alice:job")); + assertTrue(filter.getValue().test("bob:job")); + closeable.close(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java index 1a1b1a1599..a9baf5288f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java @@ -182,10 +182,10 @@ class FailureKindTest { class Unknown { @Test - void existsAndCanBeTriaged() { - assertThat(FailureKind.UNKNOWN.getActions()) - .containsExactlyInAnyOrder( - FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS); + void offersOnlyTheActionThatClearsIt() { + // Nothing here can be fixed, so "seen it" and "clear it" would be the same decision. + // Offering both just asks the reviewer to press two buttons to reach one outcome. + assertThat(FailureKind.UNKNOWN.getActions()).containsExactly(FailureActionId.DISMISS); } @Test @@ -233,8 +233,14 @@ class FailureKindTest { @Test void declaresOnlyWhatItLists() { - assertThat(FailureKind.UNKNOWN.declares(FailureActionId.ACKNOWLEDGE)).isTrue(); assertThat(FailureKind.UNKNOWN.declares(FailureActionId.DISMISS)).isTrue(); + assertThat(FailureKind.UNKNOWN.declares(FailureActionId.ACKNOWLEDGE)).isFalse(); + } + + @Test + void aKindWithSomethingToFixOffersTheFixAndAWayToSkipIt() { + assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getActions()) + .containsExactly(FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java index a4b830d0f8..58b8d1b408 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java @@ -51,9 +51,7 @@ class FileRunEventControllerTest { List.of(new AcknowledgeAction(store), new DismissAction(store))); controller = new FileRunEventController( - new FileRunEventService(store, registry, authority, userService, props), - authority, - props); + new FileRunEventService(store, registry, authority, userService, props)); lenient().when(authority.canEditPolicies()).thenReturn(true); lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); @@ -61,19 +59,46 @@ class FileRunEventControllerTest { } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { + return recorded("author@example.com", kind, teamId, fileId, "run-1"); + } + + /** + * As {@link #given} but naming who hit it, in its own run. A RUN-scoped kind keys on the run, + * so two rows sharing one run id are one incident, however they differ otherwise. + */ + private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) { + return recorded(actor, kind, teamId, fileId, "run-" + fileId); + } + + private FileRunEvent recorded( + String actor, FailureKind kind, Long teamId, String fileId, String runId) { return store.record( new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", - "run-1", + runId, null, fileId, "the raw failure message")); } + private static List fileIds(int count) { + return java.util.stream.IntStream.range(0, count).mapToObj(i -> "f-" + i).toList(); + } + + /** The status a refused call came back with. Fails the test if the call was allowed. */ + private HttpStatus statusOf(Runnable call) { + try { + call.run(); + } catch (ResponseStatusException e) { + return HttpStatus.valueOf(e.getStatusCode().value()); + } + throw new AssertionError("expected the call to be refused"); + } + @Nested @DisplayName("listing") class Listing { @@ -117,6 +142,7 @@ class FileRunEventControllerTest { @Test void showsAClosedRowsActionsDisabledWithAReasonRatherThanHidingThem() { + // Only visible by asking for the closed status: the default queue drops it. FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); controller.act(event.id(), "DISMISS", null); @@ -134,15 +160,16 @@ class FileRunEventControllerTest { @Test void filtersByStatusAndByKind() { - FileRunEvent open = given(FailureKind.UNKNOWN, TEAM, "open"); - given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked"); - controller.act(open.id(), "ACKNOWLEDGE", null); + FileRunEvent locked = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked"); + given(FailureKind.UNKNOWN, TEAM, "open"); + controller.act(locked.id(), "ACKNOWLEDGE", null); assertThat(controller.list(FileRunEventStatus.ACKNOWLEDGED, null, null).events()) .hasSize(1); assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events()) .extracting(FileRunEventView::fileId) .containsExactly("locked"); + // Acknowledged is still open work, so it stays in the default queue. assertThat(controller.list(null, "NO_SUCH_KIND", null).events()).isEmpty(); } @@ -182,7 +209,7 @@ class FileRunEventControllerTest { @Test void appliesADeclaredActionAndReturnsTheUpdatedRow() { - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); FileRunEventView updated = controller.act(event.id(), "ACKNOWLEDGE", null); @@ -218,21 +245,12 @@ class FileRunEventControllerTest { @Test void anAlreadyClosedRowIsAConflict() { // The request was well formed and would have been valid a moment earlier. - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); controller.act(event.id(), "DISMISS", null); assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null))) .isEqualTo(HttpStatus.CONFLICT); } - - private HttpStatus statusOf(Runnable call) { - try { - call.run(); - } catch (ResponseStatusException e) { - return HttpStatus.valueOf(e.getStatusCode().value()); - } - throw new AssertionError("expected the call to be refused"); - } } @Nested @@ -274,48 +292,68 @@ class FileRunEventControllerTest { } @Nested - @DisplayName("only a team leader may review failures") + @DisplayName("a leader reviews the team's failures, everyone else their own") class Authorization { @Test - void aMemberCannotListThem() { + void aMemberSeesTheirOwnFailuresAndNobodyElses() { + // A member can report a failure, so they get to see it back. It must not widen to a + // colleague's. + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.list(null, null, null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("mine"); } @Test - void aMemberCannotDispatchAnAction() { - // The read being refused is not enough on its own: an id learned any other way must - // not let a member close another user's failure. - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f-1"); - when(authority.canEditPolicies()).thenReturn(false); + void aLeaderSeesTheWholeTeams() { + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(true); - assertThatThrownBy(() -> controller.act(event.id(), "DISMISS", null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactlyInAnyOrder("mine", "theirs"); } @Test - void theRegistryIsAlsoLeaderOnly() { + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.kinds()) - .isInstanceOf(ResponseStatusException.class); + assertThat(controller.act(mine.id(), "DISMISS", null).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + + @Test + void aMemberCannotCloseAColleaguesEvenKnowingTheId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Answered as not-found rather than forbidden, so trying does not confirm the + // row exists. + FileRunEvent theirs = + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null))) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void theRegistryIsOpenBecauseItIsCopyNotData() { + // A member renders the failures they can see, so they need the labels for them. No role + // stub: the point is that kinds() never asks. + assertThat(controller.kinds()).isNotEmpty(); } @Test void loginDisabledTrustsTheLocalOperator() { - // A single-user deployment has no roles to distinguish, so the role gate must not lock - // the only user out of their own failures. + // A single-user deployment has no roles to distinguish, so the narrowing must not leave + // the only user reading nothing. ApplicationProperties unsecured = new ApplicationProperties(); unsecured.getSecurity().setEnableLogin(false); FileRunEventController noLogin = @@ -328,9 +366,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); assertThatCode(() -> noLogin.list(null, null, null)).doesNotThrowAnyException(); // Not merely permitted: the role is never consulted at all, which is what makes the @@ -375,9 +411,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); given(FailureKind.UNKNOWN, null, "unteamed"); given(FailureKind.UNKNOWN, TEAM, "teamed"); @@ -387,6 +421,109 @@ class FileRunEventControllerTest { } } + @Nested + @DisplayName("reporting from the editor") + class Reporting { + + @Test + void aMemberMayReportAndThenSeeTheirOwnReport() { + // Reporting was always open to a member; reading their own back is the round trip that + // makes the report worth anything to them. + when(authority.canEditPolicies()).thenReturn(false); + + assertThatCode( + () -> + controller.report( + new EditorFailureReport( + "compress", "E004", List.of("f-1"), "boom"))) + .doesNotThrowAnyException(); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("f-1"); + } + + @Test + void answersWithNoContentSoTheEditorNeverWaitsOnABody() { + EditorFailureReport report = + new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"); + + assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + void rejectsAReportWithNoOperation() { + assertThat( + statusOf( + () -> + controller.report( + new EditorFailureReport( + " ", "E004", List.of("f-1"), "boom")))) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void acceptsAReportAtTheFileLimitAndRecordsEveryRow() { + List atLimit = fileIds(EditorFailureReport.MAX_FILE_IDS); + + EditorFailureReport report = + new EditorFailureReport("compress", "E004", atLimit, "boom"); + + assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + .hasSize(EditorFailureReport.MAX_FILE_IDS); + } + + @Test + void refusesAReportOverTheFileLimitAndRecordsNothing() { + // One call used to be able to mint an unbounded number of permanent incidents, since + // each named file gets its own row and TOOL dedup keys never fold across ids. Refused + // rather than trimmed so nothing is lost silently, and refused before the first write + // so a rejected report cannot leave a partial set behind either. + List overLimit = fileIds(EditorFailureReport.MAX_FILE_IDS + 1); + + assertThat( + statusOf( + () -> + controller.report( + new EditorFailureReport( + "compress", + "E004", + overLimit, + "boom")))) + .isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + .isEmpty(); + } + + @Test + void saysWhatTheLimitIsSoAClientAuthorCanSeeWhatHappened() { + // The editor reports in the background, so the message is the only place this surfaces. + assertThatThrownBy( + () -> + controller.report( + new EditorFailureReport( + "compress", + "E004", + fileIds(EditorFailureReport.MAX_FILE_IDS + 1), + "boom"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining(String.valueOf(EditorFailureReport.MAX_FILE_IDS)); + } + + @Test + void theReportHasNoTeamOrFileNameToSupply() { + // Stated as a test because the absence of those fields is the property. Adding either + // to + // EditorFailureReport breaks this at compile time. + List components = + java.util.Arrays.stream(EditorFailureReport.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + + assertThat(components).containsExactly("operation", "errorCode", "fileIds", "detail"); + } + } + @Nested @DisplayName("action request body") class RequestBody { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java index 3537c9e06c..375e46f4ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java @@ -152,9 +152,9 @@ class FileRunEventHttpIntegrationTest { @Test void coercesQueryParametersAndFiltersOnThem() throws Exception { - String open = seed(FailureKind.UNKNOWN, TEAM, "open", "a"); - seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b"); - post("/api/v1/file-run-events/" + open + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}"); + String locked = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b"); + seed(FailureKind.UNKNOWN, TEAM, "open", "a"); + post("/api/v1/file-run-events/" + locked + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}"); JsonNode acknowledged = mapper.readTree(get("/api/v1/file-run-events?status=ACKNOWLEDGED").body()) @@ -189,6 +189,75 @@ class FileRunEventHttpIntegrationTest { } } + @Nested + @DisplayName("reporting from the editor") + class Reporting { + + @Test + void bindsAReportBodyAndAnswersNoContent() throws Exception { + HttpResponse response = + post( + "/api/v1/file-run-events/reports", + "{\"operation\":\"remove-password\",\"errorCode\":\"E004\"," + + "\"fileIds\":[\"f-1\",\"f-2\"],\"detail\":\"locked\"}"); + + assertThat(response.statusCode()).isEqualTo(204); + assertThat(response.body()).isEmpty(); + + JsonNode events = mapper.readTree(get("/api/v1/file-run-events").body()).get("events"); + assertThat(events).hasSize(2); + assertThat(events.get(0).get("origin").asString()).isEqualTo("TOOL"); + assertThat(events.get(0).get("kindId").asString()) + .isEqualTo("INPUT_PASSWORD_PROTECTED"); + } + + @Test + void acceptsAReportWithNoCodeOrFiles() throws Exception { + HttpResponse response = + post( + "/api/v1/file-run-events/reports", + "{\"operation\":\"compress\",\"detail\":\"network died\"}"); + + assertThat(response.statusCode()).isEqualTo(204); + JsonNode events = mapper.readTree(get("/api/v1/file-run-events").body()).get("events"); + assertThat(events).hasSize(1); + assertThat(events.get(0).get("kindId").asString()).isEqualTo("UNKNOWN"); + assertThat(events.get(0).get("fileId").isNull()).isTrue(); + } + + @Test + void rejectsAReportWithNoOperation() throws Exception { + assertThat( + post( + "/api/v1/file-run-events/reports", + "{\"errorCode\":\"E004\",\"detail\":\"boom\"}") + .statusCode()) + .isEqualTo(400); + } + + @Test + void rejectsAnOversizedReportWithoutRecordingAnyOfIt() throws Exception { + // Over the wire because that is where the flood would arrive: one request, an + // arbitrarily long fileIds array, a permanent row per entry. The read-back is the point + // of the test, since a partial write would be worse than either accepting or refusing. + String ids = + java.util.stream.IntStream.range(0, EditorFailureReport.MAX_FILE_IDS + 1) + .mapToObj(i -> "\"f-" + i + "\"") + .collect(java.util.stream.Collectors.joining(",")); + + HttpResponse response = + post( + "/api/v1/file-run-events/reports", + "{\"operation\":\"compress\",\"errorCode\":\"E004\",\"fileIds\":[" + + ids + + "],\"detail\":\"boom\"}"); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(mapper.readTree(get("/api/v1/file-run-events").body()).get("events")) + .isEmpty(); + } + } + @Nested @DisplayName("action dispatch") class Dispatch { @@ -197,7 +266,7 @@ class FileRunEventHttpIntegrationTest { void bindsTheRequestBodyAndReturnsTheUpdatedRow() throws Exception { // The regression guard: an object body, sent as real JSON over the wire, binding into // ActionRequest. A double-encoded string would fail here. - String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom"); + String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "boom"); HttpResponse response = post( @@ -262,7 +331,7 @@ class FileRunEventHttpIntegrationTest { @Test void mapsAnAlreadyClosedRowToConflict() throws Exception { - String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom"); + String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "boom"); post("/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}"); assertThat( @@ -369,11 +438,8 @@ class FileRunEventHttpIntegrationTest { } @Bean - FileRunEventController fileRunEventController( - FileRunEventService service, - PolicyManagementAuthority authority, - ApplicationProperties props) { - return new FileRunEventController(service, authority, props); + FileRunEventController fileRunEventController(FileRunEventService service) { + return new FileRunEventController(service); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java index cd75c181c5..4e6508bed9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java @@ -54,6 +54,9 @@ class FileRunEventServiceTest { lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR); + // A leader unless a test says otherwise: most of these are about team scoping, which is + // what a leader sees. The member narrowing has its own tests. + lenient().when(authority.canEditPolicies()).thenReturn(true); } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { @@ -76,7 +79,7 @@ class FileRunEventServiceTest { @Test void movesANewEventToAcknowledgedAndStampsTheActor() { - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); FileRunEvent updated = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()); @@ -87,7 +90,7 @@ class FileRunEventServiceTest { @Test void isANoOpWhenAlreadyAcknowledgedSoOwnershipIsNotStolen() { - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); FileRunEvent first = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()); Instant originalAt = first.statusAt(); @@ -114,7 +117,7 @@ class FileRunEventServiceTest { @Test void closesAnAcknowledgedEvent() { - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()); assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status()) @@ -225,7 +228,7 @@ class FileRunEventServiceTest { @Test void aClosedEventCannotBeActedOnAgain() { - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); service.dispatch(event.id(), "DISMISS", Map.of()); assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of())) @@ -286,13 +289,12 @@ class FileRunEventServiceTest { assertThat(service.availableActions(event)) .extracting(FileRunEventService.AvailableAction::labelKey) - .containsExactlyInAnyOrder( - "portal.failures.action.acknowledge", "portal.failures.action.dismiss"); + .containsExactly("portal.failures.action.dismiss"); } } @Nested - @DisplayName("team scoping") + @DisplayName("read scoping") class Scoping { @Test @@ -305,6 +307,68 @@ class FileRunEventServiceTest { .containsExactly("mine"); } + @Test + void aMemberReadsOnlyTheFailuresTheyCaused() { + // Reporting is open to a member, so reading their own back is what lets us tell them + // anything at all. A colleague's must not come with it. + store.record(RecordFailure.forEditor(FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "colleague@example.com", "theirs", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.list(null, null, 50)) + .extracting(FileRunEvent::fileId) + .containsExactly("mine"); + } + + @Test + void aMemberWithNoResolvableNameReadsNothingRatherThanEverything() { + // Narrowing to "mine" needs a name to narrow by. Dropping the filter would hand the + // whole team to someone who may not have it. + given(FailureKind.UNKNOWN, TEAM, "mine"); + when(authority.canEditPolicies()).thenReturn(false); + when(userService.getCurrentUsername()).thenReturn(null); + + assertThat(service.list(null, null, 50)).isEmpty(); + } + + @Test + void aMemberCannotActOnAColleaguesRowEvenKnowingItsId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Reported as not-found, so trying does not confirm the row exists. + FileRunEvent theirs = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, + TEAM, + "colleague@example.com", + "theirs", + "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of())) + .isInstanceOf(FailureActionException.class) + .extracting(e -> ((FailureActionException) e).getReason()) + .isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND); + + assertThat(store.find(theirs.id(), TEAM).orElseThrow().status()) + .isEqualTo(FileRunEventStatus.NEW); + } + + @Test + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.dispatch(mine.id(), "DISMISS", Map.of()).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + @Test void aCallerWhoseTeamCannotBeResolvedReadsNothing() { // A run with no stored policy is recorded unteamed, and those rows are shared by every @@ -390,4 +454,272 @@ class FileRunEventServiceTest { } } } + + @Nested + @DisplayName("reporting a failure the user hit in the editor") + class Reporting { + + @Test + void classifiesTheReportedCodeAndStampsItAsEditorOrigin() { + service.report( + new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); + + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); + assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); + assertThat(event.origin()).isEqualTo(FailureOrigin.TOOL); + assertThat(event.fileId()).isEqualTo("f-1"); + assertThat(event.detail()).contains("boom"); + } + + @Test + void takesTheTeamAndActorFromThePrincipalNotTheReport() { + // The report carries no team or actor field at all; both come from the caller's + // session. + service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); + + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); + assertThat(event.teamId()).isEqualTo(TEAM); + assertThat(event.actor()).isEqualTo(ACTOR); + } + + @Test + void stillFilesTheRowUnderTheTeamWhenTheReporterCannotBeNamed() { + // Recording is open to everyone and takes the caller's team, not their read scope: a + // reporter who cannot be named reads nothing back, but the row is still the team's + // rather than dropping into the unteamed bucket every team shares. No role stub either, + // since recording never asks. + when(userService.getCurrentUsername()).thenReturn(null); + + service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); + + assertThat(store.list(TEAM, null, null, null, 10)) + .singleElement() + .extracting(FileRunEvent::teamId) + .isEqualTo(TEAM); + } + + @Test + void recordsAnUnrecognisedCodeAsUnknownRatherThanDroppingIt() { + service.report(new EditorFailureReport("ocr", "E999", List.of("f-1"), "no idea")); + + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) + .isEqualTo(FailureKind.UNKNOWN); + } + + @Test + void recordsAnAbsentCodeAsUnknown() { + service.report(new EditorFailureReport("ocr", null, List.of("f-1"), "network died")); + + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) + .isEqualTo(FailureKind.UNKNOWN); + } + + @Test + void recordsOneIncidentPerFileSoEachDocumentStaysActionable() { + service.report( + new EditorFailureReport( + "compress", "E004", List.of("f-1", "f-2", "f-3"), "boom")); + + assertThat(store.list(TEAM, null, null, null, 10)) + .hasSize(3) + .extracting(FileRunEvent::fileId) + .containsExactlyInAnyOrder("f-1", "f-2", "f-3"); + } + + @Test + void foldsARepeatOfTheSameFileIntoTheExistingIncident() { + service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); + service.report( + new EditorFailureReport("compress", "E004", List.of("f-1"), "boom again")); + + assertThat(store.list(TEAM, null, null, null, 10)) + .singleElement() + .extracting(FileRunEvent::occurrences) + .isEqualTo(2); + } + + @Test + void recordsOneUnattributedIncidentWhenNoFileWasNamed() { + service.report(new EditorFailureReport("compress", "E004", List.of(), "boom")); + + assertThat(store.list(TEAM, null, null, null, 10)) + .singleElement() + .extracting(FileRunEvent::fileId) + .isNull(); + } + + @Test + void recordsEveryFileInALargeBatchRatherThanTrimmingIt() { + // A cap here used to drop the overflow silently, so a reviewer saw 25 of 60 failures + // with nothing indicating the rest existed. The processor path has never had one. + List many = + java.util.stream.IntStream.range(0, 60).mapToObj(i -> "f-" + i).toList(); + + service.report(new EditorFailureReport("compress", "E004", many, "boom")); + + assertThat(store.list(TEAM, null, null, null, 200)).hasSize(60); + } + + @Test + void keepsTheOperationNameOutOfTheStoredDocumentReferences() { + // The operation is context for the reviewer, not a document reference: it belongs in + // detail, never in fileId. + service.report( + new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); + + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); + assertThat(event.detail()).contains("remove-password"); + assertThat(event.fileId()).isEqualTo("f-1"); + } + + @Test + void storesTheReportedMessageVerbatimAlongsideTheOperation() { + // The user's own error about their own file. The operation is prefixed because an + // editor failure has no run to give a reviewer context. + service.report( + new EditorFailureReport( + "compress", "E004", List.of("f-1"), "Failed on Q4 report.pdf")); + + assertThat(store.list(TEAM, null, null, null, 10).getFirst().detail()) + .isEqualTo("compress: Failed on Q4 report.pdf"); + } + + @Test + void theServiceHoldsNothingThatCouldReachADocument() { + // Asserted structurally rather than with verifyNoInteractions on unwired mocks, which + // is how the version of this test on the other branch passed without proving anything. + List> forbidden = + List.of( + stirling.software.proprietary.policy.ledger.ProcessedLedger.class, + stirling.software.common.service.FileStorage.class, + stirling.software.proprietary.policy.output.PolicyOutputSink.class); + + List> held = + Arrays.stream(FileRunEventService.class.getDeclaredFields()) + .filter(field -> !field.isSynthetic()) + .map(Field::getType) + .toList(); + + assertThat(held).isNotEmpty().doesNotContainAnyElementsOf(forbidden); + } + } + + @Nested + @DisplayName("editor incidents stay separate") + class EditorIncidentIdentity { + + private void reportedBy(String actor, String fileId) { + when(userService.getCurrentUsername()).thenReturn(actor); + service.report(new EditorFailureReport("compress", null, List.of(fileId), "boom")); + } + + @Test + void twoPeopleHittingTheSameUnclassifiedFailureAreTwoIncidents() { + // UNKNOWN is RUN scoped and an editor report has no run, so without the fallback every + // unclassified editor failure in a team collapsed into one row: one actor credited for + // everyone's, and the wrong person offered the row. + reportedBy("alice@example.com", "a-1"); + reportedBy("bob@example.com", "b-1"); + + assertThat(store.list(TEAM, null, null, null, 10)) + .extracting(FileRunEvent::actor) + .containsExactlyInAnyOrder("alice@example.com", "bob@example.com"); + } + + @Test + void onePersonsTwoBrokenFilesAreTwoIncidents() { + reportedBy("alice@example.com", "a-1"); + reportedBy("alice@example.com", "a-2"); + + assertThat(store.list(TEAM, null, null, null, 10)) + .extracting(FileRunEvent::fileId) + .containsExactlyInAnyOrder("a-1", "a-2"); + } + + @Test + void theSamePersonHittingTheSameFileTwiceIsOneIncident() { + reportedBy("alice@example.com", "a-1"); + reportedBy("alice@example.com", "a-1"); + + assertThat(store.list(TEAM, null, null, null, 10)) + .singleElement() + .extracting(FileRunEvent::occurrences) + .isEqualTo(2); + } + } + + @Nested + @DisplayName("files deleted from the editor") + class RemovedFiles { + + private void reported(String fileId) { + service.report(new EditorFailureReport("compress", "E004", List.of(fileId), "boom")); + } + + @Test + void closeTheirIncidentsSoTheQueueStopsAskingAboutThem() { + reported("f-1"); + + assertThat(service.forgetFiles(List.of("f-1"))).isEqualTo(1); + assertThat(service.list(null, null, 10)) + .as("the open queue is what the reviewer works from") + .isEmpty(); + } + + @Test + void theRowSurvivesForAudit() { + reported("f-1"); + service.forgetFiles(List.of("f-1")); + + assertThat(service.list(FileRunEventStatus.FILE_REMOVED, null, 10)) + .singleElement() + .satisfies( + event -> { + assertThat(event.fileId()).isEqualTo("f-1"); + assertThat(event.detail()).contains("boom"); + }); + } + + @Test + void aReviewersDismissKeepsItsMeaningAndItsActor() { + reported("f-1"); + FileRunEvent event = service.list(null, null, 10).getFirst(); + service.dispatch(event.id(), "DISMISS", Map.of()); + + assertThat(service.forgetFiles(List.of("f-1"))) + .as("only open rows move; a closed one has already been decided") + .isZero(); + assertThat(service.list(FileRunEventStatus.DISMISSED, null, 10)).hasSize(1); + } + + @Test + void aColleaguesIncidentIsUntouchedEvenForALeader() { + // File ids come from the client, so naming one must not close someone else's row. The + // caller here is a leader, who reads the whole team: this path narrows to their own + // rows + // regardless, since a null actor would otherwise match every unattributed row. + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "employee@example.com", "f-1", "theirs")); + + assertThat(service.forgetFiles(List.of("f-1"))).isZero(); + } + + @Test + void aProcessorIncidentIsUntouchedEvenOnTheSameFileId() { + // Nothing was deleted from an editor there, and the file may still be in the bucket. + given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f-1"); + + assertThat(service.forgetFiles(List.of("f-1"))).isZero(); + } + + @Test + void namingNoFilesClosesNothing() { + reported("f-1"); + + assertThat(service.forgetFiles(List.of())).isZero(); + assertThat(service.forgetFiles(java.util.Arrays.asList(null, " "))).isZero(); + assertThat(service.list(null, null, 10)).hasSize(1); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java index 46d2be8a0d..982d804354 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Proxy; import java.time.Instant; +import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -54,11 +55,16 @@ class FileRunEventStoreDbTest { } private RecordFailure failure(FailureKind kind, Long teamId, String fileId) { + return failure(kind, teamId, "author@example.com", fileId); + } + + /** As {@link #failure} but naming the actor, which is what the read scope narrows by. */ + private RecordFailure failure(FailureKind kind, Long teamId, String actor, String fileId) { return new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", "run-1", null, @@ -73,16 +79,37 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs")); store.record(failure(FailureKind.UNKNOWN, null, "unteamed")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("ours"); // A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is // never true, which is what the explicit null branch in the JPQL exists for. - assertThat(store.list(null, null, null, 10)) + assertThat(store.list(null, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } + @Test + @DisplayName("actor narrowing is enforced by the query, within the team") + void actorNarrowingIsEnforcedBySql() { + // The clause that makes a member read only their own rows. Exercised here rather than only + // against the in-memory repository, which reimplements the filter in Java and would agree + // with a query that had lost it. + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "mine@example.com", "f1")); + store.record( + failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "theirs@example.com", "f2")); + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null, "f3")); + + assertThat(store.list(TEAM, null, null, "mine@example.com", 10)) + .extracting(FileRunEvent::fileId) + .containsExactly("f1"); + // A null actor is "no filter", which is what a leader reads with: the whole team, including + // the rows nobody is named on. + assertThat(store.list(TEAM, null, null, null, 10)) + .extracting(FileRunEvent::fileId) + .containsExactlyInAnyOrder("f1", "f2", "f3"); + } + @Test @DisplayName("a fold lands on the row's current state, not the caller's snapshot") void foldTargetsTheCurrentRowNotACallersSnapshot() { @@ -169,7 +196,7 @@ class FileRunEventStoreDbTest { assertThat(replacement.id()).isNotEqualTo(first.id()); assertThat(replacement.occurrences()).isEqualTo(1); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -187,7 +214,7 @@ class FileRunEventStoreDbTest { FileRunEvent folded = store.record(secondSweep); assertThat(folded.occurrences()).isEqualTo(2); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .as("one incident per document, however many runs it failed in") .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("file-hash-a", "file-hash-b"); @@ -198,12 +225,66 @@ class FileRunEventStoreDbTest { FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null, + null, "policy-1", runId, fileId, "locked"); } + @Test + @DisplayName("closing deleted files touches only that owner's own open editor rows") + void markFilesRemovedIsScopedBySqlNotByTheCaller() { + // The scoping is entirely in the JPQL, so the in-memory fake proves nothing about it: + // it implements the same rules by hand and would agree with a wrong query. + FileRunEvent mine = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1", "boom")); + FileRunEvent theirs = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "colleague@example.com", "f-1", "boom")); + FileRunEvent otherTeam = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, + OTHER_TEAM, + "owner@example.com", + "f-1", + "boom")); + FileRunEvent fromProcessor = store.record(failure(FailureKind.UNKNOWN, TEAM, "f-1")); + + int closed = store.markFilesRemoved(TEAM, "owner@example.com", List.of("f-1")); + + assertThat(closed).isEqualTo(1); + assertThat(store.find(mine.id(), TEAM).orElseThrow().status()) + .isEqualTo(FileRunEventStatus.FILE_REMOVED); + assertThat(store.find(theirs.id(), TEAM).orElseThrow().status()) + .as("another person's incident about their own file") + .isEqualTo(FileRunEventStatus.NEW); + assertThat(store.find(otherTeam.id(), OTHER_TEAM).orElseThrow().status()) + .as("another team entirely") + .isEqualTo(FileRunEventStatus.NEW); + assertThat(store.find(fromProcessor.id(), TEAM).orElseThrow().status()) + .as("nothing was deleted from an editor here") + .isEqualTo(FileRunEventStatus.NEW); + } + + @Test + @DisplayName("a row already closed by a reviewer is left as they left it") + void markFilesRemovedLeavesClosedRowsAlone() { + FileRunEvent event = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1", "boom")); + store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com"); + + assertThat(store.markFilesRemoved(TEAM, "owner@example.com", List.of("f-1"))).isZero(); + assertThat(store.find(event.id(), TEAM).orElseThrow().statusActor()) + .isEqualTo("reviewer@example.com"); + } + @Test @DisplayName("the kind filter applies before the limit, not after") void kindFilterAppliesBeforeTheLimit() { @@ -212,7 +293,7 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i)); } - assertThat(store.list(TEAM, null, "UNKNOWN", 1)) + assertThat(store.list(TEAM, null, "UNKNOWN", null, 1)) .extracting(FileRunEvent::fileId) .containsExactly("old-unknown"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java index 2adbc7d2b2..97de1952e9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java @@ -281,7 +281,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a")); store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b")); - assertThat(store.list(TEAM, null, null, 50)) + assertThat(store.list(TEAM, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("mine"); } @@ -293,18 +293,37 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a")); store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b")); - assertThat(store.list(null, null, null, 50)) + assertThat(store.list(null, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } + @Test + void dismissingARowClearsItFromTheDefaultQueue() { + // The reviewer's whole complaint: without this, dismissing changes the buttons and + // leaves the row sitting there, so the list can only ever grow. + FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); + store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer"); + + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); + assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, null, 10)).hasSize(1); + } + + @Test + void anAcknowledgedRowIsStillOpenWorkSoItStays() { + FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); + store.applyStatus(event.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "reviewer"); + + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); + } + @Test void filtersByStatus() { FileRunEvent open = store.record(failure(FailureKind.UNKNOWN, TEAM, "open", "a")); store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b")); store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me"); - assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50)) + assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("open"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java index ed8b6f29cd..6dbb16322b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java @@ -6,6 +6,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.Function; @@ -53,19 +54,18 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { return kindId == null || kindId.equals(entity.getKindId()); } - @Override - public List findByTeam(Long teamId, String kindId, Pageable pageable) { - return page( - newestFirst( - rows.values().stream() - .filter(e -> sameTeam(e, teamId) && sameKind(e, kindId)) - .toList()), - pageable); + /** Null means the whole team, matching the JPQL's {@code :actor is null} branch. */ + private static boolean sameActor(FileRunEventEntity entity, String actor) { + return actor == null || actor.equals(entity.getActor()); } @Override public List findByTeamAndStatus( - Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) { + Long teamId, + FileRunEventStatus status, + String kindId, + String actor, + Pageable pageable) { return page( newestFirst( rows.values().stream() @@ -73,7 +73,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { e -> sameTeam(e, teamId) && e.getStatus() == status - && sameKind(e, kindId)) + && sameKind(e, kindId) + && sameActor(e, actor)) .toList()), pageable); } @@ -124,6 +125,51 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { return 1; } + @Override + public int markFilesRemoved( + Long teamId, + String actor, + Collection fileIds, + Instant now, + Collection allowedFrom) { + int closed = 0; + for (FileRunEventEntity entity : rows.values()) { + if (entity.getOrigin() != FailureOrigin.TOOL + || !sameTeam(entity, teamId) + || !Objects.equals(entity.getActor(), actor) + || entity.getFileId() == null + || !fileIds.contains(entity.getFileId()) + || !allowedFrom.contains(entity.getStatus())) { + continue; + } + entity.setStatus(FileRunEventStatus.FILE_REMOVED); + entity.setStatusActor(actor); + entity.setStatusAt(now); + closed++; + } + return closed; + } + + @Override + public List findByTeamAndStatusIn( + Long teamId, + List statuses, + String kindId, + String actor, + Pageable pageable) { + return page( + newestFirst( + rows.values().stream() + .filter( + e -> + sameTeam(e, teamId) + && statuses.contains(e.getStatus()) + && sameKind(e, kindId) + && sameActor(e, actor)) + .toList()), + pageable); + } + @Override public List findByTeamAndDedupKey( Long teamId, String dedupKey, Pageable pageable) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java new file mode 100644 index 0000000000..2a71c236a7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java @@ -0,0 +1,264 @@ +package stirling.software.proprietary.failure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.core.io.ByteArrayResource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.service.JobQueue; +import stirling.software.common.service.ResourceMonitor; +import stirling.software.common.service.TaskManager; +import stirling.software.common.service.ToolMetadataService; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.engine.PolicyEngine; +import stirling.software.proprietary.policy.engine.PolicyExecutor; +import stirling.software.proprietary.policy.engine.PolicyRunRegistry; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyInputs; +import stirling.software.proprietary.policy.output.InlineOutputSink; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; +import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +import tools.jackson.databind.json.JsonMapper; + +/** + * Pins what the engine records as a failure's actor against what a reader gets back, because the + * two sides used to assert independently: the engine's test passed {@code any()} for the actor, and + * the service's fixtures assumed an actor the engine never actually produced. Every collaborator + * between the failing tool call and the read is real here, so a regression in either one fails. + * + *

This is what makes this PR's promise hold. Reads are narrowed to the rows the caller is the + * actor on, so if the engine names the wrong person, a member reads nothing at all. + * + *

The bug it exists for: the engine recorded the BILLING principal as the actor, which for a + * stored policy is always its owner. So an attended failure was filed under someone who never + * touched the document, and the member who did could not see it. + */ +@ExtendWith(MockitoExtension.class) +class PolicyFailureAttributionTest { + + private static final String ROTATE = "/api/v1/general/rotate-pdf"; + private static final Long TEAM = 3L; + + @Mock private InternalApiClient internalApiClient; + @Mock private ToolMetadataService toolMetadataService; + @Mock private TaskManager taskManager; + @Mock private FileStorage fileStorage; + @Mock private JobOwnershipService jobOwnershipService; + @Mock private ResourceMonitor resourceMonitor; + @Mock private JobQueue jobQueue; + @Mock private PolicyStore policyStore; + @Mock private PolicyManagementAuthority authority; + @Mock private UserServiceInterface userService; + + @TempDir Path tempDir; + + private PolicyEngine engine; + private FileRunEventService service; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().setEnableLogin(true); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("failure-attribution-test-"); + + FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository()); + service = + new FileRunEventService( + store, + new FailureActionRegistry( + List.of(new AcknowledgeAction(store), new DismissAction(store))), + authority, + userService, + props); + + PolicyFailureRecorder recorder = + new PolicyFailureRecorder( + new FailureClassifier(JsonMapper.builder().build()), store, policyStore); + PolicyExecutor executor = + new PolicyExecutor( + internalApiClient, + toolMetadataService, + new TempFileManager(new TempFileRegistry(), props), + JsonMapper.builder().build()); + engine = + new PolicyEngine( + executor, + taskManager, + new PolicyRunRegistry(new ApplicationProperties()), + recorder, + fileStorage, + jobOwnershipService, + List.of(new InlineOutputSink(fileStorage)), + new PolicyOutputResolver(new InProcessSourceStore()), + resourceMonitor, + jobQueue, + new PolicyAssetResolver(new InProcessPolicyAssetStore())); + + lenient() + .when(jobOwnershipService.createScopedJobKey(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false); + lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + // The team is resolved from the policy, so the recorded row lands in the reader's team. + lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy())); + lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); + } + + /** Alice's policy, shared with her team. Bob is a member of it and does not own it. */ + private static Policy sharedPolicy() { + return new Policy( + "p1", + "rotate", + "alice", + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline(), + TEAM); + } + + /** + * Run the shared policy so its single tool step fails, as {@code triggeredBy} (null = sweep). + */ + private void runAndFail(String triggeredBy, String sourceId, String fileIdentity) + throws Exception { + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + if (triggeredBy != null) { + MDC.put("auditPrincipal", triggeredBy); + } + try { + engine.runPolicy( + sharedPolicy(), + PolicyInputs.of(List.of(pdf())), + PolicyProgressListener.NOOP, + sourceId, + fileIdentity) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + } + + private static ByteArrayResource pdf() { + return new ByteArrayResource("input".getBytes()) { + @Override + public String getFilename() { + return "input.pdf"; + } + }; + } + + /** Read as a plain member, who is narrowed to the rows they are the actor on. */ + private FileRunEvent asMember(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(false); + List visible = service.list(null, null, 10); + return visible.isEmpty() ? null : visible.getFirst(); + } + + /** Read as a team leader, who reviews the whole team's incidents. */ + private FileRunEvent asReviewer(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(true); + return service.list(null, null, 10).getFirst(); + } + + @Nested + @DisplayName("a non-owner runs a shared policy on their own upload") + class AttendedByANonOwner { + + @Test + void theTriggeringUserCanReadTheFailureTheyCaused() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // The whole point: Bob's read scope narrows to his own rows, so the row only reaches + // him if the engine named him. Before the fix this list was empty. + FileRunEvent mine = asMember("bob"); + assertThat(mine).as("bob must be able to see the failure he caused").isNotNull(); + assertThat(mine.actor()).isEqualTo("bob"); + } + + @Test + void thePolicyOwnerIsNotNamedAsTheActorMerelyForBeingBilled() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // Alice owns the policy and pays for the run, but she never touched the document. + assertThat(asReviewer("alice").actor()).isEqualTo("bob"); + } + + @Test + void aColleagueWhoDidNotTriggerItCannotSeeItAtAll() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + assertThat(asMember("carol")).isNull(); + } + } + + @Nested + @DisplayName("an unattended sweep pulls a file from a source") + class UnattendedSweep { + + @Test + void theRowIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asReviewer("alice").actor()) + .as("a trigger-fired run has no user to name") + .isNull(); + } + + @Test + void theSourceThatFedItIsStillRecorded() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + FileRunEvent unattended = asReviewer("alice"); + assertThat(unattended.sourceId()).isEqualTo("src-watched-folder"); + assertThat(unattended.fileId()).isEqualTo("file-hash-1"); + } + + @Test + void aMemberDoesNotInheritAnUnattendedFailureAsTheirOwn() throws Exception { + // An unowned row must not fall to whoever happens to be reading: with no actor there is + // nothing for a member's narrowed read to match. + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asMember("bob")).isNull(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java index 841fdec684..73c5add6ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java @@ -89,12 +89,13 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", - "dana@example.com", null, + null, + "dana@example.com", "Policy run failed: locked", passwordFailure()); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); assertThat(event.runId()).isEqualTo("run-1"); assertThat(event.policyId()).isEqualTo("policy-1"); @@ -114,12 +115,13 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", - "dana@example.com", null, + null, + "dana@example.com", "Policy run failed: something we do not recognise", new RuntimeException("boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("something we do not recognise"); } @@ -133,10 +135,11 @@ class PolicyFailureRecorderTest { "policy-1", null, null, + null, "Policy run failed: java.lang.NullPointerException", new RuntimeException("npe")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("NullPointerException"); } @@ -147,9 +150,9 @@ class PolicyFailureRecorderTest { when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); recorder.recordRunFailureAs( - FailureKind.UNKNOWN, "run-3", "policy-1", null, "could not be queued"); + FailureKind.UNKNOWN, "run-3", "policy-1", null, null, "could not be queued"); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -161,11 +164,23 @@ class PolicyFailureRecorderTest { when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); recorder.recordRunFailure( - "run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure()); + "run-1", + "policy-1", + null, + null, + "dana@example.com", + "locked", + passwordFailure()); recorder.recordRunFailure( - "run-2", "policy-1", "dana@example.com", null, "locked", passwordFailure()); + "run-2", + "policy-1", + null, + null, + "dana@example.com", + "locked", + passwordFailure()); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(2); assertThat(events).allMatch(event -> event.occurrences() == 1); assertThat(events) @@ -173,16 +188,64 @@ class PolicyFailureRecorderTest { .containsExactlyInAnyOrder("run-1", "run-2"); } + @Test + void namesTheSourceWhenNoUserWasInvolved() { + // An unattended file has no actor, so the source is the only attribution a reviewer + // gets: which bucket, folder or webhook fed the run. + when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); + + recorder.recordRunFailure( + "run-1", + "policy-1", + "src-s3-invoices", + null, + null, + "locked", + passwordFailure()); + + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); + assertThat(event.sourceId()).isEqualTo("src-s3-invoices"); + assertThat(event.actor()).isNull(); + } + + @Test + void keepsTwoSourcesApartEvenWhenTheyFailIdentically() { + // Same kind, same policy, no user on either: without the source they would be one row. + when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); + + recorder.recordRunFailureAs( + FailureKind.UNKNOWN, "run-1", "policy-1", "src-a", null, "unreachable"); + recorder.recordRunFailureAs( + FailureKind.UNKNOWN, "run-2", "policy-1", "src-b", null, "unreachable"); + + assertThat(store.list(TEAM, null, null, null, 10)) + .hasSize(2) + .extracting(FileRunEvent::sourceId) + .containsExactlyInAnyOrder("src-a", "src-b"); + } + @Test void thatSameRunFailingTwiceStaysOneIncident() { when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); recorder.recordRunFailure( - "run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure()); + "run-1", + "policy-1", + null, + null, + "dana@example.com", + "locked", + passwordFailure()); recorder.recordRunFailure( - "run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure()); + "run-1", + "policy-1", + null, + null, + "dana@example.com", + "locked", + passwordFailure()); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -198,19 +261,20 @@ class PolicyFailureRecorderTest { when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); recorder.recordRunFailure( - "run-1", "policy-1", null, null, "boom", new RuntimeException()); + "run-1", "policy-1", null, null, null, "boom", new RuntimeException()); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test void leavesAnAdHocRunUnteamedRatherThanGuessing() { // No stored policy means no team to attribute it to. Recorded unteamed rather than // attributed to whichever team happened to be nearby. - recorder.recordRunFailure("run-1", null, null, null, "boom", new RuntimeException()); + recorder.recordRunFailure( + "run-1", null, null, null, null, "boom", new RuntimeException()); - assertThat(store.list(null, null, null, 10)).hasSize(1); - assertThat(store.list(TEAM, null, null, 10)).isEmpty(); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); } @Test @@ -224,11 +288,12 @@ class PolicyFailureRecorderTest { "policy-1", null, null, + null, "boom", new RuntimeException())) .doesNotThrowAnyException(); // Still recorded, just unteamed: a lookup problem must not lose the incident. - assertThat(store.list(null, null, null, 10)).hasSize(1); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); } } @@ -258,6 +323,7 @@ class PolicyFailureRecorderTest { "policy-1", null, null, + null, "Policy run failed: locked", passwordFailure())) .doesNotThrowAnyException(); @@ -270,9 +336,15 @@ class PolicyFailureRecorderTest { assertThatCode( () -> recorder.recordRunFailure( - "run-1", "policy-1", null, null, "no cause", null)) + "run-1", + "policy-1", + null, + null, + null, + "no cause", + null)) .doesNotThrowAnyException(); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } } @@ -286,11 +358,11 @@ class PolicyFailureRecorderTest { when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); recorder.recordRunFailure( - "run-1", "policy-1", null, null, "boom", new IOException("x")); + "run-1", "policy-1", null, null, null, "boom", new IOException("x")); recorder.recordRunFailure( - "run-1", "policy-1", null, null, "boom", new IOException("x")); + "run-1", "policy-1", null, null, null, "boom", new IOException("x")); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(1); assertThat(events.getFirst().occurrences()).isEqualTo(2); } @@ -301,11 +373,11 @@ class PolicyFailureRecorderTest { when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); recorder.recordRunFailure( - "run-1", "policy-1", null, null, "boom", new IOException("x")); + "run-1", "policy-1", null, null, null, "boom", new IOException("x")); recorder.recordRunFailure( - "run-2", "policy-1", null, null, "boom", new IOException("x")); + "run-2", "policy-1", null, null, null, "boom", new IOException("x")); - assertThat(store.list(TEAM, null, null, 10)).hasSize(2); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java index ef9648571f..86c52c4007 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java @@ -24,7 +24,14 @@ class RecordFailurePrivacyTest { private static RecordFailure withDetail(String detail) { return RecordFailure.forRun( - FailureKind.UNKNOWN, 1L, "dana@example.com", "policy-1", "run-1", null, detail); + FailureKind.UNKNOWN, + 1L, + "dana@example.com", + "policy-1", + "run-1", + null, + null, + detail); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java index 811aae6b4f..0f97fcaf62 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java @@ -39,6 +39,18 @@ class AdminPolicyManagementAuthorityTest { assertFalse(authority().canEditPolicies()); } + @Test + void adminMayTriggerPolicies() { + when(userService.isCurrentUserAdmin()).thenReturn(true); + assertTrue(authority().canTriggerPolicies()); + } + + @Test + void nonAdminMayNotTriggerPolicies() { + when(userService.isCurrentUserAdmin()).thenReturn(false); + assertFalse(authority().canTriggerPolicies()); + } + @Test void currentUserTeamIdResolvesFromTheCurrentUsersTeam() { Team team = new Team(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 43477aa9ed..2fa675597c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -216,7 +217,7 @@ class PolicyControllerTest { } private static PolicyRunHandle handle(String runId) { - PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null); + PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null, null); return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run)); } @@ -317,7 +318,7 @@ class PolicyControllerTest { @Test @DisplayName("returns the run view when present") void found() { - PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null); + PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null, null); when(runRegistry.get("run-3")).thenReturn(run); ResponseEntity response = controller.status("run-3"); @@ -346,9 +347,11 @@ class PolicyControllerTest { @Test @DisplayName("excludes ad-hoc runs and runs owned by others") void filtersRuns() { - PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null); - PolicyRun ownedStored = new PolicyRun("owned", "policy-A", definitionWithStep(), null); - PolicyRun otherStored = new PolicyRun("other", "policy-B", definitionWithStep(), null); + PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null, null); + PolicyRun ownedStored = + new PolicyRun("owned", "policy-A", definitionWithStep(), null, null, null); + PolicyRun otherStored = + new PolicyRun("other", "policy-B", definitionWithStep(), null, null, null); when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored)); // ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned @@ -736,5 +739,78 @@ class PolicyControllerTest { assertThat(((ResponseStatusException) e).getStatusCode()) .isEqualTo(HttpStatus.NOT_FOUND)); } + + @Test + @DisplayName("trigger is forbidden for a team member who cannot manage policies") + void triggerForbiddenForMember() { + // Sweeping a policy's configured sources is a policy-management capability, so being + // in the policy's team is not on its own enough to perform it. + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canTriggerPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.trigger("a")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + // Rejected before the policy is looked up, so no run starts. + verify(policyRunner, never()).run(any()); + verify(policyStore, never()).get(any()); + } + + @Test + @DisplayName("trigger runs for a caller who may manage policies") + void triggerAllowedForLeader() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canTriggerPolicies()).thenReturn(true); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0); + when(policyRunner.run(p)).thenReturn(outcome); + + ResponseEntity response = controller.trigger("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody()).isEqualTo(outcome); + } + + @Test + @DisplayName("trigger skips the role check when login is disabled") + void triggerTrustsTheLocalOperator() { + // Single-user deployments have no roles at all; the gate must not lock them out of + // their + // own sweeps. + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", null); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0); + when(policyRunner.run(p)).thenReturn(outcome); + + assertThat(controller.trigger("a").getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + verify(policyManagementAuthority, never()).canTriggerPolicies(); + } + + @Test + @DisplayName("running a policy over the caller's own files stays open to any member") + void storedRunIsNotGatedByRole() { + // Editor enforcement: every member's upload/export runs the team's stored policies on + // their own documents. Gating this the way the sweep is gated would break the editor. + applicationProperties.getSecurity().setEnableLogin(true); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-9")); + + ResponseEntity> response = + assertDoesNotThrow(() -> controller.runStoredPolicy("a", new PolicyRunFiles())); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + verify(policyManagementAuthority, never()).canTriggerPolicies(); + verify(policyManagementAuthority, never()).canEditPolicies(); + } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index b3aaf291f9..1efe094fe4 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; @@ -225,7 +226,161 @@ class PolicyEngineTest { // A failed run is recorded durably, so an admin can see it after the in-memory run expires. verify(failureRecorder) .recordRunFailure( - eq(runId), any(), any(), any(), anyString(), any(Throwable.class)); + eq(runId), any(), any(), any(), any(), anyString(), any(Throwable.class)); + } + + @Test + void recordsWhichSourceFedAFailedRun() throws Exception { + // The source is threaded onto the run so an unattended failure is attributable: there is no + // user to name for a file that arrived from a bucket. The actor is asserted null rather + // than + // any(): a loose matcher here is what let the owner be recorded as the actor unnoticed. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + PolicyRunHandle handle = + engine.runPolicy( + policyOwnedBy("owner"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + "src-s3-invoices", + "file-hash-1"); + handle.completion().get(10, TimeUnit.SECONDS); + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + eq("src-s3-invoices"), + eq("file-hash-1"), + isNull(), + anyString(), + any(Throwable.class)); + } + + @Test + void anAttendedFailureIsRecordedAgainstWhoTriggeredItNotThePolicysOwner() throws Exception { + // Bob runs Alice's shared policy on his own upload and it fails. The row must name Bob: he + // is the one whose browser holds the document, and a member's read scope narrows to their + // own rows, so filing it under Alice hides it from the only person who can act on it. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); // the request thread's acting user + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1") + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + isNull(), + eq("bob-doc-1"), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void anUnattendedFailureIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + // The two identities are deliberately different, and this pins both at once: usage is + // charged to the owner (MDC audit principal on the worker), but the failure has no actor, + // which is what makes it UNOWNED and hands the owner actions to the team's reviewer. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + String[] principalAtDispatch = {""}; + when(internalApiClient.post(eq(ROTATE), any())) + .thenAnswer( + invocation -> { + principalAtDispatch[0] = MDC.get("auditPrincipal"); + throw new RuntimeException("boom"); + }); + + // No MDC and no security context: exactly a trigger-fired sweep. + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + "src-watched-folder", + "file-hash-1") + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals("alice", principalAtDispatch[0], "billing must still be the policy owner"); + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + eq("src-watched-folder"), + eq("file-hash-1"), + isNull(), + anyString(), + any(Throwable.class)); + } + + @Test + void anAdHocFailureIsRecordedAgainstTheSubmittingUser() throws Exception { + // An ad-hoc run has no stored policy, so the submitter is both payer and actor. Asserted so + // the two entry points cannot drift apart. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); + try { + engine.submit( + definition(new PipelineStep(ROTATE, Map.of())), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + any(), + any(), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void aRunRefusedAtAdmissionIsRecordedAgainstWhoeverTriggeredIt() throws Exception { + // The queue-full path records its own row, and it is attended: the user is still holding + // the + // document, so it must reach them rather than landing as an ownerless incident. + when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true); + CompletableFuture rejected = new CompletableFuture<>(); + rejected.completeExceptionally(new RuntimeException("Job queue full")); + doReturn(rejected).when(jobQueue).queueJob(anyString(), anyInt(), any(), anyLong()); + + MDC.put("auditPrincipal", "bob"); + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1"); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailureAs(any(), anyString(), any(), isNull(), eq("bob"), anyString()); } @Test @@ -238,7 +393,7 @@ class PolicyEngineTest { doThrow(new RuntimeException("event store unavailable")) .when(failureRecorder) .recordRunFailure( - anyString(), any(), any(), any(), anyString(), any(Throwable.class)); + anyString(), any(), any(), any(), any(), anyString(), any(Throwable.class)); PolicyRunHandle handle = engine.submit( @@ -506,6 +661,17 @@ class PolicyEngineTest { return new PipelineDefinition("test", List.of(steps), OutputSpec.inline()); } + private static Policy policyOwnedBy(String owner) { + return new Policy( + "p1", + "rotate", + owner, + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline()); + } + private void stubEndpoint(String endpoint, Resource body) { when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java index 784baa207c..692fe84c37 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java @@ -97,7 +97,12 @@ class PolicyRunRegistryTest { private PolicyRun register(String runId) { PolicyRun run = new PolicyRun( - runId, null, new PipelineDefinition(runId, List.of(), List.of()), null); + runId, + null, + new PipelineDefinition(runId, List.of(), List.of()), + null, + null, + null); registry.register(run); return run; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index ba66cb8b2d..d6f5951e3a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -78,13 +78,13 @@ class PolicyRunnerTest { @Test void runsOnceWithNoFilesWhenThePolicyHasNoSources() { Policy policy = policy(List.of()); - when(policyEngine.runPolicy(eq(policy), any(), any(), any())) + when(policyEngine.runPolicy(eq(policy), any(), any(), any(), any())) .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); runner.run(policy); ArgumentCaptor inputs = ArgumentCaptor.forClass(PolicyInputs.class); - verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any(), any()); + verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any(), any(), any()); assertTrue(inputs.getValue().primary().isEmpty()); // Ledger hygiene still runs: rows recorded for a generator policy's folder outputs // are pruned by its own sweeps rather than accumulating until the policy is deleted. @@ -137,12 +137,12 @@ class PolicyRunnerTest { List.of( ResolvedInput.of(PolicyInputs.of(List.of())), ResolvedInput.of(PolicyInputs.of(List.of())))); - when(policyEngine.runPolicy(any(), any(), any(), any())) + when(policyEngine.runPolicy(any(), any(), any(), any(), any())) .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); runner.run(policy); - verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any(), any()); + verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any(), any(), any()); } @Test @@ -154,7 +154,7 @@ class PolicyRunnerTest { when(folderSource.supports(spec)).thenReturn(true); when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit)); CompletableFuture completion = new CompletableFuture<>(); - when(policyEngine.runPolicy(any(), any(), any(), any())) + when(policyEngine.runPolicy(any(), any(), any(), any(), any())) .thenReturn(new PolicyRunHandle("r", completion)); runner.run(policy); @@ -175,7 +175,7 @@ class PolicyRunnerTest { when(folderSource.supports(spec)).thenReturn(true); when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit)); CompletableFuture completion = new CompletableFuture<>(); - when(policyEngine.runPolicy(any(), any(), any(), any())) + when(policyEngine.runPolicy(any(), any(), any(), any(), any())) .thenReturn(new PolicyRunHandle("r", completion)); runner.run(policy); @@ -224,12 +224,12 @@ class PolicyRunnerTest { when(folderSource.supports(spec)).thenReturn(true); when(folderSource.resolve(eq(spec), any())) .thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of())))); - when(policyEngine.runPolicy(any(), any(), any(), any())) + when(policyEngine.runPolicy(any(), any(), any(), any(), any())) .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); runner.run(policy, SweepKind.LIGHT); - verify(policyEngine).runPolicy(eq(policy), any(), any(), any()); + verify(policyEngine).runPolicy(eq(policy), any(), any(), any(), any()); verify(processedLedger, never()).markSeen(any(), any()); verify(processedLedger, never()).deleteUnseen(any(), anyLong()); } @@ -244,12 +244,13 @@ class PolicyRunnerTest { when(folderSource.resolve(eq(broken), any())).thenThrow(new IOException("mount gone")); when(folderSource.resolve(eq(healthy), any())) .thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of())))); - when(policyEngine.runPolicy(any(), any(), any(), any())) + when(policyEngine.runPolicy(any(), any(), any(), any(), any())) .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); runner.run(policy); - verify(policyEngine).runPolicy(eq(policy), any(), any(), any()); // healthy source still ran + verify(policyEngine) + .runPolicy(eq(policy), any(), any(), any(), any()); // healthy source still ran verify(processedLedger, never()).deleteUnseen(any(), anyLong()); // history preserved } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 8610926c2a..373b596136 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -28,9 +28,11 @@ import stirling.software.proprietary.policy.store.InProcessPolicyStore; import stirling.software.proprietary.policy.store.PolicyStore; /** - * Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to - * names, its steps and trigger/output summarised, and the KPI strip counting active vs paused. - * Login is disabled so the team guards pass everything through. + * Tests for {@link PolicyOverviewService}: every Pipelines-page policy appears once with its + * sources resolved to names, its steps and trigger/output summarised, and the KPI strip counting + * active vs paused. Frontend/catalogue policies (owned by the Policies page) are excluded, while a + * pipeline that uses a folder-watch trigger stays. Login is disabled so the team guards pass + * everything through. */ class PolicyOverviewServiceTest { @@ -95,6 +97,51 @@ class PolicyOverviewServiceTest { assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList()); } + @Test + void excludesCataloguePoliciesButKeepsFolderWatchPipelines() { + Source inbox = source("Inbox", "/inbox"); + // A hand-built pipeline: shows. + policyStore.save( + new Policy( + null, + "Compress pipeline", + "owner", + true, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + // A folder-watch pipeline is still a pipeline: shows. + policyStore.save( + new Policy( + null, + "Inbox watcher", + "owner", + true, + List.of( + new PipelineInput( + inbox.id(), new TriggerConfig("folder-watch", Map.of()))), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + // A frontend/catalogue policy (categoryId in output options): hidden. + policyStore.save( + new Policy( + null, + "Classification Policy", + "system", + true, + List.of(), + List.of(new PipelineStep("/api/v1/ai/tools/classify-and-label", Map.of())), + new OutputSpec("inline", Map.of("categoryId", "classification")))); + + PoliciesOverviewResponse response = service.overview(); + + assertEquals( + List.of("Compress pipeline", "Inbox watcher"), + response.pipelines().stream().map(PolicyView::name).toList()); + // KPIs count both visible pipelines, not the hidden catalogue policy. + assertEquals(List.of(2L, 2L, 0L), response.kpis().stream().map(PolicyKpi::value).toList()); + } + @Test void anUnresolvedSourceFallsBackToItsId() { policyStore.save( diff --git a/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java b/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java new file mode 100644 index 0000000000..4b2496bf15 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java @@ -0,0 +1,108 @@ +package stirling.software.saas.config; + +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; + +/** + * Hides the migration-owned tables from Hibernate's schema management. + * + *

Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}. + * Self-hosted is untouched: there Hibernate rightly owns everything. + * + *

Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two + * writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables + * inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off + * and a fresh preview branch is missing that second half; leave it on and Hibernate is free to + * reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to + * varchar(255) and needed a migration to put back. A filter keeps the first half working and makes + * the second impossible. + * + *

Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs + * over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would + * not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class + * only applies it. + * + *

Foreign keys still cross the line, on purpose. Several inherited tables reference + * migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at + * {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code + * AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table + * only and then emit every foreign key on it, without consulting the referenced table. So excluding + * {@code users} does not cost the branch its referential integrity, and a branch ends up matching + * staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds + * because a Supabase branch applies its migrations at build time and the app connects afterwards. + * + *

Known gap: this cannot detect drift. Filtering means Hibernate never inspects these + * tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here + * compares a migration-owned table against its entity. Combined with the register being a + * hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is + * currently no automated signal when the register, the entities and the database disagree. That is + * a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift + * cannot happen; a non-fatal drift report is the missing piece and belongs outside this class. + */ +public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter { + + /** + * The one decision this class makes. Everything Hibernate might do to a table it does not own — + * create, alter, drop, truncate — is refused. + */ + @Override + public boolean includeTable(Table table) { + return !SaasSchemaOwnership.isMigrationOwned(table.getName()); + } + + /** + * Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code + * stirling_pdf}, so excluding the namespace would take both with it. + */ + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + /** + * Sequences are left alone. Every id here is an identity column rather than a mapped generator, + * so there is nothing for Hibernate to create; filtering them would be dead code pretending to + * be a safeguard. + */ + @Override + public boolean includeSequence(Sequence sequence) { + return true; + } + + @Override + public SchemaFilter getCreateFilter() { + return this; + } + + @Override + public SchemaFilter getMigrateFilter() { + return this; + } + + @Override + public SchemaFilter getDropFilter() { + return this; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return this; + } + + /** + * Validation is filtered too, which is the one debatable call here. + * + *

Letting it through would give a useful signal when a migration-owned table drifts from its + * entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have + * deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature + * that nothing maps, for instance. A boot failure over a table we have chosen not to manage is + * noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all. + */ + @Override + public SchemaFilter getValidateFilter() { + return this; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java b/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java new file mode 100644 index 0000000000..39305fcbcd --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java @@ -0,0 +1,53 @@ +package stirling.software.saas.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** Logs which Supabase project this backend is talking to, and its schema policy. */ +@Slf4j +@Component +@Profile({"dev", "staging"}) +public class SaasProjectNotice { + + private final Environment environment; + private final String projectRef; + private final String ddlAuto; + + public SaasProjectNotice( + Environment environment, + @Value("${app.supabase.project-ref:unknown}") String projectRef, + @Value("${spring.jpa.hibernate.ddl-auto:none}") String ddlAuto) { + this.environment = environment; + this.projectRef = projectRef; + this.ddlAuto = ddlAuto; + } + + @EventListener(ApplicationReadyEvent.class) + public void announceProject() { + boolean staging = environment.matchesProfiles("staging"); + if (staging) { + log.info( + """ + SaaS staging profile: Supabase project {}, ddl-auto={}. This is the SHARED \ + long-lived environment, so its data and schema are not yours alone. Testing an \ + open SaaS PR? Use that PR's preview branch instead \ + (SAAS_DEV_PROJECT_REF in app/.env.saas.local); staging will not have its \ + migrations.\ + """, + projectRef, + ddlAuto); + return; + } + log.info( + "SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so Hibernate" + + " is allowed to add the inherited tables the migrations do not create.", + projectRef, + ddlAuto); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java new file mode 100644 index 0000000000..dffd1eccf1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -0,0 +1,117 @@ +package stirling.software.saas.config; + +import java.util.Set; + +/** + * Which side owns each table in the SaaS database. + * + *

The SaaS schema has two writers and always has: the Supabase migrations in the + * Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a + * rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to + * varchar(255), which needed a dedicated migration to repair because RLS policies depended on the + * column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it + * simply did not exist on a fresh preview branch. + * + *

This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding: + * every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new + * entity fails the build until someone states who owns its table, which is the decision that was + * previously made by accident. + * + *

{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the + * migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says. + * Inherited tables stay under Hibernate, so a preview branch built from migrations alone still + * heals itself on first boot. + * + *

What this does not catch. The register is a hand-maintained copy of what lives in + * another repository, and only one direction is enforced. The test fails when a *new* entity + * appears with no owner. It cannot notice a table changing sides: write a migration for {@code + * folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate + * carries on managing a table the migrations now own — which is precisely how {@code + * team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED} + * therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind + * you. Making that structural rather than remembered is what moving the SaaS tables into their own + * schema would buy, and is the reason this class is a stepping stone rather than the answer. + */ +public final class SaasSchemaOwnership { + + /** + * Created and altered by the Supabase migrations. Hibernate must not touch these: the + * migrations carry constraints, defaults and RLS policies it knows nothing about and would + * reconcile away. + */ + public static final Set MIGRATION_OWNED = + Set.of( + "ai_create_sessions", + "audit_events", + "authorities", + "billing_subscriptions", + "job_artifact_hash", + "legal_consent", + "linked_instance", + "payg_instance_usage", + "payg_meter_event_log", + "payg_prepaid_bundle", + "payg_shadow_charge", + "payg_team_extensions", + "persistent_logins", + "pricing_policy", + "processing_job", + "processing_job_step", + "procurement_agreement_signature", + "procurement_deal", + "procurement_quote", + "saas_team_extensions", + "saas_user_extensions", + "sessions", + "team_invitations", + "team_memberships", + "teams", + "users", + "wallet_entitlement_snapshot", + "wallet_ledger", + "wallet_policy"); + + /** + * Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase + * migration exists. Deliberately left under Hibernate so a fresh branch gets them on first + * boot. + */ + public static final Set HIBERNATE_MANAGED = + Set.of( + "account_link_device_credential", + "account_link_metered_signature", + "account_link_sync_state", + "account_link_usage_counter", + "api_key_daily_usage", + "api_keys", + "file_encryption_keys", + "file_run_events", + "file_share_accesses", + "file_shares", + "folders", + "integration_configs", + "invite_tokens", + "jwt_signing_keys", + "policies", + "policy_assets", + "policy_completed_migrations", + "policy_processed_files", + "policy_source_doc_counts", + "policy_source_doc_totals", + "policy_sources", + "resource_grants", + "storage_cleanup_entries", + "stored_file_blobs", + "stored_files", + "user_license_settings", + "user_server_certificates", + "workflow_participants", + "workflow_sessions"); + + private SaasSchemaOwnership() {} + + /** Case-insensitive: Hibernate hands us whatever casing the mapping used. */ + public static boolean isMigrationOwned(String tableName) { + return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java index e2f5b65b47..0ce5e8e308 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java @@ -25,6 +25,11 @@ public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuth return teamSecurity.isCurrentUserTeamLeader(); } + @Override + public boolean canTriggerPolicies() { + return teamSecurity.isCurrentUserTeamLeader(); + } + @Override public Long currentUserTeamId() { return teamSecurity.currentUserTeamId(); diff --git a/app/saas/src/main/resources/application-dev.properties b/app/saas/src/main/resources/application-dev.properties index ee8bf80ff2..b289fc95cc 100644 --- a/app/saas/src/main/resources/application-dev.properties +++ b/app/saas/src/main/resources/application-dev.properties @@ -1,32 +1,40 @@ -# SaaS dev profile. Points at the dev Supabase project. -# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev +# SaaS dev profile: follows the Supabase preview branch of the SaaS PR under test. +# One variable switches PR, SAAS_DEV_PROJECT_REF; everything else derives from it. +# Want a stable shared environment instead? Use the staging profile. + spring.config.import=optional:classpath:application-dev-local.properties -app.supabase.project-ref=qacaivhsjtftfwtgjvva +# Let Hibernate reconcile the entity tables so a fresh preview branch heals itself. A branch is built +# from the Supabase migrations, which cover the SaaS-owned tables but not the ~28 inherited from the +# self-hosted app -- those have only ever been created by ddl-auto. Safe here because a preview branch +# is disposable and `update` only ever adds; staging pins `none`, so keep this profile-scoped. +spring.jpa.hibernate.ddl-auto=update -stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co -stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow +# From the PR's "Supabase Preview" check. Required with no fallback: ddl-auto=update above must never +# be aimed at the shared project. +app.supabase.project-ref=${SAAS_DEV_PROJECT_REF} -spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}} +stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co +# Per-branch, not derivable. Dashboard > Settings > API. +stirling.supabase.publishable-key=${SAAS_DEV_PUBLISHABLE_KEY} + +# Override the whole URL if the branch needs the pooler host rather than the direct one. +spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-dev-${user.name}} spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres} -# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=... +# A preview branch has its own password; the parent project's will not authenticate. spring.datasource.password=${SAAS_DEV_DB_PASSWORD:} -# Conservative dev pool sizing. spring.datasource.hikari.maximum-pool-size=2 spring.datasource.hikari.minimum-idle=1 spring.datasource.hikari.idle-timeout=60000 spring.datasource.hikari.max-lifetime=1800000 spring.datasource.hikari.keepalive-time=300000 -spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name} +spring.datasource.hikari.data-source-properties.ApplicationName=stirling-dev-${user.name} logging.level.stirling.software.saas=DEBUG logging.level.org.springframework.security.oauth2.jwt=WARN logging.level.org.springframework.security.oauth2.server.resource=WARN -# Supabase meter edge fn the Java backend calls (server-to-server, on job close). -# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same -# shared secret the team-invitation flow uses — no service-role key in the Java env). -# Blank secret → the meter service no-ops with a WARN, so the app still boots. -# The billing portal is NOT here — the FE calls create-customer-portal-session directly. -payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units +# Server-to-server meter call. Auth rides SUPABASE_EDGE_FUNCTION_SECRET; blank secret means the meter +# service no-ops with a WARN rather than failing the boot. +payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index f0630aaac1..e3c32c429e 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true spring.jpa.hibernate.ddl-auto=update +# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the +# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that +# no migration has ever created. This filter hides the former from schema management, so ddl-auto can +# still heal a fresh preview branch without being free to reconcile a migration-owned table — which +# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership. +spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter + # ---------- Supabase JWT auth ---------- # Required: set SAAS_DB_PROJECT_REF via env. app.supabase.project-ref=${SAAS_DB_PROJECT_REF:} diff --git a/app/saas/src/main/resources/application-staging.properties b/app/saas/src/main/resources/application-staging.properties new file mode 100644 index 0000000000..4874ed3fb5 --- /dev/null +++ b/app/saas/src/main/resources/application-staging.properties @@ -0,0 +1,39 @@ +# SaaS staging profile: the long-lived shared v3 project, pinned so it is still there tomorrow. +# For work on an open SaaS PR use the dev profile, which follows that PR's preview branch. + +spring.config.import=optional:classpath:application-staging-local.properties + +# Stated rather than inherited: application-saas.properties defaults to `update`, and staging's +# schema is shared and RLS-dependent, so it must not be reconciled by Hibernate. +spring.jpa.hibernate.ddl-auto=none + +# Committed as a default rather than a literal, so staging needs no setup but stays repointable. +# Neither the ref nor the publishable key is secret: the ref is a public subdomain, the key ships in +# the browser bundle. Everything below derives from the ref, so an override follows through. +app.supabase.project-ref=${SAAS_STAGING_PROJECT_REF:qacaivhsjtftfwtgjvva} + +stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co +stirling.supabase.publishable-key=${SAAS_STAGING_PUBLISHABLE_KEY:sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY} + +spring.datasource.url=${SAAS_STAGING_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-staging-${user.name}} +spring.datasource.username=${SAAS_STAGING_DB_USERNAME:postgres} +# Password not committed; export SAAS_STAGING_DB_PASSWORD or pass --spring.datasource.password=... +spring.datasource.password=${SAAS_STAGING_DB_PASSWORD:} + +# Conservative pool sizing: this is a shared project, so don't hold connections others need. +spring.datasource.hikari.maximum-pool-size=2 +spring.datasource.hikari.minimum-idle=1 +spring.datasource.hikari.idle-timeout=60000 +spring.datasource.hikari.max-lifetime=1800000 +spring.datasource.hikari.keepalive-time=300000 +spring.datasource.hikari.data-source-properties.ApplicationName=stirling-staging-${user.name} + +logging.level.stirling.software.saas=DEBUG +logging.level.org.springframework.security.oauth2.jwt=WARN +logging.level.org.springframework.security.oauth2.server.resource=WARN + +# Supabase meter edge fn the Java backend calls (server-to-server, on job close). +# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same +# shared secret the team-invitation flow uses — no service-role key in the Java env). +# Blank secret → the meter service no-ops with a WARN, so the app still boots. +payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units diff --git a/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java b/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java new file mode 100644 index 0000000000..956654384e --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java @@ -0,0 +1,129 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.util.Properties; + +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; + +/** + * Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring. + * + *

{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter + * applies it, or that Hibernate is even asking. A typo in the {@code + * hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move, + * or a getter returning null would all leave every migration-owned table exposed to {@code + * ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing + * here that would catch that. + */ +class MigrationOwnedSchemaFilterTest { + + private static final String FILTER_PROPERTY = + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider"; + + private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter(); + + /** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */ + private static Table table(String name) { + return new Table("orm", name); + } + + @Test + void migrationOwnedTablesAreHiddenFromHibernate() { + assertThat(filter.includeTable(table("teams"))).isFalse(); + assertThat(filter.includeTable(table("users"))).isFalse(); + assertThat(filter.includeTable(table("team_memberships"))).isFalse(); + assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse(); + } + + @Test + void inheritedTablesStayUnderHibernate() { + assertThat(filter.includeTable(table("folders"))).isTrue(); + assertThat(filter.includeTable(table("stored_files"))).isTrue(); + assertThat(filter.includeTable(table("api_keys"))).isTrue(); + } + + @Test + void anUnknownTableIsLeftToHibernate() { + // Fail-open is the right default: an unrecognised table is either brand new or from a + // module + // we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised. + assertThat(filter.includeTable(table("no_such_table"))).isTrue(); + } + + @Test + void casingDoesNotDefeatTheFilter() { + assertThat(filter.includeTable(table("TEAMS"))).isFalse(); + assertThat(filter.includeTable(table("Team_Memberships"))).isFalse(); + } + + /** + * Foreign keys from an inherited table into a migration-owned one survive the filter. + * + *

Worth pinning, because it is not obvious and it decides whether a preview branch keeps + * referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all + * reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code + * SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys}) + * tests {@code includeTable} against the *owning* table only, then emits every foreign key on + * it; the referenced table is never consulted. So the constraints are still created and a + * branch matches staging. + * + *

The one thing this depends on is ordering: the referenced tables have to exist first. They + * do, because a Supabase branch runs its migrations at build time and the app connects after. + */ + @Test + void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() { + assertThat(filter.includeTable(table("folders"))).isTrue(); + assertThat(filter.includeTable(table("file_shares"))).isTrue(); + assertThat(filter.includeTable(table("users"))).isFalse(); + assertThat(filter.includeTable(table("teams"))).isFalse(); + } + + @Test + void everySchemaActionGetsTheSameFilter() { + assertThat(filter.getCreateFilter()).isSameAs(filter); + assertThat(filter.getMigrateFilter()).isSameAs(filter); + assertThat(filter.getDropFilter()).isSameAs(filter); + assertThat(filter.getTruncatorFilter()).isSameAs(filter); + assertThat(filter.getValidateFilter()).isSameAs(filter); + } + + @Test + void namespacesAndSequencesAreNeverFiltered() { + // Both share the stirling_pdf namespace, so filtering it would take the inherited tables + // with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate + // bootstrap machinery. + assertThat(filter.includeNamespace(null)).isTrue(); + assertThat(filter.includeSequence(null)).isTrue(); + } + + @Test + void theFilterIsActuallyWiredIntoHibernate() throws Exception { + Properties properties = new Properties(); + try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) { + assertThat(in) + .as("application-saas.properties must be on the test classpath to check wiring") + .isNotNull(); + properties.load(in); + } + + String configured = properties.getProperty(FILTER_PROPERTY); + assertThat(configured) + .as( + "%s is unset, so Hibernate installs its default filter and every" + + " migration-owned table is back under ddl-auto", + FILTER_PROPERTY) + .isNotBlank(); + + Class wired = Class.forName(configured.trim()); + assertThat(SchemaFilterProvider.class) + .as("Hibernate only accepts a SchemaFilterProvider here") + .isAssignableFrom(wired); + assertThat(SchemaFilter.class).isAssignableFrom(wired); + assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java new file mode 100644 index 0000000000..e07b1042e7 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java @@ -0,0 +1,167 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.ClassUtils; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import stirling.software.proprietary.security.configuration.DatabaseConfig; + +/** + * Makes {@link SaasSchemaOwnership} binding rather than decorative. + * + *

Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase + * migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead + * of months later on a preview branch that has no such table. That is not hypothetical: {@code + * payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch + * tried to use it. + * + *

"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan} + * declarations the app actually boots with, not everything under {@code stirling.software}. See + * {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership} + * documents the drift it cannot see. + */ +class SaasSchemaOwnershipTest { + + /** + * The packages the running app actually maps, read off the two {@code @EntityScan} declarations + * that define them rather than hardcoded. + * + *

Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is + * a superset, so it would force ownership declarations for entities Hibernate never sees and + * let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving + * the list means this test measures the same set Hibernate does, and follows a package being + * added or moved without anyone updating it here. + */ + private static Set mappedPackages() { + Set packages = new TreeSet<>(); + for (Class config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) { + EntityScan scan = config.getAnnotation(EntityScan.class); + assertThat(scan) + .as("%s must carry @EntityScan, or its entities are not mapped", config) + .isNotNull(); + packages.addAll(Arrays.asList(scan.value())); + } + return packages; + } + + private static TreeMap mappedTables() { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); + TreeMap byTable = new TreeMap<>(); + for (String basePackage : mappedPackages()) { + for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) { + String className = bd.getBeanClassName(); + Class type; + try { + type = + ClassUtils.forName( + className, SaasSchemaOwnershipTest.class.getClassLoader()); + } catch (ClassNotFoundException | LinkageError e) { + continue; // not on this module's runtime classpath; nothing to own + } + Table table = type.getAnnotation(Table.class); + String name = + table != null && !table.name().isBlank() + ? table.name() + : camelToSnake(type.getSimpleName()); + byTable.put(name.toLowerCase(), className); + } + } + return byTable; + } + + /** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */ + private static String camelToSnake(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + } + + @Test + void everyEntityTableIsOwnedByExactlyOneSide() { + TreeMap mapped = mappedTables(); + assertThat(mapped) + .as("entity scan found nothing, so this test proves nothing") + .isNotEmpty(); + // The scan is derived from @EntityScan now, so a package quietly dropped from either + // declaration would shrink it and weaken this test rather than fail it. These four straddle + // the two declarations, so losing either side fails here instead of silently checking less. + assertThat(mapped.keySet()) + .as("both @EntityScan declarations must have contributed to the scan") + .contains("users", "teams", "payg_instance_usage", "folders"); + + Set undeclared = new TreeSet<>(); + Set both = new TreeSet<>(); + for (String table : mapped.keySet()) { + boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table); + boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table); + if (migration && hibernate) both.add(table); + if (!migration && !hibernate) undeclared.add(table); + } + + assertThat(undeclared) + .as( + """ + These entity tables are not declared in SaasSchemaOwnership, so nobody owns \ + them. Decide and add each to exactly one set: + - MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \ + will not exist on a fresh preview branch. + - HIBERNATE_MANAGED: only correct for a table inherited from the \ + self-hosted app that no Supabase migration creates. + Offending tables -> entities: %s""" + .formatted( + undeclared.stream() + .map(t -> t + " (" + mapped.get(t) + ")") + .toList())) + .isEmpty(); + + assertThat(both) + .as("declared as owned by both sides, which is the one thing it cannot be") + .isEmpty(); + } + + @Test + void theTwoSetsDoNotOverlap() { + Set overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED); + overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED); + assertThat(overlap).isEmpty(); + } + + @Test + void tableNamesAreLowercaseSoLookupsCannotMiss() { + // isMigrationOwned() lowercases its input; a capital in either set would be unreachable. + assertThat(SaasSchemaOwnership.MIGRATION_OWNED) + .allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase())); + assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED) + .allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase())); + } + + @Test + void migrationOwnedTablesIncludeTheOnesThatBitUs() { + // team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one + // that had an entity and no migration. Both must be on the migrations' side of the line. + assertThat(SaasSchemaOwnership.MIGRATION_OWNED) + .contains("team_memberships", "payg_instance_usage", "teams", "users"); + } + + @Test + void isMigrationOwnedIsCaseInsensitiveAndNullSafe() { + assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue(); + assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue(); + assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse(); + assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java index 70cd360d7c..2c37980a5c 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java @@ -32,6 +32,18 @@ class TeamLeaderPolicyManagementAuthorityTest { assertFalse(authority().canEditPolicies()); } + @Test + void teamLeaderMayTriggerPolicies() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true); + assertTrue(authority().canTriggerPolicies()); + } + + @Test + void nonLeaderMayNotTriggerPolicies() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false); + assertFalse(authority().canTriggerPolicies()); + } + @Test void currentUserTeamIdDelegatesToTeamSecurity() { when(teamSecurity.currentUserTeamId()).thenReturn(9L); diff --git a/build.gradle b/build.gradle index d6fa590ee4..cadf4bafa6 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ ext { openSamlVersion = "5.2.1" commonmarkVersion = "0.28.0" googleJavaFormatVersion = "1.35.0" - logback = "1.5.32" + logback = "1.6.1" commonsIoVersion = "2.22.0" commonsLang3 = "3.20.0" rhinoVersion = "1.9.1" @@ -44,9 +44,9 @@ ext { batikVersion = "1.19" jpdfiumVersion = "1.0.4" jwtVersion = "0.13.0" - awsSdkVersion = "2.44.12" - jschVersion = "0.2.23" - commonsNetVersion = "3.11.1" + awsSdkVersion = "2.51.3" + jschVersion = "2.28.6" + commonsNetVersion = "3.13.0" smbjVersion = "0.14.0" tinkVersion = "1.23.0" testcontainersMinioVersion = "1.21.4" @@ -302,8 +302,17 @@ subprojects { tasks.withType(Test).configureEach { useJUnitPlatform() + jvmArgs '--enable-native-access=ALL-UNNAMED' systemProperty 'java.awt.headless', 'true' systemProperty 'apple.awt.UIElement', 'true' + + testLogging { + events "started", "failed" + showExceptions = true + showCauses = true + showStackTraces = true + exceptionFormat "full" + } finalizedBy(jacocoReport) } diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 454fdedcfd..fd26ee2fb7 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 6cffbf88f9..95e4e84795 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -364,7 +364,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ # Final runtime image - the actual base image -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS runtime +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index de7da3789e..d4ad422476 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 300729e560..58eafec851 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -56,7 +56,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 68a65dea6a..d4a21b7812 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -57,7 +57,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li # Stage 2: Runtime image # glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked. -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ diff --git a/docs/stirling.png b/docs/stirling.png index 5edc6eae26..1b043a72ec 100644 Binary files a/docs/stirling.png and b/docs/stirling.png differ diff --git a/docs/stirling.svg b/docs/stirling.svg index 0fef4393aa..b5455291c9 100644 --- a/docs/stirling.svg +++ b/docs/stirling.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 3f2c277fbb..87e7cc99c0 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -64,7 +64,7 @@ updater-signatures = [ ] # Pinned repository-wide pre-commit tooling. pre-commit = [ - "codespell==2.4.2", + "codespell==2.4.3", "ruff==0.15.5", "tomli-w==1.2.0", ] diff --git a/engine/scripts/generate_tool_models.py b/engine/scripts/generate_tool_models.py index 7587d8c62f..c3cd9e7267 100644 --- a/engine/scripts/generate_tool_models.py +++ b/engine/scripts/generate_tool_models.py @@ -149,6 +149,9 @@ class ToolDiscovery: "/api/v1/misc/add-image", "/api/v1/misc/add-attachments", "/api/v1/general/overlay-pdfs", + # 5. Server maintenance, not a document operation: releases finished jobs and + # their stored files. Nothing an edit agent should ever call on its own. + "/api/v1/general/jobs/cleanup", ) def _is_excluded(self, path: str) -> bool: diff --git a/engine/uv.lock b/engine/uv.lock index 0b25716add..fbfa665bc6 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -414,11 +414,11 @@ wheels = [ [[package]] name = "codespell" -version = "2.4.2" +version = "2.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] [[package]] @@ -718,7 +718,7 @@ engine-dev = [ { name = "ruff", specifier = "==0.15.5" }, ] pre-commit = [ - { name = "codespell", specifier = "==2.4.2" }, + { name = "codespell", specifier = "==2.4.3" }, { name = "ruff", specifier = "==0.15.5" }, { name = "tomli-w", specifier = "==1.2.0" }, ] diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index c0436a3a82..e86a8636e3 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -10,6 +10,18 @@ import tsconfigPaths from "vite-tsconfig-paths"; * the portal layer at editor/src/portal/). MDX docs pages live in * editor/src/portal/docs/. */ +/** + * Editor stories import via `@app/*` (proprietary→core fallback), `@core/*` and + * `@proprietary/*`. Resolve them exactly the way the editor's own build does - + * through vite-tsconfig-paths against the proprietary vite tsconfig - so the + * shared Storybook can host editor components without duplicating the alias map + * here. Built per pass: the main bundle and the worker bundle each need their own. + */ +const editorPathAliases = () => + tsconfigPaths({ + projects: [resolve(__dirname, "../editor/tsconfig.proprietary.vite.json")], + }); + const config: StorybookConfig = { stories: [ "../editor/src/portal/**/*.mdx", @@ -47,19 +59,15 @@ const config: StorybookConfig = { // than a relative path. "@public": resolve(__dirname, "../editor/public"), }; - // Editor stories import via @app/* (proprietary→core fallback), @core/* and - // @proprietary/*. Resolve them exactly the way the editor's own build does — - // through vite-tsconfig-paths against the proprietary vite tsconfig — so the - // shared Storybook can host editor components without duplicating the alias - // map here. config.plugins = config.plugins ?? []; - config.plugins.push( - tsconfigPaths({ - projects: [ - resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"), - ], - }), - ); + config.plugins.push(editorPathAliases()); + // Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, so + // without this a worker importing @app/* fails to resolve while the same + // import works everywhere else. Mirrors editor/vite.config.ts. + config.worker = { + ...(config.worker ?? {}), + plugins: () => [editorPathAliases()], + }; // Point apiClient.saas at a mock origin so the SaaS-backed billing stories // (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and // their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts index 93e6572392..c4a3885b15 100644 --- a/frontend/editor/playwright.config.ts +++ b/frontend/editor/playwright.config.ts @@ -17,9 +17,12 @@ import { defineConfig, devices } from "@playwright/test"; * * @see https://playwright.dev/docs/test-configuration */ +/** Shared by every stubbed project so a spec sees one layout on all engines. */ +const STUBBED_VIEWPORT = { width: 1920, height: 1080 }; + const chromiumViewport = { ...devices["Desktop Chrome"], - viewport: { width: 1920, height: 1080 }, + viewport: STUBBED_VIEWPORT, }; export default defineConfig({ @@ -55,7 +58,8 @@ export default defineConfig({ }, projects: [ - // Stubbed - no backend required, chromium-only for CI speed + // Stubbed - no backend required. The chromium arm of the cross-browser + // set below; CI fans all three out, one job per engine. { name: "stubbed", testDir: "./src/core/tests/stubbed", @@ -93,16 +97,17 @@ export default defineConfig({ }, }, - // Cross-browser coverage for the stubbed suite (opt-in locally) + // Cross-browser coverage for the stubbed suite. Same viewport as `stubbed`, + // or a layout difference here reads as an engine outage. { name: "stubbed-firefox", testDir: "./src/core/tests/stubbed", - use: { ...devices["Desktop Firefox"] }, + use: { ...devices["Desktop Firefox"], viewport: STUBBED_VIEWPORT }, }, { name: "stubbed-webkit", testDir: "./src/core/tests/stubbed", - use: { ...devices["Desktop Safari"] }, + use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT }, }, ], diff --git a/frontend/editor/public/android-chrome-192x192.png b/frontend/editor/public/android-chrome-192x192.png index 4219bb8403..55c165df4b 100644 Binary files a/frontend/editor/public/android-chrome-192x192.png and b/frontend/editor/public/android-chrome-192x192.png differ diff --git a/frontend/editor/public/android-chrome-512x512.png b/frontend/editor/public/android-chrome-512x512.png index 19bc603ec5..1b043a72ec 100644 Binary files a/frontend/editor/public/android-chrome-512x512.png and b/frontend/editor/public/android-chrome-512x512.png differ diff --git a/frontend/editor/public/favicon.png b/frontend/editor/public/favicon.png index 5edc6eae26..1b043a72ec 100644 Binary files a/frontend/editor/public/favicon.png and b/frontend/editor/public/favicon.png differ diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index a91d52cca0..bdf6a739ed 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3895,8 +3895,6 @@ openFromComputer = "Open from computer" openSettings = "Open settings" other = "Other" recent = "Recent" -search = "Search" -searchPlaceholder = "Search files..." viewAll = "View all {{count}} files" [fileSidebar.fileItem] @@ -3925,7 +3923,6 @@ backToMyFiles = "Back to My Files" breadcrumbs = "Folder path" cancel = "Cancel" classification = "Classification" -clearSearch = "Clear search" clearSelection = "Clear selection" closeDetails = "Close details" create = "Create" @@ -3990,8 +3987,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub save = "Save" saveToServer = "Save to server" saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it." -search = "Search" -searchPlaceholder = "Search this folder & subfolders" selectAll = "Select all" selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range." selectedCount = "{{count}} selected" @@ -4051,6 +4046,10 @@ title = "No cloud files yet" hint = "Files saved without uploading stay here. Drop a file to add one." title = "No local-only files" +[filesPage.empty.noResults] +hint = "No files in this folder match your filter. Try a different term or clear the filter." +title = "No matching files" + [filesPage.empty.recent] hint = "Files you open or edit will appear here." title = "Nothing modified yet" @@ -4120,6 +4119,11 @@ localHint = "Only stored in this browser" shared = "Shared" sharedHint = "Shared with you via link" +[filesPage.search] +clear = "Clear filter" +label = "Filter files by name" +placeholder = "Filter files…" + [filesPage.sort] modifiedAsc = "Oldest first" modifiedDesc = "Recent first" @@ -5005,9 +5009,6 @@ title = "Upload from Mobile" tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide" title = "PDF Multi Tool" -[navbar] -search = "Search" - [oauth.error] message = "Authentication was not successful. You can close this window and try again." title = "Authentication Failed" @@ -7091,11 +7092,6 @@ title = "Official SDKs" beta = "Beta" deprecated = "Deprecated" -[portal.docs.search] -empty = "No matching docs" -placeholder = "Search docs" -results = "{{count}} results" - [portal.docs.skills] eyebrow = "SKILLS" lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached." @@ -8701,16 +8697,6 @@ maxed = "Maxed out" subtitle = "Your free trial runs through {{date}}. No card required." title = "Enterprise trial" -[portal.search] -ariaLabel = "Search" -placeholder = "Search Stirling — endpoints, pipelines, docs…" - -[portal.search.empty] -noActionsDescription = "Quick actions will appear here once they're available." -noActionsTitle = "No quick actions" -noMatches = "No matches for \"{{query}}\"" -noMatchesDescription = "Try a different keyword or browse the catalogue." - [portal.settings.groups] admin = "Admin" @@ -9999,9 +9985,6 @@ title = "Policies & Privacy" [settings.preferences] title = "Preferences" -[settings.search] -placeholder = "Search settings pages..." - [settings.security] description = "Update your password to keep your account secure." title = "Security" @@ -10644,6 +10627,23 @@ title = "Upload to Server" updateButton = "Update on Server" uploadButton = "Upload to Server" +[superSearch] +all = "All" +ariaLabel = "Super search" +filtersAriaLabel = "Search filters" +hint = "Type to search" +placeholder = "Search Stirling" +showLess = "Show less" +showMore = "Show {{count}} more" + +[superSearch.group] +docs = "Docs" +files = "Files" +pages = "Pages" +processor = "Processor" +settings = "Settings" +tools = "Tools" + [survey] title = "Stirling-PDF Survey" @@ -10793,7 +10793,6 @@ goBack = "Go back" pdfTools = "PDF Tools" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" -searchTools = "Search tools" toolsHeader = "Tools" viewAllTools = "View all tools" @@ -11665,6 +11664,7 @@ exitRedaction = "Exit Redaction Mode" exportAll = "Export PDF" exportSelected = "Export Selected Pages" formFill = "Fill Form" +hideToolbar = "Hide toolbar" multiTool = "Multi-Tool" panMode = "Pan Mode" print = "Print PDF" @@ -11685,6 +11685,7 @@ selectAll = "Select All" selectByNumber = "Select by Page Numbers" selectLanguage = "Select language" share = "Share" +showToolbar = "Show toolbar" toggleAnnotations = "Toggle Annotations Visibility" toggleAttachments = "Toggle Attachments" toggleBookmarks = "Toggle Bookmarks" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 5df410c368..a72d56453e 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -1894,6 +1894,9 @@ width = "Width" [app] description = "The Free Adobe Acrobat alternative (10M+ Downloads)" +[appBanner] +dismiss = "Dismiss" + [attachments] convertToPdfA3b = "Convert to PDF/A-3b" convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments" @@ -3025,6 +3028,7 @@ saturation = "Saturation and brightness" title = "Choose color" [common] +actions = "Actions" back = "Back" cancel = "Cancel" close = "Close" @@ -3040,6 +3044,7 @@ error = "Error" expand = "Expand" loading = "Loading..." next = "Next" +open = "Open" preview = "Preview" previous = "Previous" refresh = "Refresh" @@ -3905,6 +3910,8 @@ addFiles = "Add files" addingFiles = "Adding files…" collapse = "Collapse sidebar" customizeGroups = "Customize groups" +dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it." +dataLostTitle = "File data is unavailable" dropHint = "Open files to get started" dropToAdd = "Drop files to add" expand = "Expand sidebar" @@ -3919,12 +3926,12 @@ openFromComputer = "Open from computer" openSettings = "Open settings" other = "Other" recent = "Recent" -search = "Search" -searchPlaceholder = "Search files..." viewAll = "View all {{count}} files" [fileSidebar.fileItem] closeViewer = "Close viewer" +dataLost = "Data lost" +dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it." delete = "Delete" moreActions = "More actions" openInViewer = "Open in viewer" @@ -3949,7 +3956,6 @@ backToMyFiles = "Back to My Files" breadcrumbs = "Folder path" cancel = "Cancel" classification = "Classification" -clearSearch = "Clear search" clearSelection = "Clear selection" closeDetails = "Close details" create = "Create" @@ -4014,8 +4020,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub save = "Save" saveToServer = "Save to server" saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it." -search = "Search" -searchPlaceholder = "Search this folder & subfolders" selectAll = "Select all" selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range." selectedCount = "{{count}} selected" @@ -4075,6 +4079,10 @@ title = "No cloud files yet" hint = "Files saved without uploading stay here. Drop a file to add one." title = "No local-only files" +[filesPage.empty.noResults] +hint = "No files in this folder match your filter. Try a different term or clear the filter." +title = "No matching files" + [filesPage.empty.recent] hint = "Files you open or edit will appear here." title = "Nothing modified yet" @@ -4144,6 +4152,11 @@ localHint = "Only stored in this browser" shared = "Shared" sharedHint = "Shared with you via link" +[filesPage.search] +clear = "Clear filter" +label = "Filter files by name" +placeholder = "Filter files…" + [filesPage.sort] modifiedAsc = "Oldest first" modifiedDesc = "Recent first" @@ -4832,9 +4845,6 @@ title = "Image to PDF" [imageToPdf] tags = "conversion,img,jpg,picture,photo" -[infoBanner] -dismiss = "Dismiss" - [invite] acceptError = "Failed to create account" accountFor = "Creating account for" @@ -5062,8 +5072,13 @@ title = "Upload from Mobile" tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide" title = "PDF Multi Tool" -[navbar] -search = "Search" +[navFooter] +openEditor = "Open PDF Editor" +openProcessor = "Open PDF Processor" + +[navFooter.credits] +count = "{{remaining}} of {{total}}" +label = "Free credits" [oauth.error] message = "Authentication was not successful. You can close this window and try again." @@ -5629,8 +5644,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man freeTitle = "Unlimited PDF editing" [payg.free.hero] -barAria = "Free PDFs used" -capSuffix = "/ {{limit}} free PDFs" +barAria = "Free PDFs remaining" +capSuffix = "of {{limit}} free PDFs left" metaCategories = "Automation Ā· AI Ā· API requests" [payg.free.member] @@ -6349,7 +6364,6 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] -actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -6694,12 +6708,12 @@ reachedTitle = "Monthly spend limit reached" title = "Couldn't open Stripe portal" [portal.billing.walletMeter] -barAria = "Free PDFs used" -capSuffix_one = "of {{allowance}} free PDFs used" -capSuffix_other = "of {{allowance}} free PDFs used" +barAria = "Free PDFs remaining" +capSuffix_one = "of {{allowance}} free PDF left" +capSuffix_other = "of {{allowance}} free PDFs left" eyebrow = "Processor trial" -statusLabel_one = "{{remaining}} left" -statusLabel_other = "{{remaining}} left" +statusLabel_one = "{{used}} used" +statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." title_one = "Process {{allowance}} PDFs free" title_other = "Process {{allowance}} PDFs free" @@ -6944,6 +6958,32 @@ label = "Jira" description = "Email a document or a notification when a policy runs." label = "Mailgun" +[portal.connections.types.n8n] +baseUrlPlaceholder = "https://your-n8n/webhook/9f2c-..." +description = "Hand documents to an n8n workflow, and take back whatever it produces." +label = "n8n" + +[portal.connections.types.n8n.fields.authType] +helperText = "Match the Authentication set on the n8n Webhook node." +label = "Webhook authentication" + +[portal.connections.types.n8n.fields.authType.options.basic] +label = "Basic auth" + +[portal.connections.types.n8n.fields.authType.options.header] +label = "Header auth" + +[portal.connections.types.n8n.fields.authType.options.none] +label = "None" + +[portal.connections.types.n8n.fields.headerName] +helperText = "The header name from the n8n credential, not its value." +label = "Header name" +placeholder = "X-N8N-Key" + +[portal.connections.types.n8n.fields.token] +label = "Header value" + [portal.connections.types.nextcloud] baseUrlPlaceholder = "https://your-server/remote.php/dav" description = "File processed documents into Nextcloud." @@ -7148,11 +7188,6 @@ title = "Official SDKs" beta = "Beta" deprecated = "Deprecated" -[portal.docs.search] -empty = "No matching docs" -placeholder = "Search docs" -results = "{{count}} results" - [portal.docs.skills] eyebrow = "SKILLS" lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached." @@ -7251,12 +7286,11 @@ editorAction = "Editor" empty = "No documents match this filter." rowActions = "Row actions" sensitiveLabel = "Sensitive" -sensitiveTitle = "Sensitive — access required" [portal.documents.table.columns] action = "Pipeline / Action" -actions = "Actions" document = "Document" +labels = "Labels" product = "Product" status = "Status" time = "Time" @@ -7272,6 +7306,7 @@ host = "Host" lastSeen = "Last seen" region = "Region" status = "Status" +target = "Target" version = "Version" [portal.editorAdmin.health.empty] @@ -7347,9 +7382,11 @@ retry = "Try again" title = "Something went wrong on this page" [portal.failures] +fromSource = "From source {{source}}" occurrences = "{{count}} occurrences" +reportedBy = "Hit by {{actor}}" runReference = "Run {{runId}}" -subtitle = "Failures recorded from your policy runs, with the actions you can take." +subtitle = "Failures recorded from your policy runs and your team's editors, with the actions you can take." title = "Failures" [portal.failures.action] @@ -7358,6 +7395,14 @@ confirm = "Are you sure?" dismiss = "Dismiss" dismissSkipFile = "Skip this file" +[portal.failures.debug] +copyJson = "Copy JSON" +dismissAll = "Dismiss all ({{total}})" +dismissing = "Dismissing..." +hideJson = "Hide raw JSON ({{total}})" +refresh = "Refresh failures" +showJson = "Show raw JSON ({{total}})" + [portal.failures.disabled] closed = "This failure is already closed." unavailable = "Not available for this failure." @@ -7374,6 +7419,11 @@ title = "Password-protected document" description = "This run failed for a reason Stirling does not yet recognise. The raw message is shown below." title = "Unrecognised failure" +[portal.failures.origin] +pipeline = "Pipeline" +policy = "Policy" +tool = "Tool run" + [portal.failures.stage] blocked = "Blocked" input = "Input" @@ -7459,7 +7509,7 @@ morning = "Good morning" [portal.infrastructure] manageEditorDeployment = "Manage Editor deployment" sectionsAriaLabel = "Infrastructure sections" -subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace." +subtitle = "API credentials and the audit trail for your Stirling workspace." title = "Infrastructure" [portal.infrastructure.apiKeys] @@ -7487,11 +7537,6 @@ cancel = "Cancel" confirm = "Revoke key" title = "Revoke API key" -[portal.infrastructure.attestationLabel] -attested = "Attested" -inScope = "In scope" -notApplicable = "N/A" - [portal.infrastructure.audit] filterAriaLabel = "Filter audit events by category" heading = "Audit logs" @@ -7563,11 +7608,6 @@ info = "Info" success = "Success" warning = "Warning" -[portal.infrastructure.certLabel] -certified = "Certified" -inProgress = "In progress" -notStarted = "Not started" - [portal.infrastructure.createKey] cancel = "Cancel" createKey = "Create key" @@ -7581,240 +7621,10 @@ subtitleCreated = "Copy this secret now — it won't be shown again." title = "Create API key" titleCreated = "Key created" -[portal.infrastructure.deployLabel] -live = "Live" -queued = "Queued" -rolledBack = "Rolled back" -rolling = "Rolling out" - -[portal.infrastructure.deployments] -loadAria = "Load for {{name}}" -msValue = "{{value}} ms" -throughputValue = "{{value}}/min" - -[portal.infrastructure.deployments.deployColumns] -deployedBy = "Deployed by" -environment = "Environment" -product = "Product" -status = "Status" -version = "Version" -when = "When" - -[portal.infrastructure.deployments.recent] -heading = "Recent deployments" -subheading = "The latest rollouts across products and environments." - -[portal.infrastructure.deployments.regionColumns] -instances = "Instances" -latency = "Latency" -load = "Load" -p99 = "P99" -region = "Region" -status = "Status" -throughput = "Throughput" -uptime = "Uptime" -version = "Version" - -[portal.infrastructure.deployments.regions] -heading = "Regions" -subheading = "Live health for every deployed Stirling region — latency, load, and rollout version." - -[portal.infrastructure.deployments.regions.empty] -description = "Deployed regions appear here once your workspace is provisioned." -title = "No regions deployed" - [portal.infrastructure.keyLabel] active = "Active" revoked = "Revoked" -[portal.infrastructure.modelLabel] -active = "Active" -degraded = "Degraded" -disabled = "Disabled" - -[portal.infrastructure.models] -heading = "Models" -loadAria = "Load for {{name}}" -msValue = "{{value}} ms" -subheading = "The model catalogue and routing that powers document processing across your workspace." - -[portal.infrastructure.models.byom] -description = "Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing." -title = "Bring your own model" - -[portal.infrastructure.models.catalogue] -heading = "Catalogue" -sub = "Managed models available to your workspace, with live latency and cost." -subEnterprise = "Managed, bring-your-own, and on-prem models — with per-region pinning available." - -[portal.infrastructure.models.catalogue.empty] -description = "Models in your workspace's catalogue appear here." -title = "No models available" - -[portal.infrastructure.models.columns] -cost = "Cost" -latency = "Latency" -load = "Load" -model = "Model" -status = "Status" -type = "Type" -version = "Version" - -[portal.infrastructure.models.cost] -perCall = "{{price}}/call" -perThousand = "{{price}}/1k" - -[portal.infrastructure.models.metrics] -activeModels = "Active models" -avgLatency = "Avg latency" -included = "Included" -monthlySpend = "Monthly model spend" - -[portal.infrastructure.models.routing] -empty = "No routing rules configured." -heading = "Routing rules" -sub = "Which model handles each operation. The default applies when no narrower rule matches." -subLocked = "Route operations to specific models — available on paid plans." - -[portal.infrastructure.models.routing.lockedBanner] -description = "Upgrade to Pro to control which model handles each operation and document type." -title = "Model routing is a paid feature" - -[portal.infrastructure.models.routingColumns] -default = "Default" -docType = "Document type" -modelForAria = "Model for {{operation}}" -operation = "Operation" -routedTo = "Routed to" - -[portal.infrastructure.modelTypeLabel] -classification = "Classification" -extraction = "Extraction" -llm = "LLM" -ocr = "OCR" - -[portal.infrastructure.regionLabel] -degraded = "Degraded" -down = "Down" -healthy = "Healthy" - -[portal.infrastructure.security.access.byok] -description = "Supply a key from your own KMS. Stirling encrypts with it but can still read." -label = "Bring your own key (BYOK)" - -[portal.infrastructure.security.access.hyok] -description = "Keys never leave your KMS. Stirling holds only ciphertext." -label = "Hold your own key (HYOK)" - -[portal.infrastructure.security.access.stirling] -description = "Stirling manages encryption keys. Simplest — zero key ops on your side." -label = "Stirling-held keys" - -[portal.infrastructure.security.accessPolicy] -heading = "Document access policy" -subheading = "Controls who can decrypt processed documents at rest." - -[portal.infrastructure.security.attestations] -heading = "Compliance attestations" -noReport = "No report available" -subheading = "Framework-by-framework audit posture, with reports available on attested controls." -viewReport = "View report →" - -[portal.infrastructure.security.compliance] -heading = "Compliance" -subheading = "Attestations and certifications covering the Stirling platform." - -[portal.infrastructure.security.empty] -description = "Your workspace's security configuration will appear here." -title = "Security posture unavailable" - -[portal.infrastructure.security.hyokBanner] -description = "With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time." -title = "Stirling cannot decrypt your documents" - -[portal.infrastructure.security.ipAllowlist] -empty = "No IP ranges configured — all IPs allowed." -heading = "IP allowlist" -sub = "API access is restricted to these CIDR ranges." -subLocked = "Restrict API access to known IP ranges — available on paid plans." - -[portal.infrastructure.security.ipAllowlist.lockedBanner] -description = "Upgrade to Pro to restrict API access to specific networks." -title = "IP allowlisting is a paid feature" - -[portal.infrastructure.security.ipColumns] -added = "Added" -addedBy = "Added by" -cidr = "CIDR" -label = "Label" - -[portal.infrastructure.security.keyManagement] -algorithm = "Algorithm" -heading = "Encryption key management" -keyId = "Key identifier" -lastRotated = "Last rotated" -rotateKey = "Rotate key" -rotationPolicy = "Rotation policy" -subheading = "Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate." - -[portal.infrastructure.security.managedBanner] -description = "Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS." -title = "Keys are managed by Stirling on your plan" - -[portal.infrastructure.security.residency.apac] -description = "ap-southeast-1" -label = "Asia Pacific" - -[portal.infrastructure.security.residency.eu] -description = "eu-west-1 Ā· GDPR data boundary" -label = "European Union" - -[portal.infrastructure.security.residency.us] -description = "us-east-1 Ā· us-west-2" -label = "United States" - -[portal.infrastructure.security.residencyHeader] -heading = "Data residency" -subheading = "Where documents are stored and processed." - -[portal.infrastructure.storage] -gbValue = "{{value}} GB" -percentUsed = "{{value}} used" - -[portal.infrastructure.storage.empty] -description = "Connected storage and usage appear here." -title = "No storage configured" - -[portal.infrastructure.storage.lifecycle] -active = "Active" -activeRange = "0–{{value}}d" -archived = "Archived" -coldStorage = "cold storage" -deleted = "Deleted" -never = "never" -purged = "purged" - -[portal.infrastructure.storage.providers] -connect = "Connect" -connected = "Connected" -heading = "Connected providers" -subheading = "Where processed artifacts are written." - -[portal.infrastructure.storage.retention] -heading = "Retention" -subheading = "How long artifacts are kept before lifecycle deletion." -windowLabel = "Default retention window" - -[portal.infrastructure.storage.retentionOption] -days_one = "{{count}} day" -days_other = "{{count}} days" -never = "Never delete" - -[portal.infrastructure.storage.totalUsage] -heading = "Total usage" -progressLabel = "Storage used" -subheading = "Storage consumed across all connected providers." - [portal.infrastructure.tabs] apiKeys = "API Keys" audit = "Audit Logs" @@ -7824,11 +7634,9 @@ security = "Security" storage = "Storage" [portal.integrations] -addAnother = "Add another" availableHeading = "Available" comingSoonHeading = "Coming soon" connect = "Connect" -connectedHeading = "Connected" connectionCount_one = "{{count}} connection" connectionCount_other = "{{count}} connections" customApi = "Custom API" @@ -7846,6 +7654,10 @@ security = "Security" signing = "Signing" storage = "Storage" +[portal.integrations.noResults] +description = "No integrations match your filters. Try a different category or search." +title = "No matches" + [portal.integrations.status] connected = "Connected" @@ -7876,7 +7688,6 @@ integrations = "Integrations" pipelines = "Pipelines" policies = "Policies" procurement = "Procurement" -settings = "Settings" sources = "Sources" usage = "Usage & Billing" users = "Users" @@ -7893,6 +7704,7 @@ title = "Pipelines" newPipeline = "New pipeline" [portal.pipelines.builder] +activate = "Activate" back = "Back to pipelines" cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" @@ -7900,7 +7712,6 @@ chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" chooseSource = "Choose a source" discard = "Discard changes" -enabled = "Enabled" inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" @@ -7911,6 +7722,8 @@ needsDestination = "No destination chosen" needsSource = "No source chosen" needsUpload = "Needs an uploaded file" noToolMatches = "No tools match your search." +pause = "Pause" +rename = "Rename pipeline" searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." @@ -7923,6 +7736,17 @@ uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these usesDefaults = "Runs with default settings" viewDefinition = "View definition" +[portal.pipelines.builder.blocker] +destination = "Choose a destination" +heading = "To create this pipeline:" +incompatible = "Fix steps that can't run in order: {{tools}}" +name = "Give the pipeline a name" +saveHeading = "To save your changes:" +schedule = "Set how often it runs" +setup = "Finish setting up: {{tools}}" +source = "Choose an input source" +upload = "Remove steps that need an uploaded file: {{tools}}" + [portal.pipelines.builder.diagnostic] fan-in = "Combines every incoming file" fan-out = "Runs once per incoming file" @@ -7933,12 +7757,12 @@ undeclared-operation = "Can't check what this step accepts" [portal.pipelines.composer] addTool = "Add a tool" -cancel = "Cancel" create = "Create pipeline" +createPaused = "Create paused" editingUnsupported = "Displaying these tool params for editing is not supported yet." editSource = "Edit source" name = "Name" -namePlaceholder = "e.g. Redaction sweep" +namePlaceholder = "Pipeline name" noToolSettings = "This tool has no configurable settings." output = "Destination" save = "Save changes" @@ -7970,7 +7794,7 @@ confirm = "Delete" title = "Delete pipeline?" [portal.pipelines.detail] -clearHistory = "Clear history" +clearHistory = "Process ignored files in source" delete = "Delete pipeline" run = "Run now" @@ -8023,10 +7847,9 @@ completed_one = "Run completed." completed_other = "All {{count}} runs completed." empty = "Nothing to run: the sources had no documents to process." failed = "Run failed: {{error}}" -historyCleared = "History cleared. The next run reprocesses everything currently in the sources." inFlight = "Nothing new to run: documents are still being processed from an earlier run." -parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then clear history to retry it." -parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then clear history to retry them." +parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then reprocess the source to retry it." +parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then reprocess the source to retry them." running = "Run started; still in progress." timeout = "Run is taking longer than expected; it may still finish in the background." @@ -8036,10 +7859,10 @@ paused = "Paused" [portal.pipelines.table] name = "Pipeline" -open = "Open" sources = "Sources" status = "Status" steps = "Steps" +trigger = "Trigger" [portal.pipelines.trigger] folder-watch = "Folder watch" @@ -8354,6 +8177,24 @@ label = "Attach to a Jira issue" description = "Sends the processed document as an attachment." label = "Email the document (Mailgun)" +[portal.policies.operations.n8nGate] +description = "Your workflow decides whether the run carries on." +label = "Ask an n8n workflow to approve" +note = "The workflow must reply with {\"approved\": true}. Anything else — including a timeout — stops the run and parks the document." + +[portal.policies.operations.n8nNotify] +description = "Sends the run's details, not the document." +label = "Tell an n8n workflow" + +[portal.policies.operations.n8nSend] +description = "Sends the document and the run's details to your workflow." +label = "Send the document to n8n" + +[portal.policies.operations.n8nTransform] +description = "Your workflow processes the document and the file it returns replaces it." +label = "Process the document in n8n" +note = "Set the Webhook node to respond \"Using Respond to Webhook Node\", and return a file. On the default \"Immediately\" setting n8n replies with an acknowledgement, and that would replace the document." + [portal.policies.operations.nextcloudUpload] description = "Writes the processed document into a folder." label = "Upload to Nextcloud" @@ -8828,16 +8669,6 @@ maxed = "Maxed out" subtitle = "Your free trial runs through {{date}}. No card required." title = "Enterprise trial" -[portal.search] -ariaLabel = "Search" -placeholder = "Search Stirling — endpoints, pipelines, docs…" - -[portal.search.empty] -noActionsDescription = "Quick actions will appear here once they're available." -noActionsTitle = "No quick actions" -noMatches = "No matches for \"{{query}}\"" -noMatchesDescription = "Try a different keyword or browse the catalogue." - [portal.settings.groups] admin = "Admin" @@ -8888,10 +8719,6 @@ cancel = "Cancel" confirm = "Delete" title = "Delete source?" -[portal.sources.empty] -description = "Connect a storage location so your policies have somewhere to pull data from." -title = "No sources connected yet" - [portal.sources.kpi] inUse = "In use" total = "Connections" @@ -8928,9 +8755,9 @@ unused = "Unused" [portal.sources.table] documents = "Documents" -open = "Open" source = "Source" status = "Status" +type = "Type" usedBy = "Policies" [portal.sources.types.box] @@ -10126,9 +9953,6 @@ title = "Policies & Privacy" [settings.preferences] title = "Preferences" -[settings.search] -placeholder = "Search settings pages..." - [settings.security] description = "Update your password to keep your account secure." title = "Security" @@ -10784,6 +10608,23 @@ title = "Upload to Server" updateButton = "Update on Server" uploadButton = "Upload to Server" +[superSearch] +all = "All" +ariaLabel = "Super search" +filtersAriaLabel = "Search filters" +hint = "Type to search" +placeholder = "Search Stirling" +showLess = "Show less" +showMore = "Show {{count}} more" + +[superSearch.group] +docs = "Docs" +files = "Files" +pages = "Pages" +processor = "Processor" +settings = "Settings" +tools = "Tools" + [survey] title = "Stirling-PDF Survey" @@ -10933,7 +10774,6 @@ goBack = "Go back" pdfTools = "PDF Tools" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" -searchTools = "Search tools" toolsHeader = "Tools" viewAllTools = "View all tools" @@ -11141,6 +10981,13 @@ approver = "Approves policy" editor = "Editor" processor = "Processor" +[users.columns] +capabilities = "Capabilities" +email = "Email" +person = "Person" +role = "Role" +status = "Status" + [users.confirm] cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link." cancelInviteTitle = "Cancel invitation" @@ -11160,10 +11007,8 @@ title = "No members yet" addToTeam = "Add to team" guestCount = "{{count}} guest" guests = "Guests" -guestsDesc = "External collaborators, scoped to what you shared. Editor only." ledBy = "led by {{owner}}" org = "Organization" -orgDesc = "Owners with org-wide authority and policy approval" owners = "{{count}} owner" team = "{{name}} team" teamMeta = "{{count}} people" @@ -11203,13 +11048,15 @@ usernamePlaceholder = "jsmith" [users.invites] by = "Invited by {{who}}" cancel = "Cancel" -count = "{{count}} pending" -desc = "Invited people who haven't joined yet. They hold a seat until they accept." expiresInDays_one = "Expires in {{count}} day" expiresInDays_other = "Expires in {{count}} days" expiresToday = "Expires today" title = "Pending invitations" +[users.invites.columns] +expires = "Expires" +invitee = "Invitee" + [users.loadError] description = "Something went wrong reaching the backend, or you don't have access. Try again." title = "Couldn't load members" @@ -11814,6 +11661,7 @@ exitRedaction = "Exit Redaction Mode" exportAll = "Export PDF" exportSelected = "Export Selected Pages" formFill = "Fill Form" +hideToolbar = "Hide toolbar" multiTool = "Multi-Tool" panMode = "Pan Mode" print = "Print PDF" @@ -11834,6 +11682,7 @@ selectAll = "Select All" selectByNumber = "Select by Page Numbers" selectLanguage = "Select language" share = "Share" +showToolbar = "Show toolbar" toggleAnnotations = "Toggle Annotations Visibility" toggleAttachments = "Toggle Attachments" toggleBookmarks = "Toggle Bookmarks" diff --git a/frontend/editor/public/mstile-144x144.png b/frontend/editor/public/mstile-144x144.png index ff28cf1ac6..3c47163f60 100644 Binary files a/frontend/editor/public/mstile-144x144.png and b/frontend/editor/public/mstile-144x144.png differ diff --git a/frontend/editor/public/mstile-150x150.png b/frontend/editor/public/mstile-150x150.png index c900c83ae3..1833c9340f 100644 Binary files a/frontend/editor/public/mstile-150x150.png and b/frontend/editor/public/mstile-150x150.png differ diff --git a/frontend/editor/public/mstile-310x150.png b/frontend/editor/public/mstile-310x150.png index 43a095f36d..e5ad21a345 100644 Binary files a/frontend/editor/public/mstile-310x150.png and b/frontend/editor/public/mstile-310x150.png differ diff --git a/frontend/editor/public/mstile-310x310.png b/frontend/editor/public/mstile-310x310.png index fd52bd61d2..044a032aaf 100644 Binary files a/frontend/editor/public/mstile-310x310.png and b/frontend/editor/public/mstile-310x310.png differ diff --git a/frontend/editor/public/mstile-70x70.png b/frontend/editor/public/mstile-70x70.png index 7692923f74..23f118d9f1 100644 Binary files a/frontend/editor/public/mstile-70x70.png and b/frontend/editor/public/mstile-70x70.png differ diff --git a/frontend/editor/public/og_images/saas/app-editor.png b/frontend/editor/public/og_images/saas/app-editor.png index e89c1d060a..2c7fb2b653 100644 Binary files a/frontend/editor/public/og_images/saas/app-editor.png and b/frontend/editor/public/og_images/saas/app-editor.png differ diff --git a/frontend/editor/public/og_images/saas/app-processor.png b/frontend/editor/public/og_images/saas/app-processor.png index fd1ebfc620..2cd3cecbcd 100644 Binary files a/frontend/editor/public/og_images/saas/app-processor.png and b/frontend/editor/public/og_images/saas/app-processor.png differ diff --git a/frontend/editor/public/og_images/saas/app.png b/frontend/editor/public/og_images/saas/app.png index 9c6b300e6e..81e69b8f4c 100644 Binary files a/frontend/editor/public/og_images/saas/app.png and b/frontend/editor/public/og_images/saas/app.png differ diff --git a/frontend/editor/public/og_images/shared-sign.png b/frontend/editor/public/og_images/shared-sign.png index 9a2750d2b3..85c1558494 100644 Binary files a/frontend/editor/public/og_images/shared-sign.png and b/frontend/editor/public/og_images/shared-sign.png differ diff --git a/frontend/editor/public/safari-pinned-tab.svg b/frontend/editor/public/safari-pinned-tab.svg index f0a689a75d..9a7f46f2f6 100644 --- a/frontend/editor/public/safari-pinned-tab.svg +++ b/frontend/editor/public/safari-pinned-tab.svg @@ -1 +1,4 @@ -Created by potrace 1.14, written by Peter Selinger 2001-2017 \ No newline at end of file + + + + diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 494fc03cca..97b32ef208 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [ /mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/, /\/onboarding\//, /addStamp|addWatermark|\/tooltips\//, - /UpgradeBanner|AdminPlanSection/, // Stories are checked like app code; colour-as-data lines opt out with // `theme-allow-color`. /\.test\.[jt]sx?$|\/types\//, diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index c7d25757c9..fe8352f5a7 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -276,9 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" @@ -4551,7 +4551,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stirling-pdf" version = "0.1.0" dependencies = [ - "base64 0.23.0", + "base64 0.23.1", "core-foundation 0.10.1", "core-services", "keyring", diff --git a/frontend/editor/src-tauri/icons/128x128.png b/frontend/editor/src-tauri/icons/128x128.png index d233685dd2..9712693d06 100644 Binary files a/frontend/editor/src-tauri/icons/128x128.png and b/frontend/editor/src-tauri/icons/128x128.png differ diff --git a/frontend/editor/src-tauri/icons/128x128@2x.png b/frontend/editor/src-tauri/icons/128x128@2x.png index 9473eda3f1..e4f69e37ff 100644 Binary files a/frontend/editor/src-tauri/icons/128x128@2x.png and b/frontend/editor/src-tauri/icons/128x128@2x.png differ diff --git a/frontend/editor/src-tauri/icons/16x16.png b/frontend/editor/src-tauri/icons/16x16.png index d88e6615d0..6448ce5424 100644 Binary files a/frontend/editor/src-tauri/icons/16x16.png and b/frontend/editor/src-tauri/icons/16x16.png differ diff --git a/frontend/editor/src-tauri/icons/192x192.png b/frontend/editor/src-tauri/icons/192x192.png index 4219bb8403..55c165df4b 100644 Binary files a/frontend/editor/src-tauri/icons/192x192.png and b/frontend/editor/src-tauri/icons/192x192.png differ diff --git a/frontend/editor/src-tauri/icons/32x32.png b/frontend/editor/src-tauri/icons/32x32.png index 9e8dd8a5db..1844dfceb2 100644 Binary files a/frontend/editor/src-tauri/icons/32x32.png and b/frontend/editor/src-tauri/icons/32x32.png differ diff --git a/frontend/editor/src-tauri/icons/64x64.png b/frontend/editor/src-tauri/icons/64x64.png index 280a9c5ac3..d758a26bf3 100644 Binary files a/frontend/editor/src-tauri/icons/64x64.png and b/frontend/editor/src-tauri/icons/64x64.png differ diff --git a/frontend/editor/src-tauri/icons/Square107x107Logo.png b/frontend/editor/src-tauri/icons/Square107x107Logo.png index 840e43164a..f2ef1260e5 100644 Binary files a/frontend/editor/src-tauri/icons/Square107x107Logo.png and b/frontend/editor/src-tauri/icons/Square107x107Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square142x142Logo.png b/frontend/editor/src-tauri/icons/Square142x142Logo.png index 3a74337ff0..27e766be49 100644 Binary files a/frontend/editor/src-tauri/icons/Square142x142Logo.png and b/frontend/editor/src-tauri/icons/Square142x142Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square150x150Logo.png b/frontend/editor/src-tauri/icons/Square150x150Logo.png index 7efa08a3ae..b72682f1b7 100644 Binary files a/frontend/editor/src-tauri/icons/Square150x150Logo.png and b/frontend/editor/src-tauri/icons/Square150x150Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square284x284Logo.png b/frontend/editor/src-tauri/icons/Square284x284Logo.png index 3138c510c9..5174444203 100644 Binary files a/frontend/editor/src-tauri/icons/Square284x284Logo.png and b/frontend/editor/src-tauri/icons/Square284x284Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square30x30Logo.png b/frontend/editor/src-tauri/icons/Square30x30Logo.png index dc2fe4cfc7..72f49af68e 100644 Binary files a/frontend/editor/src-tauri/icons/Square30x30Logo.png and b/frontend/editor/src-tauri/icons/Square30x30Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square310x310Logo.png b/frontend/editor/src-tauri/icons/Square310x310Logo.png index a38038f8cc..58f31c4a9c 100644 Binary files a/frontend/editor/src-tauri/icons/Square310x310Logo.png and b/frontend/editor/src-tauri/icons/Square310x310Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square44x44Logo.png b/frontend/editor/src-tauri/icons/Square44x44Logo.png index 097839b004..a7e08945ed 100644 Binary files a/frontend/editor/src-tauri/icons/Square44x44Logo.png and b/frontend/editor/src-tauri/icons/Square44x44Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square71x71Logo.png b/frontend/editor/src-tauri/icons/Square71x71Logo.png index fb066fb32a..1d64d7ad39 100644 Binary files a/frontend/editor/src-tauri/icons/Square71x71Logo.png and b/frontend/editor/src-tauri/icons/Square71x71Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square89x89Logo.png b/frontend/editor/src-tauri/icons/Square89x89Logo.png index 00d893d7cf..7017222db0 100644 Binary files a/frontend/editor/src-tauri/icons/Square89x89Logo.png and b/frontend/editor/src-tauri/icons/Square89x89Logo.png differ diff --git a/frontend/editor/src-tauri/icons/StoreLogo.png b/frontend/editor/src-tauri/icons/StoreLogo.png index c56df3f8ad..f9a89b4c08 100644 Binary files a/frontend/editor/src-tauri/icons/StoreLogo.png and b/frontend/editor/src-tauri/icons/StoreLogo.png differ diff --git a/frontend/editor/src-tauri/icons/android-chrome-192x192.png b/frontend/editor/src-tauri/icons/android-chrome-192x192.png index 4219bb8403..55c165df4b 100644 Binary files a/frontend/editor/src-tauri/icons/android-chrome-192x192.png and b/frontend/editor/src-tauri/icons/android-chrome-192x192.png differ diff --git a/frontend/editor/src-tauri/icons/android-chrome-512x512.png b/frontend/editor/src-tauri/icons/android-chrome-512x512.png index 19bc603ec5..1b043a72ec 100644 Binary files a/frontend/editor/src-tauri/icons/android-chrome-512x512.png and b/frontend/editor/src-tauri/icons/android-chrome-512x512.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 6a361221e1..bb04f26bb7 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index dd48655a9b..64d33096ba 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 6a361221e1..be50b5487a 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index a82e68b38f..e0d4b496ad 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index d563b2d258..be11e05b10 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index a82e68b38f..fba443fccd 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 6c28ce5994..fb4a2583fe 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 9b7807975c..251b991a03 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 6c28ce5994..06aa109793 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 95a8327d0c..3459a810d4 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 6b0bf8f45e..e6c0d49b64 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 95a8327d0c..3459a810d4 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index e6603a9268..02f73102aa 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 503572a657..e51cd37946 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index e6603a9268..02f73102aa 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/editor/src-tauri/icons/icon.icns b/frontend/editor/src-tauri/icons/icon.icns index 983df8577c..86bad6a2e1 100644 Binary files a/frontend/editor/src-tauri/icons/icon.icns and b/frontend/editor/src-tauri/icons/icon.icns differ diff --git a/frontend/editor/src-tauri/icons/icon.ico b/frontend/editor/src-tauri/icons/icon.ico index b058a5591f..2351219afa 100644 Binary files a/frontend/editor/src-tauri/icons/icon.ico and b/frontend/editor/src-tauri/icons/icon.ico differ diff --git a/frontend/editor/src-tauri/icons/icon.png b/frontend/editor/src-tauri/icons/icon.png index 5819d1b89d..8abdd8ca3a 100644 Binary files a/frontend/editor/src-tauri/icons/icon.png and b/frontend/editor/src-tauri/icons/icon.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@1x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@1x.png index b440dda9d9..14945dd067 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 44ec1c6bc9..a42c0f75f0 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x.png index 44ec1c6bc9..5045b9a17f 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@3x.png index e388901c86..2417dadd8a 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@1x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@1x.png index df4c10e2f4..e12aa0d5bd 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 8a78c7b879..97cabce4ec 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x.png index 8a78c7b879..7942561423 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@3x.png index da7b0097be..58c87d5ea7 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@1x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@1x.png index 44ec1c6bc9..882b9b5d2f 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 70f8711ff4..ac1f2800cd 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x.png index 70f8711ff4..cb2137c6b1 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png index 1648f9cc90..ffdac97f01 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png index e75780f7ff..b7eb3b3fad 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png index 1648f9cc90..d1bbbcdd09 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png index 53ff5b05dc..80aa0bbbc4 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@1x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@1x.png index 9d97d05bb3..5e18a2da14 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png index 9df1bcea80..618fb3b29d 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 6403edcfee..e796d944b5 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/frontend/editor/src-tauri/icons/mstile-144x144.png b/frontend/editor/src-tauri/icons/mstile-144x144.png index ff28cf1ac6..3c47163f60 100644 Binary files a/frontend/editor/src-tauri/icons/mstile-144x144.png and b/frontend/editor/src-tauri/icons/mstile-144x144.png differ diff --git a/frontend/editor/src-tauri/icons/mstile-150x150.png b/frontend/editor/src-tauri/icons/mstile-150x150.png index c900c83ae3..4fe5606ad8 100644 Binary files a/frontend/editor/src-tauri/icons/mstile-150x150.png and b/frontend/editor/src-tauri/icons/mstile-150x150.png differ diff --git a/frontend/editor/src-tauri/icons/mstile-310x150.png b/frontend/editor/src-tauri/icons/mstile-310x150.png index 43a095f36d..9ef3b02fe7 100644 Binary files a/frontend/editor/src-tauri/icons/mstile-310x150.png and b/frontend/editor/src-tauri/icons/mstile-310x150.png differ diff --git a/frontend/editor/src-tauri/icons/mstile-310x310.png b/frontend/editor/src-tauri/icons/mstile-310x310.png index fd52bd61d2..044a032aaf 100644 Binary files a/frontend/editor/src-tauri/icons/mstile-310x310.png and b/frontend/editor/src-tauri/icons/mstile-310x310.png differ diff --git a/frontend/editor/src-tauri/icons/mstile-70x70.png b/frontend/editor/src-tauri/icons/mstile-70x70.png index 7692923f74..23f118d9f1 100644 Binary files a/frontend/editor/src-tauri/icons/mstile-70x70.png and b/frontend/editor/src-tauri/icons/mstile-70x70.png differ diff --git a/frontend/editor/src-tauri/windows/wix/banner.bmp b/frontend/editor/src-tauri/windows/wix/banner.bmp index 8c120d1fe2..d6ee476bcc 100644 Binary files a/frontend/editor/src-tauri/windows/wix/banner.bmp and b/frontend/editor/src-tauri/windows/wix/banner.bmp differ diff --git a/frontend/editor/src-tauri/windows/wix/dialog.bmp b/frontend/editor/src-tauri/windows/wix/dialog.bmp index 680241e9e7..49725f82db 100644 Binary files a/frontend/editor/src-tauri/windows/wix/dialog.bmp and b/frontend/editor/src-tauri/windows/wix/dialog.bmp differ diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index 110a6c88a5..e17cef8f8b 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -10,7 +10,7 @@ { "moduleName": "@cantoo/pdf-lib", "moduleUrl": "https://github.com/cantoo-scribe/pdf-lib", - "moduleVersion": "2.6.5", + "moduleVersion": "2.8.2", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -255,14 +255,14 @@ { "moduleName": "@stripe/react-stripe-js", "moduleUrl": "https://github.com/stripe/react-stripe-js", - "moduleVersion": "4.0.2", + "moduleVersion": "6.8.0", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "@stripe/stripe-js", "moduleUrl": "https://github.com/stripe/stripe-js", - "moduleVersion": "7.9.0", + "moduleVersion": "9.10.0", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -287,6 +287,13 @@ "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, + { + "moduleName": "@tanstack/react-table", + "moduleUrl": "https://github.com/TanStack/table", + "moduleVersion": "9.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, { "moduleName": "@tanstack/react-virtual", "moduleUrl": "https://github.com/TanStack/virtual", diff --git a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx index 3b373e9c3b..638c752f9e 100644 --- a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx +++ b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx @@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; /** @@ -105,7 +105,7 @@ export function TeamInvitationBanner() { ); return ( - ); } diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index 8811e537b2..c4407cae7f 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -12,6 +12,7 @@ import { formatPeriodDate, MeterBar, meterState, + remainingMeter, } from "@app/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; @@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot { export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { const { t } = useTranslation(); - const { state, pct } = meterState(snap.billableUsed, snap.billableLimit); + const remaining = Math.max(0, snap.billableLimit - snap.billableUsed); + const { state, pct } = remainingMeter(remaining, snap.billableLimit); const stateLabel = state === "DEGRADED" ? t("payg.free.state.limitReached", "Limit reached") @@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { { + if (live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet]); + + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/cloud/hooks/useOpenPlan.ts b/frontend/editor/src/cloud/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..4d532319f6 --- /dev/null +++ b/frontend/editor/src/cloud/hooks/useOpenPlan.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +/** + * Cloud editor builds open the settings modal on its Plan section, which is + * where the free grant is explained and the Processor plan is switched on. + * Routed rather than called directly because the modal is URL-driven here + * (`/settings/*`), the same path the admin tour uses to open it. + */ +export function useOpenPlan(): (() => void) | null { + const navigate = useNavigate(); + return useCallback(() => navigate("/settings/plan"), [navigate]); +} diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0a3f78b3ce..ed3cb2ce6b 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -32,6 +32,14 @@ * promise see the UI flip exactly once the new state is visible — no * intermediate flash of the old value. * + *

Freshness

+ * + * The figures drain as metered work runs, so a mounted consumer re-reads the + * wallet every {@link WALLET_POLL_MS} and again whenever the tab regains + * visibility. Those refreshes are silent — they leave {@code loading} and + * {@code error} alone and only commit fresher data — so consumers that gate on + * those flags don't flicker on a background tick. + * *

Dev preview fallback

* * When the hook is rendered outside the saas app (e.g. on {@code @@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { return prev; } +/** + * How often a mounted consumer re-reads the wallet. Matches the app query + * client's staleTime, so the sidebar meter and anything cached elsewhere age + * out on the same clock. + */ +const WALLET_POLL_MS = 30_000; + export function useWallet(): UseWalletResult { // Resolved once: the dev-preview side-channel when rendered outside the real // app (saas /dev/payg-preview route), else null (every real build + desktop). @@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult { // "the request fired." Cleared when no load is pending. const inFlight = useRef | null>(null); + // Set for refreshes the user didn't ask for (the poll below). Silence governs + // whether a load may RAISE `loading` / `error`, never whether it may clear + // them: consumers gate on both — the limit modals do + // `if (loading || !wallet) return null`, and Plan swaps in an error alert — + // so a background tick must not blink an open modal out or replace a working + // page over a transient failure. Clearing is always the latest request's job, + // silent or not; a silent load that skipped the clear would strand `loading` + // true after superseding a visible one, which suppresses those modals for the + // rest of the session. + const silentRefresh = useRef(false); + useEffect(() => { const reqId = ++latestReqId.current; let cancelled = false; + const silent = silentRefresh.current; + silentRefresh.current = false; + const promise = (async () => { - setLoading(true); - setError(null); + if (!silent) { + setLoading(true); + setError(null); + } if (devPreview) { const synth = devPreview.buildWallet(devPreview.role()); @@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult { const res = await apiClient.get("/api/v1/payg/wallet"); if (cancelled || reqId !== latestReqId.current) return; setWallet((prev) => reuseIfEqual(prev, res.data)); + // Fresh data retires any earlier failure, including one a silent poll + // is recovering from — otherwise Plan keeps its alert over good data. + setError(null); } catch (e: unknown) { if (cancelled || reqId !== latestReqId.current) return; - console.warn("[useWallet] fetch failed", e); - setError(e instanceof Error ? e.message : "Failed to load wallet"); + if (!silent) { + console.warn("[useWallet] fetch failed", e); + setError(e instanceof Error ? e.message : "Failed to load wallet"); + } + // A failed background refresh is a non-event: the last good snapshot + // stands and the next tick self-heals, so it neither surfaces nor + // logs — otherwise an offline tab warns every WALLET_POLL_MS. } finally { + // Deliberately not gated on `silent`: whichever load is latest owns + // settling the flag, or a silent refresh that supersedes a visible one + // leaves it stuck true. if (!cancelled && reqId === latestReqId.current) { setLoading(false); } @@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult { }; }, [devPreview, refetchTick]); + // The wallet drains as automation, AI and API work runs, so a figure fetched + // on mount goes stale while the user watches it. Refresh on a timer, and + // immediately on returning to the tab — coming back to a stale number is the + // case people actually notice. Hidden tabs don't poll, and the dev-preview + // wallet is synthesised locally so there is nothing to re-read. + useEffect(() => { + if (devPreview) return; + + let timer: ReturnType | undefined; + const refresh = () => { + silentRefresh.current = true; + setRefetchTick((t) => t + 1); + }; + const stop = () => { + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + }; + const start = () => { + stop(); + timer = setInterval(refresh, WALLET_POLL_MS); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + refresh(); + start(); + } else { + stop(); + } + }; + + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [devPreview]); + const refetch = useCallback(async () => { setRefetchTick((t) => t + 1); // Snapshot the next-tick promise so the caller awaits this refetch diff --git a/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png index 3cee859e7f..ce40d97faf 100644 Binary files a/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png and b/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png differ diff --git a/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg b/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg index a19eaabc9c..4556168c5f 100644 --- a/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg +++ b/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/brand/classic-logo/logo192.png b/frontend/editor/src/core/assets/brand/classic-logo/logo192.png index 08101ad33c..6c8d372c56 100644 Binary files a/frontend/editor/src/core/assets/brand/classic-logo/logo192.png and b/frontend/editor/src/core/assets/brand/classic-logo/logo192.png differ diff --git a/frontend/editor/src/core/assets/brand/classic-logo/logo512.png b/frontend/editor/src/core/assets/brand/classic-logo/logo512.png index 1f7fe384fd..b71e8b11aa 100644 Binary files a/frontend/editor/src/core/assets/brand/classic-logo/logo512.png and b/frontend/editor/src/core/assets/brand/classic-logo/logo512.png differ diff --git a/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png index f12133f4f7..dab3d43aa3 100644 Binary files a/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png and b/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png differ diff --git a/frontend/editor/src/core/assets/brand/modern-logo/logo192.png b/frontend/editor/src/core/assets/brand/modern-logo/logo192.png index 2994ca293a..2019b93d99 100644 Binary files a/frontend/editor/src/core/assets/brand/modern-logo/logo192.png and b/frontend/editor/src/core/assets/brand/modern-logo/logo192.png differ diff --git a/frontend/editor/src/core/assets/brand/modern-logo/logo512.png b/frontend/editor/src/core/assets/brand/modern-logo/logo512.png index b481550734..90934019e8 100644 Binary files a/frontend/editor/src/core/assets/brand/modern-logo/logo512.png and b/frontend/editor/src/core/assets/brand/modern-logo/logo512.png differ diff --git a/frontend/editor/src/core/assets/login/authentik.svg b/frontend/editor/src/core/assets/login/authentik.svg index 26dc0189ef..4ed18d49b4 100644 --- a/frontend/editor/src/core/assets/login/authentik.svg +++ b/frontend/editor/src/core/assets/login/authentik.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/login/github.svg b/frontend/editor/src/core/assets/login/github.svg index 1174b67928..41c82d53fe 100644 --- a/frontend/editor/src/core/assets/login/github.svg +++ b/frontend/editor/src/core/assets/login/github.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/login/microsoft.svg b/frontend/editor/src/core/assets/login/microsoft.svg index fc1130cbb2..691300857f 100644 --- a/frontend/editor/src/core/assets/login/microsoft.svg +++ b/frontend/editor/src/core/assets/login/microsoft.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/login/oidc.svg b/frontend/editor/src/core/assets/login/oidc.svg index 440b54487c..6c697d709a 100644 --- a/frontend/editor/src/core/assets/login/oidc.svg +++ b/frontend/editor/src/core/assets/login/oidc.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/auth/UseSession.tsx b/frontend/editor/src/core/auth/UseSession.tsx index ee3314606c..207c4da786 100644 --- a/frontend/editor/src/core/auth/UseSession.tsx +++ b/frontend/editor/src/core/auth/UseSession.tsx @@ -26,6 +26,14 @@ export interface AuthContextType { error: Error | null; signOut: () => Promise; refreshSession: () => Promise; + /** + * Session-level permission flags, provided by layers whose auth knows them + * (proprietary's Spring session carries both). Absent in core OSS, which + * has no auth context — consumers must treat undefined as "not granted" + * and fall back to app-config gates. + */ + isAdmin?: boolean; + portalAccess?: boolean; } /** diff --git a/frontend/editor/src/core/components/AppLayout.stories.tsx b/frontend/editor/src/core/components/AppLayout.stories.tsx index 4d7e6780cf..69aceef9d7 100644 --- a/frontend/editor/src/core/components/AppLayout.stories.tsx +++ b/frontend/editor/src/core/components/AppLayout.stories.tsx @@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout"; import { BannerProvider, useBanner } from "@app/contexts/BannerContext"; import { NavigationProvider } from "@app/contexts/NavigationContext"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; const meta = { title: "Components/AppLayout", @@ -49,7 +49,7 @@ function BannerSetter() { const { setBanner } = useBanner(); useEffect(() => { setBanner( - 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 7bed49bd0b..e54964c592 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -16,6 +16,7 @@ import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutli import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import SearchIcon from "@mui/icons-material/Search"; import { FileId } from "@app/types/file"; import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; @@ -89,6 +90,8 @@ interface FileGridProps { onChangeSortMode?: (mode: FilesPageSortMode) => void; /** Drives the empty-state copy. */ currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe"; + /** A filter is applied; an empty result then means "no matches", not "no files". */ + searchActive?: boolean; /** Cloud reachability; switches the cloud empty-state copy. */ serverReachable?: boolean; /** Empty-state CTA handlers; if absent the matching button hides. */ @@ -104,6 +107,7 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) { entries, loading, currentTab, + searchActive, serverReachable, onEmptyUpload, onEmptyCreateFolder, @@ -115,15 +119,28 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) { } if (entries.length === 0) { - return ( + const emptyState = ( ); + // When a filter empties the list view, keep the column headers in place and + // show the no-results message beneath them, rather than replacing the whole + // table. Grid view (cards, no headers) just shows the empty state. + if (viewMode === "list" && searchActive) { + return ( + <> + + {emptyState} + + ); + } + return emptyState; } if (viewMode === "list") { @@ -187,6 +204,8 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) { interface EmptyStateProps { /** Drives copy + iconography. */ tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe"; + /** When true the empty list is the result of a filter, not a bare folder. */ + searchActive?: boolean; /** Switches the cloud empty-state copy. */ serverReachable?: boolean; /** CTA handlers; absent => button hidden. */ @@ -198,12 +217,35 @@ interface EmptyStateProps { function EmptyState({ tab = "all", + searchActive = false, serverReachable = true, onUpload, onCreateFolder, newFolderDisabledReason, }: EmptyStateProps) { const { t } = useTranslation(); + + // A filter with no matches isn't an empty folder - say so, and skip the + // upload / new-folder CTAs since clearing the filter is the way out. + if (searchActive) { + return ( +
+ + + +
+ {t("filesPage.empty.noResults.title", "No matching files")} +
+
+ {t( + "filesPage.empty.noResults.hint", + "No files in this folder match your filter. Try a different term or clear the filter.", + )} +
+
+ ); + } + const { titleKey, titleFallback, hintKey, hintFallback } = (() => { switch (tab) { case "local": diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index 3ed5096efb..a0b0c6cfa1 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -7,11 +7,19 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; -import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core"; +import { + Drawer, + Group, + MultiSelect, + Select, + TextInput, + Tooltip, +} from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import { SegmentedControl } from "@app/ui/SegmentedControl"; import { useMediaQuery } from "@mantine/hooks"; +import CloseIcon from "@mui/icons-material/Close"; import SearchIcon from "@mui/icons-material/Search"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import QrCode2Icon from "@mui/icons-material/QrCode2"; @@ -51,6 +59,8 @@ import { StirlingFileStub } from "@app/types/fileContext"; import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder"; import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid"; +import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; +import { useEditorSearchScopes } from "@app/hooks/useSuperSearch"; import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel"; import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal"; import MobileUploadModal from "@app/components/shared/MobileUploadModal"; @@ -74,6 +84,7 @@ export default function FileManagerView() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); + const searchScopes = useEditorSearchScopes(); // Hide Shared tab when storageSharingEnabled is false. const { sharingEnabled } = useSharingEnabled(); @@ -686,12 +697,11 @@ export default function FileManagerView() { }, [navigate]); // ─── keyboard shortcuts ───────────────────────────────────────────────── - const searchInputRef = useRef(null); - // External focus trigger (used by the FileSidebar rail Search button). - useEffect(() => { - const onFocus = () => searchInputRef.current?.focus(); - window.addEventListener("files-page:focus-search", onFocus); - return () => window.removeEventListener("files-page:focus-search", onFocus); + // Focus the super-search input (stable id), used by the "/" shortcut. + const focusSearch = useCallback(() => { + ( + document.getElementById("super-search-input") as HTMLInputElement | null + )?.focus(); }, []); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -735,13 +745,19 @@ export default function FileManagerView() { // "/" focuses the search field. if (e.key === "/" && !inInput) { e.preventDefault(); - searchInputRef.current?.focus(); + focusSearch(); return; } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]); + }, [ + visibleFiles, + selectedFileIds, + removeFiles, + setSelectedFileIds, + focusSearch, + ]); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -890,11 +906,9 @@ export default function FileManagerView() { }; return ( <> - +
+ +
)} + setSearch(e.currentTarget.value)} + placeholder={t("filesPage.search.placeholder", "Filter files…")} + leftSection={} + rightSection={ + search ? ( + setSearch("")} + aria-label={t("filesPage.search.clear", "Clear filter")} + > + + + ) : null + } + aria-label={t("filesPage.search.label", "Filter files by name")} + style={{ width: 180 }} + /> onChange(e.currentTarget.value)} - placeholder={t( - "filesPage.searchPlaceholder", - "Search this folder & subfolders", - )} - aria-label={t("filesPage.search", "Search")} - /> - {value && ( - onChange("")} - aria-label={t("filesPage.clearSearch", "Clear search")} - > - × - - )} -
- ); -}); - function Breadcrumbs() { const { t } = useTranslation(); const folders = useFolders(); diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 368bae2521..aa9d8248aa 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -34,6 +34,33 @@ gap: 0.5rem; } +.files-page-header-search { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; +} + +.files-page-header-search .super-search { + flex: 0 1 24rem; + width: min(100%, 24rem); + max-width: 24rem; +} + +.files-page-header-search .super-search input { + background-color: transparent; + padding-top: 4px; + padding-bottom: 4px; + font-size: 12.5px; +} + +[data-mantine-color-scheme="dark"] + .files-page-header-search + .super-search + input { + background-color: transparent; +} + .files-page-breadcrumbs { display: flex; align-items: center; @@ -79,28 +106,6 @@ flex-shrink: 0; } -.files-page-search { - display: flex; - align-items: center; - gap: 0.35rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: 999px; - padding: 0.2rem 0.75rem; - /* Fills its grid cell; the cell's minmax(...) clamps to a sensible range. */ - width: 100%; - min-width: 0; -} - -.files-page-search input { - background: transparent; - border: none; - outline: none; - flex: 1; - color: var(--c-text); - font-size: 0.9rem; -} - .files-page-body { display: flex; flex: 1 1 auto; @@ -1376,23 +1381,6 @@ overflow-x: auto; min-width: 0; } - .files-page-search { - /* Shrink hard so the search bar doesn't eat the whole action row. - Users still see the icon + a few chars of the placeholder. - `overflow: hidden` clips the input's natural intrinsic width so - placeholder text never leaks outside the rounded pill. */ - min-width: 0; - flex: 0 1 5.5rem; - max-width: 6.5rem; - overflow: hidden; - } - .files-page-search input { - /* `min-width: 0` lets flex actually shrink the input below its - default ~20-char intrinsic size - without this, the placeholder - extends beyond the parent's clip box and bleeds onto neighbours. */ - min-width: 0; - text-overflow: ellipsis; - } /* 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" diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 049fc2365a..05c96685b2 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -219,7 +219,20 @@ export function VersionTimeline({ align="center" style={{ flex: 1, minWidth: 0, flexWrap: "nowrap" }} > - + v{v.versionNumber ?? 1} 0; - // Custom workbench views (e.g. Watched Folders) manage their own content and may - // have no workbench files, but still need the bar's view switcher so users can - // navigate back out. - const isCustomViewActive = !isBaseWorkbench(currentView); + const { t } = useTranslation(); - // Enable bar transitions after first paint so the initial hidden state shows - // without animating (landing page on load shouldn't animate the bar up). - const [barTransitionEnabled, setBarTransitionEnabled] = useState(false); - useEffect(() => { - const raf = requestAnimationFrame(() => setBarTransitionEnabled(true)); - return () => cancelAnimationFrame(raf); - }, []); + // The viewer's tool row can be retracted to give the document more height. + // State lives here (not in WorkbenchBar) so the reopen tab can hang below the + // bar, outside the bar's overflow-clipped wrapper. Scoped to the viewer. + const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false); + const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed; const handlePreviewClose = () => { setPreviewFile(null); @@ -136,6 +134,20 @@ export default function Workbench() { } if (activeFiles.length === 0) { + // Files are open but their bytes are still loading (a cold PDF engine can + // take seconds). Showing the drop zone here reads as "the click did nothing". + if (fileIds.length > 0) { + return ( +
+ + + + {t("fileManager.loadingFiles", "Loading files...")} + + +
+ ); + } return ; } @@ -219,22 +231,39 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* Workbench Bar - animates in/out based on file presence */} + {/* Workbench Bar — always visible outside My Files (it hosts the + global search), even with no files loaded. */} {currentView !== "myFiles" && !customWorkbenchViews.find((v) => v.workbenchId === currentView) ?.hideTopControls && ( -
-
- +
+
+
+ +
+ {/* Reopen tab: a little handle hanging off the bar's bottom-right + while the viewer tool row is retracted. */} + {showReopenTab && ( +
)} diff --git a/frontend/editor/src/core/components/shared/AppBanner.css b/frontend/editor/src/core/components/shared/AppBanner.css new file mode 100644 index 0000000000..b3f3f9343a --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppBanner.css @@ -0,0 +1,115 @@ +/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour. + Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */ +.app-banner { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 3.5rem; + padding: 0.75rem 1rem; + /* Full-bleed across the top of the app: square corners, one rule underneath. */ + border-bottom: 1px solid var(--app-banner-border); + background: var(--app-banner-bg); + color: var(--c-text); +} + +.app-banner--compact { + min-height: 2.75rem; + padding: 0.5rem 1rem; + gap: 0.5rem; +} + +.app-banner--info { + --app-banner-bg: var(--c-primary-subtle); + --app-banner-border: var(--c-primary-border); + --app-banner-icon: var(--c-accent-fg, var(--c-primary)); +} + +/* The one bar meant to pop, so it takes the feature gradient rather than a tint. + Fixed hues by design — it doesn't follow the chosen accent. */ +.app-banner--promo { + --app-banner-bg: linear-gradient( + 135deg, + var(--c-hue-indigo) 0%, + var(--c-hue-purple) 100% + ); + --app-banner-border: transparent; + --app-banner-icon: var(--color-text-on-accent); + color: var(--color-text-on-accent); +} + +.app-banner--warning { + --app-banner-bg: var(--c-warning-subtle); + --app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent); + --app-banner-icon: var(--c-warning); +} + +.app-banner--danger { + --app-banner-bg: var(--c-danger-subtle); + --app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent); + --app-banner-icon: var(--c-danger); +} + +/* Only the icon carries the tone; text stays neutral in every tone. */ +.app-banner__icon { + display: flex; + flex-shrink: 0; + align-items: center; + color: var(--app-banner-icon); +} + +.app-banner__body { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 0.125rem; +} + +.app-banner__title { + font-size: 0.875rem; + font-weight: 600; +} + +.app-banner__message { + font-size: 0.8125rem; + line-height: 1.4; + color: var(--c-text-muted); +} + +.app-banner__body:not(:has(.app-banner__title)) .app-banner__message { + color: inherit; + font-weight: 500; +} + +.app-banner--compact .app-banner__title, +.app-banner--compact .app-banner__message { + font-size: 0.75rem; +} + +/* On the gradient everything is white; muted grey would disappear. */ +.app-banner--promo .app-banner__message, +.app-banner--promo .app-banner__actions .sui-btn--tertiary, +.app-banner--promo .app-banner__actions .sui-ai { + color: var(--color-text-on-accent); +} + +/* Lifts the premium CTA off the gradient it sits on. */ +.app-banner--promo .app-banner__actions .sui-btn--primary { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +} + +.app-banner__actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.5rem; +} + +/* Mantine trims the leading padding when a button has a left section, which reads + as off-centre next to the label. Even it back up. */ +.app-banner__actions .sui-btn .mantine-Button-inner { + padding-inline: 0; +} +.app-banner__actions .sui-btn { + padding-inline: 0.875rem; +} diff --git a/frontend/editor/src/core/components/shared/AppBanner.stories.tsx b/frontend/editor/src/core/components/shared/AppBanner.stories.tsx new file mode 100644 index 0000000000..db741a4bf8 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppBanner.stories.tsx @@ -0,0 +1,194 @@ +import type { ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AppBanner } from "@app/components/shared/AppBanner"; + +const meta = { + title: "Shared/AppBanner", + component: AppBanner, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Info: Story = { + args: { + icon: "info-rounded", + title: "Heads up", + message: "This document contains form fields that will be flattened.", + }, +}; + +export const Promo: Story = { + args: { + tone: "promo", + icon: "stars-rounded", + title: "Upgrade to Server Plan", + message: + "Get the most out of Stirling PDF with unlimited users and advanced features.", + buttonText: "Upgrade Now", + buttonIcon: "upgrade-rounded", + onButtonClick: () => {}, + compact: true, + }, +}; + +export const Warning: Story = { + args: { + tone: "warning", + icon: "warning-rounded", + title: "Action required", + message: "Some pages could not be processed and were skipped.", + buttonText: "Review", + onButtonClick: () => {}, + }, +}; + +export const Danger: Story = { + args: { + tone: "danger", + icon: "warning-rounded", + title: "This server needs admin attention", + message: "Review the license requirements to keep this server compliant.", + buttonText: "See info", + buttonIcon: "info-rounded", + onButtonClick: () => {}, + dismissible: false, + }, +}; + +export const Compact: Story = { + args: { + compact: true, + icon: "info-rounded", + message: "Autosave is enabled for this file.", + dismissible: false, + }, +}; + +/** Message-only, no title: the message takes the title's weight so the bar still reads. */ +export const MessageOnly: Story = { + args: { + icon: "picture-as-pdf-rounded", + message: + "Make Stirling PDF your default application for opening PDF files.", + buttonText: "Set Default", + onButtonClick: () => {}, + secondaryButtonText: "Don't remind me again", + onSecondaryButtonClick: () => {}, + }, +}; + +function Row({ caption, children }: { caption: string; children: ReactNode }) { + return ( +
+ + {caption} + + {children} +
+ ); +} + +/** + * Every top bar the app can show, in one place: each entry mirrors a real caller, + * so a change to the component is visible against the whole set at once. Renders a + * composition rather than the component, so it takes no args of its own. + */ +export const AllTopBars: StoryObj = { + render: () => ( +
+ + {}} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Decline" + onSecondaryButtonClick={() => {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Don't remind me again" + onSecondaryButtonClick={() => {}} + /> + + + + {}} + dismissible={false} + /> + +
+ ), +}; diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx new file mode 100644 index 0000000000..ee0ba03741 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -0,0 +1,124 @@ +import React, { ReactNode } from "react"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import "@app/components/shared/AppBanner.css"; + +/** Picks the whole look. Callers choose meaning, never colours. */ +export type AppBannerTone = "info" | "promo" | "warning" | "danger"; + +/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */ +const TONE_BUTTON = { + info: { variant: "secondary", accent: "default" }, + promo: { variant: "primary", accent: "premium" }, + warning: { variant: "primary", accent: "warning" }, + danger: { variant: "primary", accent: "danger" }, +} as const; + +interface AppBannerProps { + /** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */ + icon?: string | ReactNode; + title?: ReactNode; + message: ReactNode; + buttonText?: string; + buttonIcon?: string; + onButtonClick?: () => void; + /** Muted secondary action, e.g. "Don't remind me again". */ + secondaryButtonText?: string; + onSecondaryButtonClick?: () => void; + onDismiss?: () => void; + dismissible?: boolean; + loading?: boolean; + show?: boolean; + tone?: AppBannerTone; + compact?: boolean; +} + +/** The app's top bar: dismissible messaging above the workspace. */ +export const AppBanner: React.FC = ({ + icon, + title, + message, + buttonText, + buttonIcon = "check-circle-rounded", + onButtonClick, + secondaryButtonText, + onSecondaryButtonClick, + onDismiss, + dismissible = true, + loading = false, + show = true, + tone = "info", + compact = false, +}) => { + const { t } = useTranslation(); + if (!show) return null; + + const iconSize = compact ? "1rem" : "1.25rem"; + + return ( +
+ {icon != null && ( + + {typeof icon === "string" ? ( + + ) : ( + icon + )} + + )} + +
+ {title && {title}} + {message} +
+ +
+ {buttonText && onButtonClick && ( + + )} + {secondaryButtonText && onSecondaryButtonClick && ( + + )} + {dismissible && ( + onDismiss?.()} + aria-label={t("appBanner.dismiss", "Dismiss")} + > + + + )} +
+
+ ); +}; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.css b/frontend/editor/src/core/components/shared/AppConfigModal.css index 46033eaf97..2953632d98 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.css +++ b/frontend/editor/src/core/components/shared/AppConfigModal.css @@ -1,4 +1,28 @@ /* AppConfigModal styles */ + +/* Deep-link highlight: pulses the control the super search jumped to + (navigated via /settings/{section}?focus={anchor}). */ +@keyframes settings-focus-pulse { + 0% { + box-shadow: 0 0 0 3px var(--mantine-color-blue-5); + background: color-mix( + in srgb, + var(--mantine-color-blue-5) 16%, + transparent + ); + } + 100% { + box-shadow: 0 0 0 6px transparent; + background: transparent; + } +} + +.settings-focus-target { + animation: settings-focus-pulse 1.8s ease-out; + border-radius: 8px; + scroll-margin: 1rem; +} + .modal-container { display: flex; gap: 0; @@ -173,16 +197,6 @@ padding-top: 1rem; } -.settings-search-select { - min-width: 10rem; -} - -.settings-search-option { - display: flex; - flex-direction: column; - gap: 0.125rem; -} - .confirm-modal-content { display: flex; flex-direction: column; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index b5b3224985..12faf6eea5 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -29,7 +29,6 @@ import { UnsavedChangesProvider, useUnsavedChanges, } from "@app/contexts/UnsavedChangesContext"; -import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; import { stripBasePath, withBasePath } from "@app/constants/app"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; @@ -46,6 +45,8 @@ interface AppConfigModalProps { /** Section to land on when opening. Only honoured when urlSync is off (URL * deep links win otherwise). */ initialSection?: NavKey | null; + /** Row anchor to focus when opening on a non-URL host. */ + initialFocus?: string | null; /** Host-specific sections appended after the build's registry sections. */ extraSections?: ConfigNavSection[]; /** Registry section keys to drop, for hosts a section can't run in. */ @@ -67,6 +68,7 @@ const AppConfigModalInner: React.FC = ({ onClose, urlSync = true, initialSection, + initialFocus, extraSections, hiddenSectionKeys, }) => { @@ -150,6 +152,35 @@ const AppConfigModalInner: React.FC = ({ [navigate, urlSync], ); + // Deep-link: /settings/{section}?focus={anchor} scrolls to and briefly + // highlights the matching control (used by the global super search to jump + // straight to an individual setting row). + useEffect(() => { + if (!opened) return; + const focus = urlSync + ? new URLSearchParams(location.search).get("focus") + : initialFocus; + if (!focus) return; + let raf = 0; + // Wait for the (possibly just-switched) section to render before scrolling. + const timer = window.setTimeout(() => { + raf = window.requestAnimationFrame(() => { + const el = document.getElementById(focus); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("settings-focus-target"); + window.setTimeout( + () => el.classList.remove("settings-focus-target"), + 1800, + ); + }); + }, 150); + return () => { + window.clearTimeout(timer); + if (raf) window.cancelAnimationFrame(raf); + }; + }, [opened, active, initialFocus, location.search, urlSync]); + // Backwards-compat: external `appConfig:navigate` events route through the // same switchSection path so they get the no-flash treatment too. useEffect(() => { @@ -185,9 +216,10 @@ const AppConfigModalInner: React.FC = ({ const runningEE = config?.runningEE ?? false; const loginEnabled = config?.enableLogin ?? false; + /** Resolves false when a dirty-state confirm kept the modal open. */ const handleClose = useCallback(async () => { const canProceed = await confirmIfDirty(); - if (!canProceed) return; + if (!canProceed) return false; // Only unwind history if settings was opened via the URL; opened via state // there's no /settings entry to pop and navigate(-1) would jump to /files. @@ -200,6 +232,7 @@ const AppConfigModalInner: React.FC = ({ } } onClose(); + return true; }, [ confirmIfDirty, location.key, @@ -214,6 +247,24 @@ const AppConfigModalInner: React.FC = ({ void handleClose(); }, [handleClose]); + // Cmd/Ctrl+K: hand over to the global super search. The bar's own shortcut + // is inert while a dialog traps focus, so the modal closes itself (through + // the same dirty-check as any other close) and asks the bar to take focus. + // Settings results deep-link straight back into this modal. + useEffect(() => { + if (!opened) return; + const onKey = (e: KeyboardEvent) => { + const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey; + if (!combo || e.code !== "KeyK") return; + e.preventDefault(); + void handleClose().then((closed) => { + if (closed) window.dispatchEvent(new Event("superSearch:focus")); + }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [opened, handleClose]); + // Left navigation structure and icons const registrySections = useConfigNavSections( isAdmin, @@ -413,11 +464,6 @@ const AppConfigModalInner: React.FC = ({ {activeLabel} - diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css index 7ddff9b4c7..df05ff1307 100644 --- a/frontend/editor/src/core/components/shared/BrandMark.css +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -48,9 +48,55 @@ transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428); } +/* One-shot "thinking" drift — the two parallelograms swap past each other and + settle back. Same motion the chat FAB loops while the agent works, but this + pair starts and ends at rest (translate 0, full opacity) so a single + iteration can end without snapping. Callers apply it for one beat; see + NavFooter.css for the hover use. */ +@keyframes sui-brandmark-drift-a { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(-1px, -5px); + opacity: 0.55; + } + 50% { + transform: translate(-6px, 0); + opacity: 0.9; + } + 75% { + transform: translate(-1px, 5px); + opacity: 0.6; + } +} + +@keyframes sui-brandmark-drift-b { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(1px, 5px); + opacity: 0.85; + } + 50% { + transform: translate(6px, 0); + opacity: 0.5; + } + 75% { + transform: translate(1px, -5px); + opacity: 0.85; + } +} + @media (prefers-reduced-motion: reduce) { .sui-brandmark__a, .sui-brandmark__b { transition: none; + animation: none; } } diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 126ec6fede..2347d9a2b2 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -75,16 +75,13 @@ padding: 0.25rem 0; overflow: hidden; } -.file-sidebar-footer-box { - padding: 0.25rem 0; - flex-shrink: 0; -} +/* The footer is the shared : it brings its own boxes and padding, + so this class only positions it in the column. */ /* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and let the boxes stack at the top — controls, then the settings footer right after — instead of the files box stretching to fill. */ -.file-sidebar[data-collapsed="true"] .file-sidebar-controls, -.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box { +.file-sidebar[data-collapsed="true"] .file-sidebar-controls { padding: 0.25rem; } .file-sidebar[data-collapsed="true"] .file-sidebar-files-box { @@ -95,7 +92,6 @@ } /* Centre each row's icon in the narrow rail (no side padding/margin to shove it off the edge). */ -.file-sidebar[data-collapsed="true"] .file-sidebar-search-row, .file-sidebar[data-collapsed="true"] .file-sidebar-action-row, .file-sidebar[data-collapsed="true"] .file-sidebar-cloud-row { justify-content: center; @@ -139,55 +135,6 @@ color: var(--c-accent-text); } -/* ---- Search row ---- */ -.file-sidebar-search-row { - display: flex; - align-items: center; - min-height: 32px; - padding: 0 8px; - gap: 0; - cursor: pointer; - border-radius: 4px; - margin: 0; - flex-shrink: 0; - transition: background-color 0.15s ease; -} - -.file-sidebar-search-row:not(.active):hover { - background-color: var(--c-hover); -} - -.file-sidebar-search-icon { - color: var(--c-text-subtle) !important; - font-size: 18px !important; - flex-shrink: 0; -} - -.file-sidebar-search-close { - cursor: pointer; -} - -.file-sidebar-search-input { - flex: 1; - background: transparent; - border: none; - outline: none; - font-size: 14px; - color: var(--c-text); - margin-left: 12px; - min-width: 0; -} - -.file-sidebar-search-input::placeholder { - color: var(--c-text-subtle); -} - -.file-sidebar-search-label { - margin-left: 12px; - font-size: 14px; - color: var(--c-text); -} - /* ---- Scrollable content ---- */ /* This is a flex column - action rows are fixed, only the file list scrolls */ .file-sidebar-scroll { @@ -588,86 +535,3 @@ pointer-events: none; animation: none; } - -/* ---- Bottom bar (user + settings) ---- */ -.file-sidebar-bottom-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 6px; - flex-shrink: 0; - min-height: 40px; -} - -/* Bottom bar settings icon tracks the right edge during collapse animation */ - -.file-sidebar-bottom-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - background-color: var(--c-accent-text); - color: var(--c-text-on-primary); - font-size: 12px; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - user-select: none; - overflow: hidden; -} - -/* No colored disc behind an actual photo; keep it for the initials fallback. */ -.file-sidebar-bottom-avatar--picture { - background-color: transparent; -} - -.file-sidebar-bottom-avatar-img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.file-sidebar-bottom-name { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--c-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.file-sidebar-bottom-bar[role="button"]:hover { - background-color: var(--c-hover); -} - -.file-sidebar-bottom-bar[role="button"]:focus-visible { - outline: 2px solid var(--c-primary); - outline-offset: -2px; -} - -.file-sidebar-bottom-settings { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; - color: var(--c-text-subtle); - padding: 0; - flex-shrink: 0; - margin-left: auto; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings { - width: 32px; - height: 32px; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar { - justify-content: center; - padding: 8px 0; -} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 742334015c..1c06236027 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -22,25 +22,24 @@ import { } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; -import { useAuth } from "@app/auth/UseSession"; -import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { useOpenPlan } from "@app/hooks/useOpenPlan"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { useIndexedDB, useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; -import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { AppSwitcher } from "@app/components/shared/AppSwitcher"; import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import type { StirlingFileStub } from "@app/types/fileContext"; -import SearchIcon from "@mui/icons-material/Search"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; import UploadFileIcon from "@mui/icons-material/UploadFile"; -import CloseIcon from "@mui/icons-material/Close"; import AddIcon from "@mui/icons-material/Add"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; -import SettingsIcon from "@mui/icons-material/Settings"; +import OpenInFullIcon from "@mui/icons-material/OpenInFull"; import type { FileId } from "@app/types/file"; import { FileItem } from "@app/components/shared/FileSidebarFileItem"; import { useLabelName } from "@app/data/labelDisplay"; @@ -61,7 +60,8 @@ import { deleteServerFile, type DeleteScope, } from "@app/services/serverStorageDelete"; -import { fileStorage } from "@app/services/fileStorage"; +import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage"; +import { alert } from "@app/components/toast"; import { useBulkAddProgress } from "@app/services/bulkAddProgress"; import { useFolderMembership } from "@app/hooks/useFolderMembership"; import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders"; @@ -102,8 +102,6 @@ export interface FileSidebarProps { onUploadFiles?: (files: File[]) => void | Promise; /** Override the Google Drive handler. */ onPickGoogleDriveFiles?: (files: File[]) => void | Promise; - /** Override the Search row click (e.g. focus the /files search input). */ - onSearchClick?: () => void; /** Extra action row inserted under Open-from-computer (e.g. New folder). */ extraAction?: { icon: React.ReactNode; @@ -155,7 +153,6 @@ const FileSidebar = forwardRef( onOpenSettings, onUploadFiles, onPickGoogleDriveFiles, - onSearchClick, extraAction, toggleAriaLabel, toggleIcon, @@ -168,9 +165,6 @@ const FileSidebar = forwardRef( // Classification off (non-SaaS / AI-off) → never show the per-row label chip, // even if a stub carries labels from an imported PDF; keeps the row plain. const classificationEnabled = useClassificationEnabled(); - const [searchActive, setSearchActive] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); const nativeFileInputRef = useRef(null); // State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates. const [pendingViewFileId, setPendingViewFileId] = useState( @@ -248,46 +242,27 @@ const FileSidebar = forwardRef( const { addFiles } = useFileHandler(); const indexedDB = useIndexedDB(); - // Each auth layer derives its own displayName from its native user shape. - // Fall back to the proprietary REST endpoint only when the auth - // context yields nothing - then to "User" as a generic last resort. - const { displayName: authDisplayName, isAnonymous } = useAuth(); - const [accountUsername, setAccountUsername] = useState(null); - const displayName = - authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"); - - const profilePictureUrl = useProfilePictureUrl(); - const [pictureFailed, setPictureFailed] = useState(false); - useEffect(() => setPictureFailed(false), [profilePictureUrl]); - const showProfilePicture = !!profilePictureUrl && !pictureFailed; - - useEffect(() => { - if (!config?.enableLogin) { - setAccountUsername(null); - return; - } - if (authDisplayName) { - // The auth context has a name; don't bother hitting the REST - // endpoint, but clear any stale cached value from a prior call. - setAccountUsername(null); - return; - } - accountService - .getAccountData() - .then((data) => { - // Always reflect the latest result - including clearing it on - // sign-out, when the endpoint returns no username (or 401s into - // the catch branch below). Without this, signing out would leave - // the old username on screen. - setAccountUsername(data?.username ?? null); - }) - .catch(() => { - setAccountUsername(null); - }); - }, [config?.enableLogin, authDisplayName]); + const { displayName, profilePictureUrl, isAnonymous } = + useAccountIdentity(); + const credits = useFreeCreditsSummary(); + const otherApp = useOtherAppSwitch(); + const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); + // Files whose stored bytes this session PROVED unreadable. Rows render a + // "data lost" state instead of pretending the file can open; storage keeps + // the record so a reload re-tests it. + const [lostFileIds, setLostFileIds] = useState>( + () => new Set(), + ); + useEffect( + () => + onRecordUnreadable((fileId) => + setLostFileIds((prev) => new Set(prev).add(fileId as string)), + ), + [], + ); const [stubsLoaded, setStubsLoaded] = useState(false); // Kebab "Save to cloud" target; drives BulkUploadToServerModal. const [saveToServerTarget, setSaveToServerTarget] = useState< @@ -306,32 +281,45 @@ const FileSidebar = forwardRef( const storageEnabled = config?.storageEnabled === true && !isAnonymous; const refreshStubs = useCallback(async () => { - // Leaf files from IDB - same source as the file selection modal. - const stubs = await indexedDB.loadLeafMetadata(); - const idbIds = new Set(stubs.map((s) => s.id as string)); + // `stubsLoaded` gates the spinner, so the `finally` below must set it on + // every path - callers never await this, so a rejection goes nowhere. + let stubs: StirlingFileStub[] = []; + try { + // Leaf files from IDB - same source as the file selection modal. + stubs = await indexedDB.loadLeafMetadata(); + } catch (error) { + // Carry on with the in-memory workbench files: an unreadable library + // should cost the user their history, not the file they're working on. + console.error("Failed to read the file library from storage:", error); + } - // Also include workbench files not yet flushed to IDB. - const pendingStubs = state.files.ids - .map((id) => state.files.byId[id]) - .filter( - (stub): stub is NonNullable => - !!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string), + try { + const idbIds = new Set(stubs.map((s) => s.id as string)); + + // Also include workbench files not yet flushed to IDB. + const pendingStubs = state.files.ids + .map((id) => state.files.byId[id]) + .filter( + (stub): stub is NonNullable => + !!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string), + ); + + const allStubs = [...stubs, ...pendingStubs]; + // A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent. + const superseded = new Set( + allStubs.map((s) => s.parentFileId as string | undefined), ); - - const allStubs = [...stubs, ...pendingStubs]; - // A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent. - const superseded = new Set( - allStubs.map((s) => s.parentFileId as string | undefined), - ); - const currentStubs = allStubs.filter( - (s) => !superseded.has(s.id as string), - ); - setAllFileStubs( - currentStubs.sort( - (a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0), - ), - ); - setStubsLoaded(true); + const currentStubs = allStubs.filter( + (s) => !superseded.has(s.id as string), + ); + setAllFileStubs( + currentStubs.sort( + (a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0), + ), + ); + } finally { + setStubsLoaded(true); + } }, [indexedDB, state.files.ids, state.files.byId]); // Refresh on mount, workbench changes, or external IndexedDB writes — @@ -370,7 +358,9 @@ const FileSidebar = forwardRef( setDeleteTarget(stub); return; } - await fileActions.removeFiles([fileId], true); + // Its superseded versions go too - see orphanedAncestorIds. + const orphans = await fileStorage.orphanedAncestorIds([fileId]); + await fileActions.removeFiles([fileId, ...orphans], true); await refreshStubs(); }, [allFileStubs, fileActions, refreshStubs], @@ -388,7 +378,8 @@ const FileSidebar = forwardRef( await deleteServerFile(stub.remoteStorageId); } if (scope === "device" || scope === "everywhere") { - await fileActions.removeFiles([stub.id], true); + const orphans = await fileStorage.orphanedAncestorIds([stub.id]); + await fileActions.removeFiles([stub.id, ...orphans], true); } else if (scope === "cloud") { // Local copy kept - drop the dead remote pointer so the cloud badge // clears (the sidebar doesn't reconcile with the server itself). @@ -439,17 +430,8 @@ const FileSidebar = forwardRef( } }, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]); - // Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a - // stable array identity — avoids re-running the grouping memo + backfill effect. - const filteredFileStubs = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - return q - ? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q)) - : allFileStubs; - }, [allFileStubs, searchQuery]); - // SaaS groups by classification label; core returns null → one flat, recency-sorted list. - const fileGroups = useFileSidebarGroups(filteredFileStubs); + const fileGroups = useFileSidebarGroups(allFileStubs); // Workbench membership as a Set for O(1) per-row lookups (see renderFileRow). const workbenchIds = useMemo( () => new Set(state.files.ids.map((id) => id as string)), @@ -459,12 +441,12 @@ const FileSidebar = forwardRef( // must key by their unique leaf id rather than the shared lineage (see renderFileRow). const lineageCounts = useMemo(() => { const counts = new Map(); - for (const s of filteredFileStubs) { + for (const s of allFileStubs) { const k = (s.originalFileId ?? s.id) as string; counts.set(k, (counts.get(k) ?? 0) + 1); } return counts; - }, [filteredFileStubs]); + }, [allFileStubs]); // Per-group expand/collapse, falling back to each group's default until toggled. const [groupOpen, setGroupOpen] = useState>({}); const setGroupOpenState = useCallback( @@ -473,29 +455,6 @@ const FileSidebar = forwardRef( [], ); - // Handle search activation - const handleSearchClick = useCallback(() => { - if (onSearchClick) { - onSearchClick(); - return; - } - if (collapsed && onToggleCollapse) { - onToggleCollapse(); - } - setSearchActive(true); - }, [collapsed, onToggleCollapse, onSearchClick]); - - const handleSearchClose = useCallback(() => { - setSearchActive(false); - setSearchQuery(""); - }, []); - - useEffect(() => { - if (searchActive && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [searchActive]); - // Handle Google Drive const handleGoogleDriveClick = useCallback(async () => { if (!isGoogleDriveEnabled) return; @@ -524,6 +483,22 @@ const FileSidebar = forwardRef( const stub = allFileStubs.find((s) => s.id === fileId); if (!stub) return; + // Its bytes are gone; opening it can only fail. Say so instead of a + // click that goes nowhere. + if (stub.dataUnavailable || lostFileIds.has(fileId as string)) { + alert({ + alertType: "warning", + title: t("fileSidebar.dataLostTitle", "File data is unavailable"), + body: t( + "fileSidebar.dataLostBody", + "This browser lost this file's contents. Upload it again to keep working with it.", + ), + expandable: false, + durationMs: 6000, + }); + return; + } + // In the Watched Folders view a click sends the file into the open folder // (mirrors how a click toggles a file into the active workbench elsewhere). // On the folder list (no folder open) it's a no-op so browsing isn't disrupted. @@ -578,6 +553,8 @@ const FileSidebar = forwardRef( }, [ allFileStubs, + lostFileIds, + t, state.files.ids, state.ui.selectedFileIds, fileActions, @@ -765,6 +742,8 @@ const FileSidebar = forwardRef( ? state.files.byId[workbenchFileId]?.thumbnailUrl : undefined) || stub.thumbnailUrl; const fileOrigin = getFileOrigin(stub); + const dataUnavailable = + stub.dataUnavailable === true || lostFileIds.has(stub.id as string); // Key by lineage (originalFileId) so a version swap updates the row in place instead of // remounting. But a 1-input→many-output op (split) yields sibling leaves that share one // originalFileId; those would collide on the key, so fall back to the unique leaf id when a @@ -787,6 +766,7 @@ const FileSidebar = forwardRef( thumbnailUrl={thumbnailUrl} onClick={handleFileClick} onEyeClick={handleEyeClick} + dataUnavailable={dataUnavailable} draggable={isWatchedFoldersActive} onDragStart={handleWatchedFolderDragStart} folders={memberFolders} @@ -854,58 +834,9 @@ const FileSidebar = forwardRef( )}
- {/* Box 1 — top controls (search + open / my files / cloud). No title. */} + {/* Box 1 — top controls (open / my files / cloud). No title. File + search lives in the global super search (top bar), not here. */} - {/* Search row */} - -
e.key === "Enter" && handleSearchClick() - : undefined - } - > - {searchActive && !collapsed ? ( - { - e.stopPropagation(); - handleSearchClose(); - }} - /> - ) : ( - - )} - {!collapsed && - (searchActive ? ( - setSearchQuery(e.target.value)} - placeholder={t( - "fileSidebar.searchPlaceholder", - "Search files...", - )} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {t("fileSidebar.search", "Search")} - - ))} -
-
- {/* Hidden native file input - kept outside the !collapsed gate so the "Open from computer" row below (always rendered) can fire it in either sidebar state without a silent no-op. */} @@ -1138,7 +1069,7 @@ const FileSidebar = forwardRef( {t("fileSidebar.library", "PDF Library")} - + ( )} data-testid="open-files-page" > - + (
- ) : filteredFileStubs.length > 0 ? ( + ) : allFileStubs.length > 0 ? (
{fileGroups ? ( <> @@ -1250,29 +1181,24 @@ const FileSidebar = forwardRef( "fileSidebar.viewAll", "View all {{count}} files", { - count: filteredFileStubs.length, + count: allFileStubs.length, }, )} ) : ( - filteredFileStubs.map(renderFileRow) + allFileStubs.map(renderFileRow) )}
) : ( - !searchActive && ( -
-

- {t("fileSidebar.noFiles", "No files yet")} -

-

- {t( - "fileSidebar.dropHint", - "Open files to get started", - )} -

-
- ) +
+

+ {t("fileSidebar.noFiles", "No files yet")} +

+

+ {t("fileSidebar.dropHint", "Open files to get started")} +

+
)}
)} @@ -1307,70 +1233,17 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — account footer (avatar + name + settings). */} - - {/* Bottom bar: user name + settings */} - -
e.key === "Enter" && onOpenSettings() - : undefined - } - data-testid={onOpenSettings ? "config-button" : undefined} - data-tour={onOpenSettings ? "config-button" : undefined} - aria-label={ - onOpenSettings - ? t("fileSidebar.openSettings", "Open settings") - : displayName - } - style={onOpenSettings ? { cursor: "pointer" } : undefined} - > -
- {showProfilePicture ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() - )} -
- {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
-
+ {/* Box 3 — the shared footer: credits, app switch, account row. */} + ); }, diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 794ed4ca97..874bf47e28 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -447,3 +447,13 @@ transform: translateY(-50%) scale(1); } } + +/* The stored bytes are gone - the row says so instead of pretending to open. */ +.file-sidebar-datalost-badge { + display: inline-flex; + align-items: center; + gap: 0.15rem; + color: var(--c-danger); + font-size: 0.7rem; + white-space: nowrap; +} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index c1a93594c5..2b06c27e56 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -9,6 +9,7 @@ import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined"; import CloudDoneIcon from "@mui/icons-material/CloudDone"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import HistoryIcon from "@mui/icons-material/History"; import type { FileId } from "@app/types/file"; @@ -163,6 +164,9 @@ export interface FileItemProps { onVersionHistory?: (fileId: FileId) => void; /** Whether this file has more than one version (drives the menu item). */ hasVersionHistory?: boolean; + /** The stored bytes are gone (WebKit lost the blob's backing store). The row + * says so instead of pretending the file can open. */ + dataUnavailable?: boolean; } const MAX_VISIBLE_FOLDER_TAGS = 2; @@ -177,6 +181,7 @@ export const FileItem = React.memo(function FileItem({ isSelected, isActive, isViewedInViewer, + dataUnavailable, thumbnailUrl, onClick, onEyeClick, @@ -294,6 +299,21 @@ export const FileItem = React.memo(function FileItem({ )} + {dataUnavailable && ( + + + + {t("fileSidebar.fileItem.dataLost", "Data lost")} + + + )} {isUploadedToCloud && ( ; -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - icon: "info-rounded", - title: "Heads up", - message: "This document contains form fields that will be flattened.", - }, -}; - -export const Warning: Story = { - args: { - tone: "warning", - icon: "warning-rounded", - title: "Action required", - message: "Some pages could not be processed and were skipped.", - buttonText: "Review", - onButtonClick: () => {}, - }, -}; - -export const Compact: Story = { - args: { - compact: true, - icon: "info-rounded", - message: "Autosave is enabled for this file.", - dismissible: false, - }, -}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.tsx b/frontend/editor/src/core/components/shared/InfoBanner.tsx deleted file mode 100644 index 2056b6a92f..0000000000 --- a/frontend/editor/src/core/components/shared/InfoBanner.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { ReactNode } from "react"; -import { Paper, Group, Text, Stack } from "@mantine/core"; -import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button"; -import { ActionIcon } from "@app/ui/ActionIcon"; -import { useTranslation } from "react-i18next"; -import LocalIcon from "@app/components/shared/LocalIcon"; - -type InfoBannerTone = "info" | "warning"; - -const toneStyles: Record< - InfoBannerTone, - { - background: string; - border: string; - text: string; - icon: string; - buttonColor: string; - } -> = { - info: { - background: "var(--mantine-color-blue-0)", - border: "var(--mantine-color-blue-2)", - text: "var(--mantine-color-blue-9)", - icon: "var(--mantine-color-blue-6)", - buttonColor: "blue", - }, - warning: { - background: "var(--mantine-color-orange-0)", - border: "var(--mantine-color-orange-3)", - text: "var(--color-amber-dark)", - icon: "var(--mantine-color-orange-7)", - buttonColor: "orange", - }, -}; - -function toSharedButtonVariant( - variant: "light" | "filled" | "white" | "outline" | "subtle", -): ButtonVariant { - switch (variant) { - case "filled": - return "primary"; - case "outline": - return "secondary"; - case "subtle": - return "tertiary"; - case "light": - case "white": - default: - return "secondary"; - } -} - -function toSharedButtonAccent(color: string | undefined): ButtonAccent { - // Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue. - const hue = (color ?? "").split(".")[0]; - switch (hue) { - case "red": - return "danger"; - case "green": - return "success"; - case "yellow": - case "orange": - return "warning"; - case "blue": - default: - return "default"; - } -} - -interface InfoBannerProps { - /** - * Either a LocalIcon name (string) for the standard sized icon slot, or a - * pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is. - */ - icon?: string | ReactNode; - title?: ReactNode; - message: ReactNode; - buttonText?: string; - buttonIcon?: string; - onButtonClick?: () => void; - /** Optional muted secondary action (e.g. "Don't remind me again"). */ - secondaryButtonText?: string; - onSecondaryButtonClick?: () => void; - onDismiss?: () => void; - dismissible?: boolean; - loading?: boolean; - show?: boolean; - tone?: InfoBannerTone; - background?: string; - borderColor?: string; - textColor?: string; - iconColor?: string; - buttonColor?: string; - buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle"; - /** Override the button label colour (for dark/custom theme variants). */ - buttonTextColor?: string; - minHeight?: number | string; - closeIconColor?: string; - compact?: boolean; -} - -/** - * Generic info banner component for displaying dismissible messages at the top of the app - */ -export const InfoBanner: React.FC = ({ - icon, - title, - message, - buttonText, - buttonIcon = "check-circle-rounded", - onButtonClick, - secondaryButtonText, - onSecondaryButtonClick, - onDismiss, - dismissible = true, - loading = false, - show = true, - tone = "info", - background, - borderColor, - textColor, - iconColor, - buttonColor, - buttonVariant = "light", - buttonTextColor, - minHeight = 56, - closeIconColor, - compact = false, -}) => { - const { t } = useTranslation(); - if (!show) { - return null; - } - - const toneStyle = toneStyles[tone] ?? toneStyles.info; - const resolvedTextColor = textColor ?? toneStyle.text; - const handleDismiss = () => { - onDismiss?.(); - }; - - const iconSize = compact ? "1rem" : "1.2rem"; - const textSize = compact ? "xs" : "sm"; - - return ( - - - - {icon != null && - (typeof icon === "string" ? ( - - ) : ( -
- {icon} -
- ))} - - {title && ( - - {title} - - )} - - {message} - - -
- - {buttonText && onButtonClick && ( - - )} - {secondaryButtonText && onSecondaryButtonClick && ( - - )} - {dismissible && ( - - - - )} - -
-
- ); -}; diff --git a/frontend/editor/src/core/components/shared/LandingActions.tsx b/frontend/editor/src/core/components/shared/LandingActions.tsx index d69f90a1ef..750848f3c7 100644 --- a/frontend/editor/src/core/components/shared/LandingActions.tsx +++ b/frontend/editor/src/core/components/shared/LandingActions.tsx @@ -32,8 +32,7 @@ export function LandingActions({ <> - ))} - - ))} - - - - ); -} diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index dfb533f8ab..48b54b177d 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -123,8 +123,6 @@ } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; - padding-inline: 0; - align-items: center; } .portal-sidebar__logo { @@ -179,10 +177,8 @@ gap: 0.125rem; } +/* The shared brings its own boxes, padding and gap; the sidebar + only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; - padding: 0.5rem 0.375rem; - display: flex; - flex-direction: column; - gap: 0.5rem; } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index ebf91c636e..8ce7008d67 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -2,6 +2,10 @@ import { useMediaQuery } from "@mantine/hooks"; import { Tooltip } from "@mantine/core"; import { ActionIcon, NavItem, NavSurface } from "@app/ui"; import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; +import { useOpenPlan } from "@portal/hooks/useOpenPlan"; import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -10,7 +14,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; -import { CloseIcon, SettingsIcon } from "@portal/components/icons"; +import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, GROUP_PLATFORM, @@ -41,6 +45,9 @@ export function Sidebar() { const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); + const { displayName, profilePictureUrl } = useAccountIdentity(); + const credits = useFreeCreditsSummary(); + const openPlan = useOpenPlan(); // Collapse is a desktop-only affordance: on mobile the sidebar is an // off-canvas drawer, so the icon-rail state never applies there. @@ -146,15 +153,17 @@ export function Sidebar() { ))} - - - } - onClick={() => openSettings()} - /> - + } + collapsed={collapsed} + /> ); } diff --git a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx index 921de50853..910ce957dd 100644 --- a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx @@ -1,13 +1,6 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { - Button, - Card, - EmptyState, - StatusBadge, - Table, - type TableColumn, -} from "@app/ui"; +import { column, DataTable, type DataTableColumn, EmptyState } from "@app/ui"; import type { LinkedInstanceRow } from "@portal/api/link"; interface Props { @@ -45,78 +38,67 @@ export function LinkedInstancesTable({ revokingId, }: Props) { const { t } = useTranslation(); - const cols: TableColumn[] = [ - { + const cols: DataTableColumn[] = [ + column.entity({ key: "name", header: t("portal.accountLink.instances.columns.instance", "Instance"), - render: (i) => ( -
- - {i.name ?? - t("portal.accountLink.instances.unnamed", "Unnamed instance")} - - {i.deviceId} -
- ), - }, - { + sortable: true, + primary: (i) => + i.name ?? t("portal.accountLink.instances.unnamed", "Unnamed instance"), + note: (i) => i.deviceId, + }), + column.badge({ key: "status", header: t("portal.accountLink.instances.columns.status", "Status"), - render: (i) => - i.revoked ? ( - - {t("portal.accountLink.instances.revoked", "Revoked")} - - ) : ( - - {t("portal.accountLink.instances.active", "Active")} - - ), - }, - { + sortable: true, + get: (i) => + i.revoked + ? { + tone: "danger", + label: t("portal.accountLink.instances.revoked", "Revoked"), + } + : { + tone: "success", + label: t("portal.accountLink.instances.active", "Active"), + }, + }), + column.muted({ key: "lastSeen", header: t("portal.accountLink.instances.columns.lastSeen", "Last seen"), - render: (i) => ( - - {relativeTime(i.lastSeenAt, t)} - - ), - }, - { + sortable: true, + // Sort on the real ISO timestamp, not the "3d ago" label. + sortBy: (i) => i.lastSeenAt ?? undefined, + get: (i) => relativeTime(i.lastSeenAt, t), + }), + column.muted({ key: "created", header: t("portal.accountLink.instances.columns.linked", "Linked"), - render: (i) => ( - - {relativeTime(i.createdAt, t)} - - ), - }, - { + sortable: true, + sortBy: (i) => i.createdAt ?? undefined, + get: (i) => relativeTime(i.createdAt, t), + }), + column.actions({ key: "actions", - header: ( - - {t("portal.accountLink.instances.columns.actions", "Actions")} - - ), - align: "right", - render: (i) => - i.revoked ? null : ( - - ), - }, + get: (i) => + i.revoked + ? [] + : [ + { + label: t("portal.accountLink.instances.revoke", "Revoke"), + tone: "danger", + loading: revokingId === i.instanceId, + onClick: () => onRevoke(i), + }, + ], + }), ]; return ( - - {instances.length === 0 ? ( + + columns={cols} + rows={instances} + rowKey={(i) => String(i.instanceId)} + empty={ - ) : ( - String(i.instanceId)} - /> - )} - + } + /> ); } diff --git a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx index 7d322a7be6..3ed7706e80 100644 --- a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx @@ -34,6 +34,7 @@ import { type LatestBundleQuote, } from "@portal/billing/stripe"; import { PrepayModalHeader } from "@portal/components/billing/PrepayModalHeader"; +import "@portal/theme/surface.css"; /** * Prepaid-bundle purchase modal for the Processor billing page — "12 months for @@ -1166,7 +1167,7 @@ function CalculatorStep({ {/* Finer settings as progressive-disclosure rows — a "Change" blooms the card picker. */} -
+
{rows.map((row) => { const open = expanded === row.id; return ( diff --git a/frontend/editor/src/portal/components/billing/CardPlaceholder.tsx b/frontend/editor/src/portal/components/billing/CardPlaceholder.tsx index af1cd78b1b..6678b20bd1 100644 --- a/frontend/editor/src/portal/components/billing/CardPlaceholder.tsx +++ b/frontend/editor/src/portal/components/billing/CardPlaceholder.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { LockIcon } from "@portal/components/icons"; +import "@portal/theme/surface.css"; /** * Card-form stand-in shown on the checkout payment step when no Stripe publishable key is configured @@ -9,7 +10,7 @@ import { LockIcon } from "@portal/components/icons"; export function CardPlaceholder() { const { t } = useTranslation(); return ( -
+
{t("portal.billing.checkout.card.label", "Card details")} Stripe diff --git a/frontend/editor/src/portal/components/billing/InvoicesList.tsx b/frontend/editor/src/portal/components/billing/InvoicesList.tsx index 8e4c8a8540..3ad8da9c2c 100644 --- a/frontend/editor/src/portal/components/billing/InvoicesList.tsx +++ b/frontend/editor/src/portal/components/billing/InvoicesList.tsx @@ -3,11 +3,12 @@ import { useTranslation } from "react-i18next"; import { Button, Card, + type CellLink, + column, + DataTable, + type DataTableColumn, EmptyState, Skeleton, - StatusBadge, - Table, - type TableColumn, } from "@app/ui"; import { formatMinor, formatPeriodDate } from "@app/billing"; import { fetchInvoices, type Invoice } from "@portal/api/billing"; @@ -80,96 +81,78 @@ export function InvoicesList() { // Date Ā· Amount Ā· Status Ā· Description (product name) Ā· Actions // The monospace invoice id is dropped — users care about "what was it for", // not the internal id. - const columns: TableColumn[] = [ - { + const columns: DataTableColumn[] = [ + column.text({ key: "date", header: t("portal.billing.invoices.columnDate", "Date"), - render: (inv) => - inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—", - }, - { + sortable: true, + // Sort chronologically on the raw ISO timestamp, not the formatted label. + sortBy: (inv) => inv.createdAt ?? undefined, + get: (inv) => + inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "-", + }), + column.number({ key: "pdfs", header: t( "portal.billing.invoices.columnPdfsProcessed", "PDFs processed", ), - align: "right", - // Billed units on the invoice's metered line item; "—" when the + sortable: true, + // Billed units on the invoice's metered line item; blank when the // line-item table isn't synced into the Stripe mirror. - render: (inv) => - inv.pdfsProcessed == null ? "—" : inv.pdfsProcessed.toLocaleString(), - }, - { + get: (inv) => inv.pdfsProcessed, + format: (n) => n.toLocaleString(), + }), + column.number({ key: "amount", header: t("portal.billing.invoices.columnAmount", "Amount"), - align: "right", - render: (inv) => - inv.totalMinor == null - ? "—" - : formatMinor(inv.totalMinor, inv.currency), - }, - { + sortable: true, + get: (inv) => inv.totalMinor, + format: (n, inv) => formatMinor(n, inv.currency), + }), + column.badge({ key: "status", header: t("portal.billing.invoices.columnStatus", "Status"), - render: (inv) => ( - - {inv.status} - - ), - }, - { + sortable: true, + get: (inv) => ({ tone: statusTone(inv.status), label: inv.status }), + }), + column.text({ key: "description", header: t("portal.billing.invoices.columnDescription", "Description"), - render: (inv) => ( - - {inv.description ?? - t("portal.billing.invoices.descriptionFallback", "Invoice")} - - ), - }, - { + sortable: true, + get: (inv) => + inv.description ?? + t("portal.billing.invoices.descriptionFallback", "Invoice"), + }), + column.links({ key: "actions", - header: "", - align: "right", - render: (inv) => ( - - ), - }, + get: (inv) => { + const out: CellLink[] = []; + if (inv.hostedInvoiceUrl) { + out.push({ + label: t("portal.billing.invoices.viewLink", "View ↗"), + href: inv.hostedInvoiceUrl, + ariaLabel: t( + "portal.billing.invoices.viewAriaLabel", + "View invoice {{number}} in Stripe", + { number: inv.number ?? inv.id }, + ), + }); + } + if (inv.invoicePdf) { + out.push({ + label: t("portal.billing.invoices.pdfLink", "PDF ↓"), + href: inv.invoicePdf, + ariaLabel: t( + "portal.billing.invoices.downloadAriaLabel", + "Download invoice {{number}} as PDF", + { number: inv.number ?? inv.id }, + ), + }); + } + return out; + }, + }), ]; return ( @@ -209,11 +192,11 @@ export function InvoicesList() { {invoices !== null && invoices.length > 0 && ( <> -
inv.id} + defaultSort={{ key: "date", direction: "desc" }} /> {hasMore && (
diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx index c7ab0e8704..6ea4d8d336 100644 --- a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; -import { formatPeriodDate, MeterBar, meterState } from "@app/billing"; +import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; /** @@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing"; * - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a * "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer * ({@code onBuy}, leader) is present. - * - Bundle held → the capacity meter (fills as the pool is drawn down, so it - * warns as capacity runs low) plus a "Top up" action for the leader. + * - Bundle held → the capacity meter (drains towards empty as the pool is drawn + * down, so it warns as capacity runs low) plus a "Top up" action for the leader. * * Prepaid is consumed before metered billing and sits outside the spend limit, so * it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal} @@ -55,8 +55,7 @@ export function PrepaidCapacityCard({ const remaining = wallet.prepaidUnitsRemaining; const total = wallet.prepaidUnitsTotal; - const used = Math.max(0, total - remaining); - const { state, pct } = meterState(used, total); + const { state, pct } = remainingMeter(remaining, total); const stateLabel = state === "DEGRADED" ? t("portal.billing.prepaid.state.exhausted", "Used up") diff --git a/frontend/editor/src/portal/components/billing/WalletMeter.tsx b/frontend/editor/src/portal/components/billing/WalletMeter.tsx index c8be188390..9558960e89 100644 --- a/frontend/editor/src/portal/components/billing/WalletMeter.tsx +++ b/frontend/editor/src/portal/components/billing/WalletMeter.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Card } from "@app/ui"; -import { formatMinor, MeterBar, meterState } from "@app/billing"; +import { formatMinor, MeterBar, remainingMeter } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; import type { LocalUsage } from "@portal/api/link"; @@ -15,8 +15,10 @@ interface Props { } /** - * The free Processor-trial meter — "X / N free PDFs used" against the one-time - * grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the + * The free Processor-trial meter — "X of N free PDFs left" against the one-time + * grant, with what has been used alongside as the status badge. The bar shows what + * is left, so it drains towards empty as the grant is spent. + * Uses the shared {@link MeterBar} (same `paygf-meter` structure as the * cloud plan page). The subscribed spend-vs-cap meter is a separate surface * ({@code SpendLimitCard}); this card is only the free face. * @@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) { const pending = unsynced?.totalUnsyncedUnits ?? 0; const used = wallet.billableUsed + pending; const remaining = Math.max(0, wallet.freeRemaining - pending); - const { state, pct } = meterState(used, wallet.freeAllowance); + const { state, pct } = remainingMeter(remaining, wallet.freeAllowance); const rate = wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 ? wallet.pricePerDocMinor @@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
diff --git a/frontend/editor/src/portal/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css index 113b660a7c..c1b27720df 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -1198,9 +1198,6 @@ flex-direction: column; gap: 0.5rem; padding: 1rem; - border: 1px solid var(--c-border); - border-radius: var(--radius-md); - background: var(--c-surface); } .portal-billing__card-placeholder-head { display: flex; @@ -1333,8 +1330,6 @@ /* Progressive-disclosure finer settings: summary rows that bloom a card picker. */ .portal-billing__bundle-rows { - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-md); overflow: hidden; } .portal-billing__bundle-row + .portal-billing__bundle-row { diff --git a/frontend/editor/src/portal/components/docs/DocsNav.tsx b/frontend/editor/src/portal/components/docs/DocsNav.tsx index 1063d5e98f..2f6258fca9 100644 --- a/frontend/editor/src/portal/components/docs/DocsNav.tsx +++ b/frontend/editor/src/portal/components/docs/DocsNav.tsx @@ -8,7 +8,8 @@ import type { DocsNavSection } from "@portal/api/docs"; * path ("functionality/security" is a child of "functionality"), so sub-sections * nest under their parent. The root "Overview" section is static (always open, no * toggle); every other section collapses, and only the branch leading to the - * active doc opens by default. (Search lives in DocsSearch above this.) + * active doc opens by default. (Full-text docs search lives in the global + * super search.) */ // Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible. diff --git a/frontend/editor/src/portal/components/docs/DocsSearch.tsx b/frontend/editor/src/portal/components/docs/DocsSearch.tsx deleted file mode 100644 index 49bdb3101f..0000000000 --- a/frontend/editor/src/portal/components/docs/DocsSearch.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import type { SearchResult, Segment } from "@portal/docs/search"; - -/** Render highlighted segments, wrapping matched runs in . */ -function Highlighted({ segments }: { segments: Segment[] }) { - return ( - <> - {segments.map((s, i) => - s.hit ? ( - - {s.text} - - ) : ( - {s.text} - ), - )} - - ); -} - -/** - * Docs search box + results. While a query is active it shows a ranked list of - * matching docs — each with its section, a highlighted title, and a content - * snippet — that navigates on click (or Enter). Arrow keys move the selection. - */ -export function DocsSearch({ - query, - onQueryChange, - results, - onSelect, -}: { - query: string; - onQueryChange: (q: string) => void; - results: SearchResult[]; - onSelect: (docId: string) => void; -}) { - const { t } = useTranslation(); - // -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover. - const [activeIndex, setActiveIndex] = useState(-1); - const listRef = useRef(null); - const hasQuery = query.trim().length > 0; - - useEffect(() => setActiveIndex(-1), [query]); - - useEffect(() => { - listRef.current - ?.querySelector('[data-active="true"]') - ?.scrollIntoView?.({ block: "nearest" }); - }, [activeIndex]); - - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - onQueryChange(""); - return; - } - if (!results.length) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setActiveIndex((i) => Math.min(i + 1, results.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setActiveIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter") { - e.preventDefault(); - const hit = results[activeIndex >= 0 ? activeIndex : 0]; - if (hit) onSelect(hit.id); - } - }; - - return ( -
-
- - āŒ• - - onQueryChange(e.target.value)} - onKeyDown={onKeyDown} - aria-label={t("portal.docs.search.placeholder")} - /> -
- - {hasQuery && ( -
- {results.length === 0 ? ( -

- {t("portal.docs.search.empty")} -

- ) : ( - <> -
- {t("portal.docs.search.results", { count: results.length })} -
-
    - {results.map((r, i) => ( -
  • - -
  • - ))} -
- - )} -
- )} -
- ); -} diff --git a/frontend/editor/src/portal/components/docs/EndpointReferenceSection.tsx b/frontend/editor/src/portal/components/docs/EndpointReferenceSection.tsx index 930e523886..55b1c47f51 100644 --- a/frontend/editor/src/portal/components/docs/EndpointReferenceSection.tsx +++ b/frontend/editor/src/portal/components/docs/EndpointReferenceSection.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { MethodBadge, Tabs, type HttpMethod, type TabItem } from "@app/ui"; import { VERTICALS, ALL_ENDPOINTS } from "@portal/data/endpoints"; import { DocsSection } from "@portal/components/docs/DocsSection"; +import "@portal/theme/surface.css"; type VerticalFilter = "all" | (typeof VERTICALS)[number]["key"]; @@ -46,7 +47,10 @@ export function EndpointReferenceSection() { />
{shown.map((v) => ( -
+
!d.sensitive)!; -const SENSITIVE = ALL.find((d) => d.sensitive)!; + +// The mock documents ship without extractions, so seed a realistic set here - +// a spread of confidence levels so the table (and its confidence sort) is +// actually reviewable. +const EXTRACTIONS: Extraction[] = [ + { field: "Counterparty", value: "Acme Services LLC", confidence: 0.98 }, + { field: "Effective date", value: "2026-01-14", confidence: 0.94 }, + { field: "Contract value", value: "$248,000.00", confidence: 0.87 }, + { field: "Governing law", value: "Delaware", confidence: 0.72 }, + { field: "Auto-renewal", value: "Yes (12 months)", confidence: 0.55 }, +]; + +const NON_SENSITIVE = { + ...ALL.find((d) => !d.sensitive)!, + extractions: EXTRACTIONS, + fieldsExtracted: EXTRACTIONS.length, +}; +const SENSITIVE = { + ...ALL.find((d) => d.sensitive)!, + extractions: EXTRACTIONS, + fieldsExtracted: EXTRACTIONS.length, +}; const meta: Meta = { title: "Portal/Documents/DocumentExtractions", @@ -23,7 +44,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/** No extraction data exists yet - the table shows its empty state. */ +/** Extracted fields with a mix of confidence levels; click a header to sort. */ export const Default: Story = {}; /** Sensitive doc with no active grant — content stays masked. */ @@ -35,3 +56,10 @@ export const Masked: Story = { export const Unlocked: Story = { args: { doc: SENSITIVE, unlocked: true }, }; + +/** No extraction data yet — the empty state. */ +export const Empty: Story = { + args: { + doc: { ...NON_SENSITIVE, extractions: [], fieldsExtracted: 0 }, + }, +}; diff --git a/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx b/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx index 3c6de80c93..646498156d 100644 --- a/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx +++ b/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import LockRounded from "@mui/icons-material/LockRounded"; -import { StatusBadge, Table, type TableColumn } from "@app/ui"; +import { column, DataTable, type DataTableColumn } from "@app/ui"; import { type Extraction, type ReviewDocument } from "@portal/api/documents"; import { confidencePct, @@ -24,32 +24,30 @@ export function DocumentExtractions({ }: DocumentExtractionsProps) { const { t } = useTranslation(); - const cols: TableColumn[] = [ - { + const cols: DataTableColumn[] = [ + column.text({ key: "field", header: t("portal.documents.extractions.columns.field"), - render: (e) => {e.field}, - }, - { + sortable: true, + get: (e) => e.field, + }), + column.mono({ key: "value", header: t("portal.documents.extractions.columns.value"), - render: (e) => {e.value}, - }, - { + get: (e) => e.value, + }), + column.badge({ key: "confidence", header: t("portal.documents.extractions.columns.confidence"), - align: "right", - width: "7rem", - render: (e) => ( - - {confidencePct(e.confidence)} - - ), - }, + sortable: true, + // Sort on the whole-percent integer, not the "92%" label (keeps decimals + // out of the natural-sort comparator). + sortBy: (e) => Math.round(e.confidence * 100), + get: (e) => ({ + tone: confidenceTone(e.confidence), + label: confidencePct(e.confidence), + }), + }), ]; if (doc.sensitive && !unlocked) { @@ -66,7 +64,7 @@ export function DocumentExtractions({ } return ( - + columns={cols} rows={doc.extractions} rowKey={(e) => e.field} diff --git a/frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx b/frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx index ee8b0e8073..f01c6f482d 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx @@ -1,13 +1,16 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { + fireEvent, render as baseRender, screen, type RenderResult, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; import { MemoryRouter } from "react-router-dom"; import type { ReactElement } from "react"; import type { ReviewDocument } from "@portal/api/documents"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import { UIProvider } from "@portal/contexts/UIContext"; import { ReviewQueue } from "@portal/components/documents/ReviewQueue"; // Deterministic i18n: keys returned verbatim. @@ -18,6 +21,12 @@ vi.mock("react-i18next", () => ({ }), })); +const navigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigate }; +}); + // Isolate ReviewQueue's own branching: stub the heavy children so the test // doesn't need TierProvider (DocumentDrawer → useTier) or the real table body. vi.mock("@portal/components/documents/DocumentDrawer", () => ({ @@ -30,7 +39,9 @@ vi.mock("@portal/components/documents/ReviewQueueTable", () => ({ const render = (ui: ReactElement): RenderResult => baseRender( - {ui} + + {ui} + , ); @@ -56,6 +67,10 @@ const DOC: ReviewDocument = { }; describe("ReviewQueue", () => { + beforeEach(() => { + navigate.mockReset(); + }); + it("hides the filter toolbar and shows CTAs when there are no documents", () => { render(); @@ -76,6 +91,31 @@ describe("ReviewQueue", () => { ).not.toBeInTheDocument(); }); + it("opens the connect-a-source modal in place instead of navigating", async () => { + render(); + + fireEvent.click( + screen.getByText("portal.documents.queue.empty.connectSource"), + ); + + expect( + await screen.findByText("portal.sources.types.folder.label"), + ).toBeInTheDocument(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("still navigates to the pipeline builder from the other empty-state CTA", () => { + render(); + + fireEvent.click( + screen.getByText("portal.documents.queue.empty.createPipeline"), + ); + + expect(navigate).toHaveBeenCalledWith( + `${toPortalPath(VIEW_PATHS.pipelines)}/new`, + ); + }); + it("shows the filter toolbar when documents exist", () => { render(); diff --git a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx index 2200237cf6..486e770364 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx @@ -15,6 +15,7 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { DocumentsIcon } from "@portal/components/icons"; import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable"; import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer"; +import { SourceModal } from "@portal/components/sources/SourceModal"; type QueueFilter = "all" | "flagged" | "processed" | "in-review"; @@ -55,6 +56,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState(null); + const [connectSourceOpen, setConnectSourceOpen] = useState(false); const searched = useMemo(() => { const q = query.trim().toLowerCase(); @@ -156,9 +158,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { @@ -175,6 +175,12 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { )} setSelectedId(null)} /> + + setConnectSourceOpen(false)} + />
); } diff --git a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx index e63ea925c4..26befa90b9 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx @@ -1,12 +1,15 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import LockRounded from "@mui/icons-material/LockRounded"; -import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui"; +import { + type CellLabel, + column, + DataTable, + type DataTableColumn, +} from "@app/ui"; import { classificationTone, DOCUMENT_STATUS_LABEL, DOCUMENT_STATUS_TONE, - PRODUCT_CHIP_TONE, type ReviewDocument, } from "@portal/api/documents"; @@ -15,160 +18,105 @@ interface ReviewQueueTableProps { onRowClick: (doc: ReviewDocument) => void; } -function BoltIcon() { - return ( - - - - ); -} - -function KebabIcon() { - return ( - - - - - - ); -} - /** The document stream - one row per document your org has processed. */ export function ReviewQueueTable({ documents, onRowClick, }: ReviewQueueTableProps) { const { t } = useTranslation(); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ - { + column.entity({ key: "document", header: t("portal.documents.table.columns.document"), - render: (d) => ( -
-
- {d.name} - {d.classification && ( - - {d.classification} - - )} - {d.auto && ( - }> - {t("portal.documents.table.auto")} - - )} - {d.sensitive && ( - // role="img" so the label is allowed and the icon reads as one thing: aria-label - // is ignored on a bare span, leaving the padlock silent. - - - - )} -
- {d.note && {d.note}} -
- ), - }, - { + sortable: true, + primary: (d) => d.name, + note: (d) => d.note, + }), + column.labels({ + key: "labels", + header: t("portal.documents.table.columns.labels", "Labels"), + get: (d) => { + const out: CellLabel[] = []; + if (d.classification) { + out.push({ + label: d.classification, + accent: classificationTone(d), + }); + } + if (d.auto) { + out.push({ + label: t("portal.documents.table.auto"), + accent: "success", + }); + } + if (d.sensitive) { + out.push({ + label: t("portal.documents.table.sensitiveLabel"), + accent: "warning", + }); + } + return out; + }, + }), + column.text({ key: "product", header: t("portal.documents.table.columns.product"), - width: "7rem", - render: (d) => ( - - {d.product} - - ), - }, - { + sortable: true, + get: (d) => d.product, + }), + column.text({ key: "action", header: t("portal.documents.table.columns.action"), - width: "12rem", - render: (d) => - d.product === "Editor" || !d.action ? ( - - {t("portal.documents.table.editorAction")} - - ) : ( - {d.action} - ), - }, - { + sortable: true, + get: (d) => + d.product === "Editor" || !d.action + ? t("portal.documents.table.editorAction") + : d.action, + }), + column.muted({ key: "user", header: t("portal.documents.table.columns.user"), - width: "8rem", - render: (d) => ( - {d.user || "-"} - ), - }, - { + sortable: true, + get: (d) => d.user, + }), + column.badge({ key: "status", header: t("portal.documents.table.columns.status"), - width: "10rem", - render: (d) => ( - - {t(DOCUMENT_STATUS_LABEL[d.status])} - {d.status === "in-review" && d.reviewer ? ` Ā· ${d.reviewer}` : ""} - - ), - }, - { + sortable: true, + get: (d) => ({ + tone: DOCUMENT_STATUS_TONE[d.status], + label: + t(DOCUMENT_STATUS_LABEL[d.status]) + + (d.status === "in-review" && d.reviewer ? ` Ā· ${d.reviewer}` : ""), + }), + }), + column.muted({ key: "time", header: t("portal.documents.table.columns.time"), - width: "7rem", - render: (d) => ( - {d.time} - ), - }, - { + get: (d) => d.time, + }), + column.actions({ key: "actions", - header: t("portal.documents.table.columns.actions"), - headerHidden: true, - width: "3rem", - render: (d) => ( -
i.id} /> - )} - + } + /> ); } diff --git a/frontend/editor/src/portal/components/failures/FailureActionButtons.test.tsx b/frontend/editor/src/portal/components/failures/FailureActionButtons.test.tsx index 7982eb15ce..2409a03ec9 100644 --- a/frontend/editor/src/portal/components/failures/FailureActionButtons.test.tsx +++ b/frontend/editor/src/portal/components/failures/FailureActionButtons.test.tsx @@ -70,6 +70,7 @@ function event(actions: FailureActionOffer[]): FileRunEvent { detail: "boom", policyId: "p1", runId: "r1", + sourceId: null, fileId: "f-1", actor: "someone@example.com", occurrences: 1, diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx index 1458a1d64d..9ec3af6842 100644 --- a/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx @@ -21,7 +21,14 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ // Faithful to i18next: a known key resolves, an unknown key falls back to // defaultValue. That is what exercises the server-key-then-generic chain. - t: (key: string, options?: { defaultValue?: string } | string) => { + // i18next's real signature: t(key, options) or t(key, defaultValue, options). + t: ( + key: string, + second?: { defaultValue?: string } | string, + third?: Record, + ) => { + const options = typeof second === "string" ? third : second; + const fallback = typeof second === "string" ? second : undefined; const known: Record = { "portal.failures.kind.inputPasswordProtected.title": "Password-protected document", @@ -30,11 +37,20 @@ vi.mock("react-i18next", () => ({ "portal.failures.occurrences": "occurrences", "portal.failures.runReference": "Run r1", "portal.failures.stage.input": "Input", + "portal.failures.origin.tool": "Tool run", + "portal.failures.origin.policy": "Policy", }; + if (key === "portal.failures.fromSource") { + return `From source ${(options as { source?: string })?.source ?? ""}`; + } + if (key === "portal.failures.reportedBy") { + return `Hit by ${(options as { actor?: string })?.actor ?? ""}`; + } if (known[key]) return known[key]; - if (typeof options === "string") return options; - if (options?.defaultValue) return options.defaultValue; - return key; + if ((options as { defaultValue?: string })?.defaultValue) { + return (options as { defaultValue: string }).defaultValue; + } + return fallback ?? key; }, }), })); @@ -61,6 +77,7 @@ function event(overrides: Partial = {}): FileRunEvent { detail: "The PDF Document is passworded", policyId: "p1", runId: "r1", + sourceId: null, fileId: "f-1", actor: "dana@example.com", occurrences: 1, @@ -102,6 +119,29 @@ describe("FileRunEventList", () => { expect(screen.getByText("The PDF Document is passworded")).toBeTruthy(); }); + it("names the person whose editor hit it, and marks it a tool run", async () => { + // The point of reporting editor failures: a reviewer needs the person, since a + // run reference means nothing for a failure that never had a run. + fetchFileRunEvents.mockResolvedValue([ + event({ origin: "TOOL", actor: "dana@example.com", runId: null }), + ]); + + render(); + + expect(await screen.findByText("Tool run")).toBeTruthy(); + expect(screen.getByText("Hit by dana@example.com")).toBeTruthy(); + }); + + it("names the source when no user was involved, since that is the only attribution", async () => { + fetchFileRunEvents.mockResolvedValue([ + event({ origin: "POLICY", actor: null, sourceId: "src-s3-invoices" }), + ]); + + render(); + + expect(await screen.findByText("From source src-s3-invoices")).toBeTruthy(); + }); + it("shows the occurrence count only once a failure has repeated", async () => { fetchFileRunEvents.mockResolvedValue([event({ occurrences: 1 })]); const { unmount } = render(); diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx index 9b68c53b4c..2330546655 100644 --- a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx @@ -7,6 +7,7 @@ import { useFileRunEventActions, } from "@portal/queries/fileRunEvents"; import { FailureActionButtons } from "@portal/components/failures/FailureActionButtons"; +import "@portal/theme/surface.css"; /** * Recorded policy and pipeline failures, with the triage actions the server offered @@ -28,6 +29,7 @@ export function FileRunEventList() { const { apply, refresh } = useFileRunEventActions(); const [busy, setBusy] = useState<{ id: string; action: string } | null>(null); const [showJson, setShowJson] = useState(false); + const [clearing, setClearing] = useState(false); // A build without the proprietary module has no such route, and a caller who is // not a team leader gets a 403. Both mean there is nothing to show. @@ -42,19 +44,57 @@ export function FileRunEventList() { } }; + // Empties the queue so a test run starts from nothing. Sequential rather than + // concurrent: dismissing is cheap, and one request at a time keeps the failure + // obvious if the endpoint refuses one of them. + const dismissAll = async () => { + setClearing(true); + try { + for (const event of events ?? []) { + const dismiss = event.actions.find( + (action) => action.id === "DISMISS" && action.enabled, + ); + if (dismiss) { + await apply(event.id, "DISMISS"); + } + } + } finally { + setClearing(false); + await refresh(); + } + }; + // Dev-only inspector for hand-checking classification against real uploads. // Vite folds `import.meta.env.DEV` to false, so builds drop this entirely. const debugPanel = !import.meta.env.DEV ? null : (
+ {showJson && (
@@ -137,7 +177,7 @@ function FailureBody({
   return (
     
    {events.map((event) => ( -
  • +
  • {t( @@ -157,8 +197,32 @@ function FailureBody({ })} )} + + {t( + `portal.failures.origin.${event.origin.toLowerCase()}`, + event.origin, + )} +
    + {/* Who or what it came from. An unattended file has no user, so the source + is the only attribution there is. */} + {event.actor ? ( +
    + {t("portal.failures.reportedBy", "Hit by {{actor}}", { + actor: event.actor, + })} +
    + ) : ( + event.sourceId && ( +
    + {t("portal.failures.fromSource", "From source {{source}}", { + source: event.sourceId, + })} +
    + ) + )} + {/* A reference, not a name. The record deliberately holds no document identity, so a reviewer sees which run failed, never which file. */} {event.runId && ( diff --git a/frontend/editor/src/portal/components/failures/failures.css b/frontend/editor/src/portal/components/failures/failures.css index 0ac874dabc..9e43361e43 100644 --- a/frontend/editor/src/portal/components/failures/failures.css +++ b/frontend/editor/src/portal/components/failures/failures.css @@ -36,9 +36,6 @@ } .portal-failures__row { - border: 1px solid var(--c-border); - border-radius: 8px; - background: var(--c-surface); padding: 0.8rem 0.95rem; } @@ -68,6 +65,19 @@ /* The raw failure message. Monospace because for an unclassified failure this is a stack-trace-ish diagnostic, not prose. */ +.portal-failures__origin { + font-size: 0.75rem; + color: var(--c-text-subtle); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-sm, 0.25rem); + padding: 0 0.35rem; +} + +.portal-failures__actor { + font-size: 0.8125rem; + color: var(--c-text-muted); +} + .portal-failures__detail { font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.76rem; diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx index 6a2817d12d..a826e18caa 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx @@ -52,6 +52,7 @@ export function ApiKeysTab() { sub={t("portal.infrastructure.apiKeys.subheading")} />
e.id} - empty={t("portal.infrastructure.audit.noEventsInCategory")} - /> - )} - + e.id} + loading={isLoading} + empty={ + forbidden ? ( + + ) : isEmpty ? ( + + ) : ( + t("portal.infrastructure.audit.noEventsInCategory") + ) + } + /> ); } diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx deleted file mode 100644 index de559bd344..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { DeploymentsTab } from "@portal/components/infrastructure/DeploymentsTab"; -import "@portal/views/Infrastructure.css"; - -const meta: Meta = { - title: "Portal/Infrastructure/DeploymentsTab", - component: DeploymentsTab, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/infrastructure/deployments", async () => { - await delay("infinite"); - return HttpResponse.json({ regions: [], recent: [] }); - }), - ], - }, - }, -}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/infrastructure/deployments", () => - HttpResponse.json({ regions: [], recent: [] }), - ), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx deleted file mode 100644 index 41a9b5c561..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Card, - Chip, - EmptyState, - ProgressBar, - StatusBadge, - Table, - type TableColumn, -} from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchDeployments, - type DeploymentsResponse, - type DeploymentRegion, - type RecentDeployment, -} from "@portal/api/infrastructure"; -import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; -import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton"; -import { - DEPLOY_LABEL, - DEPLOY_TONE, - pct, - REGION_LABEL, - REGION_TONE, -} from "@portal/components/infrastructure/infraFormat"; - -export function DeploymentsTab() { - const { t } = useTranslation(); - const { tier } = useTier(); - const state = useAsync( - () => fetchDeployments(tier), - [tier], - ); - const { data } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - - const regionCols: TableColumn[] = [ - { - key: "name", - header: t("portal.infrastructure.deployments.regionColumns.region"), - render: (r) => ( -
- {r.name} - {r.code} -
- ), - }, - { - key: "latency", - header: t("portal.infrastructure.deployments.regionColumns.latency"), - align: "right", - render: (r) => ( - - {t("portal.infrastructure.deployments.msValue", { - value: r.latencyMs, - })} - - ), - }, - { - key: "load", - header: t("portal.infrastructure.deployments.regionColumns.load"), - width: "9rem", - render: (r) => ( -
- - {pct(r.load)} -
- ), - }, - { - key: "status", - header: t("portal.infrastructure.deployments.regionColumns.status"), - render: (r) => ( - - {t(REGION_LABEL[r.status])} - - ), - }, - { - key: "version", - header: t("portal.infrastructure.deployments.regionColumns.version"), - render: (r) => ( - {r.version} - ), - }, - { - key: "uptime", - header: t("portal.infrastructure.deployments.regionColumns.uptime"), - align: "right", - render: (r) => ( - {pct(r.uptime, 3)} - ), - }, - { - key: "instances", - header: t("portal.infrastructure.deployments.regionColumns.instances"), - align: "right", - render: (r) => {r.instances}, - }, - { - key: "throughput", - header: t("portal.infrastructure.deployments.regionColumns.throughput"), - align: "right", - render: (r) => ( - - {t("portal.infrastructure.deployments.throughputValue", { - value: r.throughput.toLocaleString(), - })} - - ), - }, - { - key: "p99", - header: t("portal.infrastructure.deployments.regionColumns.p99"), - align: "right", - render: (r) => ( - - {t("portal.infrastructure.deployments.msValue", { value: r.p99Ms })} - - ), - }, - ]; - - const deployCols: TableColumn[] = [ - { - key: "version", - header: t("portal.infrastructure.deployments.deployColumns.version"), - render: (d) => ( - {d.version} - ), - }, - { - key: "environment", - header: t("portal.infrastructure.deployments.deployColumns.environment"), - render: (d) => ( - - {d.environment} - - ), - }, - { - key: "product", - header: t("portal.infrastructure.deployments.deployColumns.product"), - render: (d) => d.product, - }, - { - key: "status", - header: t("portal.infrastructure.deployments.deployColumns.status"), - render: (d) => ( - - {t(DEPLOY_LABEL[d.status])} - - ), - }, - { - key: "deployedBy", - header: t("portal.infrastructure.deployments.deployColumns.deployedBy"), - render: (d) => {d.deployedBy}, - }, - { - key: "timestamp", - header: t("portal.infrastructure.deployments.deployColumns.when"), - align: "right", - render: (d) => {d.timestamp}, - }, - ]; - - return ( -
-
- - - {isLoading && } - {isEmpty && ( - - )} - {!isEmpty && data && data.regions.length > 0 && ( -
r.code} - /> - )} - - - -
- - - {isLoading && } - {data && data.recent.length > 0 && ( -
d.id} - /> - )} - - - - ); -} diff --git a/frontend/editor/src/portal/components/infrastructure/ModelsTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/ModelsTab.stories.tsx deleted file mode 100644 index c69fd63988..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ModelsTab } from "@portal/components/infrastructure/ModelsTab"; -import "@portal/views/Infrastructure.css"; - -// Data is served by the registered MSW infrastructure handler; the tier global -// (toolbar) drives which catalogue + routing slice each story renders. -const meta = { - title: "Infrastructure/ModelsTab", - component: ModelsTab, - parameters: { layout: "padded" }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -/** Pro: full managed catalogue plus routing control. */ -export const Pro: Story = { - globals: { tier: "pro" }, -}; - -/** Free: two managed models, no routing (upgrade nudge). */ -export const Free: Story = { - globals: { tier: "free" }, -}; - -/** Enterprise: adds bring-your-own / on-prem models and per-region pinning. */ -export const Enterprise: Story = { - globals: { tier: "enterprise" }, -}; diff --git a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx deleted file mode 100644 index 505f41ac24..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Banner, - Card, - Chip, - EmptyState, - MetricCard, - MetricStrip, - ProgressBar, - Select, - StatusBadge, - Table, - type SelectOption, - type TableColumn, -} from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchModels, - type ModelEntry, - type ModelsResponse, - type RoutingRule, -} from "@portal/api/infrastructure"; -import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; -import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton"; -import { - MODEL_LABEL, - MODEL_PROVIDER_LABEL, - MODEL_TONE, - MODEL_TYPE_LABEL, - MODEL_TYPE_TONE, - modelCost, - pct, -} from "@portal/components/infrastructure/infraFormat"; - -export function ModelsTab() { - const { t } = useTranslation(); - const { tier } = useTier(); - const state = useAsync(() => fetchModels(tier), [tier]); - const { data } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - - const modelCols: TableColumn[] = [ - { - key: "name", - header: t("portal.infrastructure.models.columns.model"), - render: (m) => ( -
- {m.name} - - {MODEL_PROVIDER_LABEL[m.provider]} - -
- ), - }, - { - key: "type", - header: t("portal.infrastructure.models.columns.type"), - render: (m) => ( - - {t(MODEL_TYPE_LABEL[m.type])} - - ), - }, - { - key: "status", - header: t("portal.infrastructure.models.columns.status"), - render: (m) => ( - - {t(MODEL_LABEL[m.status])} - - ), - }, - { - key: "load", - header: t("portal.infrastructure.models.columns.load"), - width: "9rem", - render: (m) => ( -
- - {pct(m.load)} -
- ), - }, - { - key: "latency", - header: t("portal.infrastructure.models.columns.latency"), - align: "right", - render: (m) => ( - - {t("portal.infrastructure.models.msValue", { value: m.latencyMs })} - - ), - }, - { - key: "cost", - header: t("portal.infrastructure.models.columns.cost"), - align: "right", - render: (m) => ( - - {modelCost(t, m.cost, m.costUnit)} - - ), - }, - { - key: "version", - header: t("portal.infrastructure.models.columns.version"), - render: (m) => ( - {m.version} - ), - }, - ]; - - // Free has no routing control: the catalogue is read-only and the routing - // table is replaced by an upgrade nudge. - const canRoute = tier !== "free"; - - // Routing overrides are interactive but unbacked — assigning a model just - // moves local UI state until the routing endpoint exists. - // TODO(backend): PUT /v1/infrastructure/models/routing { rules } - const modelOptions: SelectOption[] = - data?.models - .filter((m) => m.status !== "disabled") - .map((m) => ({ value: m.id, label: m.name })) ?? []; - - const routingCols: TableColumn[] = [ - { - key: "operation", - header: t("portal.infrastructure.models.routingColumns.operation"), - render: (r) => ( -
- {r.operation} - {r.isDefault && ( - - {t("portal.infrastructure.models.routingColumns.default")} - - )} -
- ), - }, - { - key: "docType", - header: t("portal.infrastructure.models.routingColumns.docType"), - render: (r) => r.docType, - }, - { - key: "modelId", - header: t("portal.infrastructure.models.routingColumns.routedTo"), - width: "16rem", - render: (r) => ( -
m.id} - /> - )} - - - - {tier === "enterprise" && ( - - )} - -
- - {canRoute ? ( - - {isLoading && } - {!isEmpty && data && ( -
r.id} - empty={t("portal.infrastructure.models.routing.empty")} - /> - )} - - ) : ( - - )} - - - ); -} diff --git a/frontend/editor/src/portal/components/infrastructure/SecurityTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/SecurityTab.stories.tsx deleted file mode 100644 index 324c841043..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/SecurityTab.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { SecurityTab } from "@portal/components/infrastructure/SecurityTab"; -import "@portal/views/Infrastructure.css"; - -const meta: Meta = { - title: "Portal/Infrastructure/SecurityTab", - component: SecurityTab, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -// Enterprise unlocks HYOK key custody (with a live rotate affordance) and the -// full attested compliance set, including PCI in-scope. -export const Enterprise: Story = { - globals: { tier: "enterprise" }, -}; - -// Free runs on Stirling-managed keys (rotate disabled, upgrade nudge) and a -// trimmed attestation set with HIPAA/PCI not-applicable. -export const Free: Story = { - globals: { tier: "free" }, -}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/infrastructure/security", async () => { - await delay("infinite"); - return HttpResponse.json(null); - }), - ], - }, - }, -}; - -export const Unavailable: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/infrastructure/security", () => - HttpResponse.json(null, { status: 503 }), - ), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx b/frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx deleted file mode 100644 index ccc1b1d3c5..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Banner, - Button, - Card, - Chip, - EmptyState, - RadioGroup, - Skeleton, - StatusBadge, - Table, - type RadioOption, - type TableColumn, -} from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchSecurity, - type AccessPolicy, - type DataResidency, - type SecurityConfig, -} from "@portal/api/infrastructure"; -import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; -import { - ATTESTATION_LABEL, - ATTESTATION_TONE, - CERT_LABEL, - CERT_TONE, - KEY_MODE_LABEL, - KEY_MODE_TONE, -} from "@portal/components/infrastructure/infraFormat"; - -export function SecurityTab() { - const { t } = useTranslation(); - const { tier } = useTier(); - const state = useAsync(() => fetchSecurity(tier), [tier]); - const { data } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - - const ACCESS_OPTS: RadioOption[] = [ - { - value: "stirling", - label: t("portal.infrastructure.security.access.stirling.label"), - description: t( - "portal.infrastructure.security.access.stirling.description", - ), - }, - { - value: "byok", - label: t("portal.infrastructure.security.access.byok.label"), - description: t("portal.infrastructure.security.access.byok.description"), - }, - { - value: "hyok", - label: t("portal.infrastructure.security.access.hyok.label"), - description: t("portal.infrastructure.security.access.hyok.description"), - }, - ]; - - const RESIDENCY_OPTS: RadioOption[] = [ - { - value: "us", - label: t("portal.infrastructure.security.residency.us.label"), - description: t("portal.infrastructure.security.residency.us.description"), - }, - { - value: "eu", - label: t("portal.infrastructure.security.residency.eu.label"), - description: t("portal.infrastructure.security.residency.eu.description"), - }, - { - value: "apac", - label: t("portal.infrastructure.security.residency.apac.label"), - description: t( - "portal.infrastructure.security.residency.apac.description", - ), - }, - ]; - - const ipCols: TableColumn[] = [ - { - key: "label", - header: t("portal.infrastructure.security.ipColumns.label"), - render: (e) => e.label, - }, - { - key: "cidr", - header: t("portal.infrastructure.security.ipColumns.cidr"), - render: (e) => {e.cidr}, - }, - { - key: "addedBy", - header: t("portal.infrastructure.security.ipColumns.addedBy"), - render: (e) => {e.addedBy}, - }, - { - key: "added", - header: t("portal.infrastructure.security.ipColumns.added"), - align: "right", - render: (e) => {e.added}, - }, - ]; - - // Local mirrors so the radios are interactive without a backend round-trip, - // seeded from the fetched config once it lands. - // TODO(backend): PATCH /v1/infrastructure/security { accessPolicy, dataResidency } - const [access, setAccess] = useState(null); - const [residency, setResidency] = useState(null); - - const accessValue = access ?? data?.accessPolicy ?? "stirling"; - const residencyValue = residency ?? data?.dataResidency ?? "us"; - - if (isLoading) { - return ( -
- - -
- ); - } - - if (isEmpty || !data) { - return ( - - ); - } - - return ( -
-
- - - - {accessValue === "hyok" && ( - - )} - - - - - - -
- -
- - -
-
- - {data.keyManagement.provider} - - - {KEY_MODE_LABEL[data.keyManagement.mode]} - -
- {/* Rotation is a privileged backend action; disabled where Stirling - holds the keys (managed tiers can't rotate customer keys). */} - -
- -
-
-
{t("portal.infrastructure.security.keyManagement.keyId")}
-
- - {data.keyManagement.keyId} - -
-
-
-
- {t("portal.infrastructure.security.keyManagement.algorithm")} -
-
- {data.keyManagement.algorithm} -
-
-
-
- {t("portal.infrastructure.security.keyManagement.lastRotated")} -
-
{data.keyManagement.lastRotated}
-
-
-
- {t( - "portal.infrastructure.security.keyManagement.rotationPolicy", - )} -
-
{data.keyManagement.rotationPolicy}
-
-
- - {!data.keyManagement.customerManaged && ( - - )} -
-
- -
- -
- {data.certs.map((c) => ( - -
- {c.name} - - {t(CERT_LABEL[c.status])} - -
-

{c.detail}

-
- ))} -
-
- -
- -
- {data.attestations.map((a) => ( - -
- {a.name} - - {t(ATTESTATION_LABEL[a.status])} - -
- {a.framework} -

{a.detail}

- {a.reportUrl ? ( - e.preventDefault()} - > - {t("portal.infrastructure.security.attestations.viewReport")} - - ) : ( - - {t("portal.infrastructure.security.attestations.noReport")} - - )} -
- ))} -
-
- -
- - {tier === "free" ? ( - - ) : ( - -
e.id} - empty={t("portal.infrastructure.security.ipAllowlist.empty")} - /> - - )} - - - ); -} diff --git a/frontend/editor/src/portal/components/infrastructure/StorageTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/StorageTab.stories.tsx deleted file mode 100644 index 57bcdbff79..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/StorageTab.stories.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { StorageTab } from "@portal/components/infrastructure/StorageTab"; -import type { StorageConfig } from "@portal/api/infrastructure"; -import "@portal/views/Infrastructure.css"; - -const meta: Meta = { - title: "Portal/Infrastructure/StorageTab", - component: StorageTab, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -const OVER_CAP: StorageConfig = { - usedGb: 1920, - quotaGb: 2000, - retention: "180", - providers: [ - { - id: "stirling", - name: "Stirling Cloud", - kind: "stirling", - connected: true, - detail: "Primary vault Ā· us-east-1", - usedGb: 1532, - }, - { - id: "s3", - name: "Amazon S3", - kind: "s3", - connected: true, - detail: "s3://acme-prod-archive Ā· WORM", - usedGb: 388, - }, - { - id: "azure", - name: "Azure Blob", - kind: "azure", - connected: false, - detail: "Not connected", - usedGb: 0, - }, - ], -}; - -// Quota nearly exhausted — exercises the danger threshold on the usage bar. -export const OverThreshold: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/infrastructure/storage", () => - HttpResponse.json(OVER_CAP), - ), - ], - }, - }, -}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/infrastructure/storage", async () => { - await delay("infinite"); - return HttpResponse.json(null); - }), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx b/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx deleted file mode 100644 index 49033345e1..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useState, type ComponentType, type CSSProperties } from "react"; -import { useTranslation } from "react-i18next"; -import ArrowForwardRounded from "@mui/icons-material/ArrowForwardRounded"; -import CloudRounded from "@mui/icons-material/CloudRounded"; -import StorageRounded from "@mui/icons-material/StorageRounded"; -import { - Button, - Card, - EmptyState, - FormField, - ProgressBar, - Select, - Skeleton, - StatusBadge, -} from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchStorage, - type RetentionWindow, - type StorageConfig, -} from "@portal/api/infrastructure"; -import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; -import { pct } from "@portal/components/infrastructure/infraFormat"; - -const PROVIDER_ICON: Record< - StorageConfig["providers"][number]["kind"], - ComponentType<{ style?: CSSProperties }> -> = { - stirling: StorageRounded, - s3: CloudRounded, - azure: CloudRounded, -}; - -/** Storage fills past this fraction of quota are surfaced in red. */ -const USAGE_DANGER_FRAC = 0.8; - -export function StorageTab() { - const { t } = useTranslation(); - const { tier } = useTier(); - const state = useAsync(() => fetchStorage(tier), [tier]); - const { data } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - - const RETENTION_OPTS = [ - { - value: "30", - label: t("portal.infrastructure.storage.retentionOption.days", { - count: 30, - }), - }, - { - value: "60", - label: t("portal.infrastructure.storage.retentionOption.days", { - count: 60, - }), - }, - { - value: "90", - label: t("portal.infrastructure.storage.retentionOption.days", { - count: 90, - }), - }, - { - value: "180", - label: t("portal.infrastructure.storage.retentionOption.days", { - count: 180, - }), - }, - { - value: "never", - label: t("portal.infrastructure.storage.retentionOption.never"), - }, - ]; - - // TODO(backend): PATCH /v1/infrastructure/storage { retention } - const [retention, setRetention] = useState(null); - const retentionValue = retention ?? data?.retention ?? "90"; - - if (isLoading) { - return ( -
- - -
- ); - } - - if (isEmpty || !data) { - return ( - - ); - } - - const usedFrac = data.quotaGb > 0 ? data.usedGb / data.quotaGb : 0; - const overThreshold = usedFrac > USAGE_DANGER_FRAC; - - return ( -
-
- - -
- - {t("portal.infrastructure.storage.gbValue", { - value: data.usedGb.toLocaleString(), - })} - - {" "} - /{" "} - {t("portal.infrastructure.storage.gbValue", { - value: data.quotaGb.toLocaleString(), - })} - - - - {t("portal.infrastructure.storage.percentUsed", { - value: pct(usedFrac), - })} - -
- -
-
- -
- - -
    - {data.providers.map((p) => { - const ProviderIcon = PROVIDER_ICON[p.kind]; - return ( -
  • - - - - - {p.name} - {p.detail} - - {p.connected ? ( - - - {t("portal.infrastructure.storage.gbValue", { - value: p.usedGb, - })} - - - {t("portal.infrastructure.storage.providers.connected")} - - - ) : ( - // TODO(backend): launch the provider OAuth/credential flow, - // then POST /v1/infrastructure/storage/providers/{id}/connect - - )} -
  • - ); - })} -
-
- - - - - onNameChange(e.target.value)} + /> + +
+ {/* The pair share one tooltip target because a disabled button swallows its own hover - the + wrapper is what the pointer lands on. */} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css new file mode 100644 index 0000000000..a83d3ff6ae --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css @@ -0,0 +1,67 @@ +/** + * Edit mode's toolbar: identity on the left, operational actions on the right. + */ + +.portal-pipeline-edit-header { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.portal-pipeline-edit-header__identity { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + flex: 1 1 16rem; +} + +/* The name is the page's title. It takes the room the identity row leaves and truncates rather than + wrapping, so a long name never pushes the pencil out of reach. */ +.portal-pipeline-edit-header__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.portal-pipeline-edit-header__name-input { + flex: 1 1 16rem; + min-width: 12rem; +} + +.portal-pipeline-edit-header__name-input input { + font-size: 1.125rem; + font-weight: 600; +} + +/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ +.portal-pipeline-edit-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex: none; +} + +.portal-pipeline-edit-header__actions .sui-btn { + flex: none; + white-space: nowrap; +} + +/* Save is wrapped so its disabled hover reaches the blocker tooltip; the wrapper must not shrink. */ +.portal-pipeline-edit-header__save { + display: inline-flex; + flex: none; +} + +/* Destructive item in the overflow tray: red label and icon, so it reads as the exception among + the neutral entries above it. */ +.portal-pipeline-edit-header__delete-item .sui-dd__item-label, +.portal-pipeline-edit-header__delete-item .sui-dd__item-leading { + color: var(--c-danger); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx new file mode 100644 index 0000000000..538024e933 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineEditHeader", + component: PipelineEditHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** The name and the pause/activate state are live, so both can be exercised. */ +function Playground({ + initialName, + initialEnabled = true, + canSave = true, + blockers = [], +}: { + initialName: string; + initialEnabled?: boolean; + canSave?: boolean; + blockers?: string[]; +}) { + const [name, setName] = useState(initialName); + const [enabled, setEnabled] = useState(initialEnabled); + return ( + setEnabled((e) => !e)} + togglingEnabled={false} + onBack={noop} + canSave={canSave} + blockers={blockers} + saving={false} + onSave={noop} + onRun={noop} + running={false} + onReprocess={noop} + reprocessing={false} + onDelete={noop} + /> + ); +} + +/** A live pipeline: the toggle offers to pause it. */ +export const Active: Story = { + render: () => , +}; + +/** A paused pipeline: the toggle offers to activate it. */ +export const Paused: Story = { + render: () => ( + + ), +}; + +/** Edits that cannot yet be saved: Save is disabled and hovering it lists what's still needed. */ +export const CannotSave: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx new file mode 100644 index 0000000000..e216dbb292 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineEditHeader, + type PipelineEditHeaderProps, +} from "@portal/components/pipelines/PipelineEditHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onTogglePause: vi.fn(), + onBack: vi.fn(), + onSave: vi.fn(), + onRun: vi.fn(), + onReprocess: vi.fn(), + onDelete: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineEditHeader", () => { + it("shows the name as the title and renames it in place", () => { + const handlers = renderHeader(); + expect(screen.getByText("Claims redaction")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("abandons a rename on Escape, keeping the old name", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Discarded" } }); + fireEvent.keyDown(input, { key: "Escape" }); + // Escape must not commit, even via the blur that unmounting the field fires in a real browser. + fireEvent.blur(input); + expect(handlers.onNameChange).not.toHaveBeenCalled(); + expect(screen.getByText("Claims redaction")).toBeInTheDocument(); + }); + + it("commits a rename when focus leaves the field", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.blur(input); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("offers to pause a live pipeline and to activate a paused one", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.builder.pause")); + expect(handlers.onTogglePause).toHaveBeenCalled(); + + renderHeader({ enabled: false }); + expect( + screen.getByText("portal.pipelines.builder.activate"), + ).toBeInTheDocument(); + }); + + it("runs the saved pipeline from the row", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.detail.run")); + expect(handlers.onRun).toHaveBeenCalled(); + }); + + it("keeps clear-history and delete behind the overflow tray", () => { + const handlers = renderHeader(); + // Not in the row itself... + expect( + screen.queryByText("portal.pipelines.detail.clearHistory"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.delete"), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + expect(handlers.onReprocess).toHaveBeenCalled(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); + expect(handlers.onDelete).toHaveBeenCalled(); + }); + + it("blocks saving until the edits are valid", () => { + renderHeader({ canSave: false }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("cannot pause while a save is committing", () => { + renderHeader({ saving: true }); + expect( + screen.getByText("portal.pipelines.builder.pause").closest("button"), + ).toBeDisabled(); + }); + + it("cannot save while a pause is committing", () => { + renderHeader({ togglingEnabled: true }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("explains, on hover, why Save is disabled", async () => { + renderHeader({ canSave: false, blockers: ["Choose a destination"] }); + const save = document.querySelector( + ".portal-pipeline-edit-header__save", + ) as HTMLElement; + fireEvent.pointerEnter(save); + fireEvent.mouseEnter(save); + expect(await screen.findByText("Choose a destination")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx new file mode 100644 index 0000000000..7ee2fbd173 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx @@ -0,0 +1,235 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import PauseRoundedIcon from "@mui/icons-material/PauseRounded"; +import PowerSettingsNewRoundedIcon from "@mui/icons-material/PowerSettingsNewRounded"; +import ReplayRoundedIcon from "@mui/icons-material/ReplayRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; +import { ActionIcon, Button, Dropdown, Input } from "@app/ui"; +import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip"; +import "@portal/components/pipelines/PipelineEditHeader.css"; + +export interface PipelineEditHeaderProps { + name: string; + onNameChange: (name: string) => void; + + /** The pipeline's live state. Toggling it takes effect immediately, not on save. */ + enabled: boolean; + onTogglePause: () => void; + togglingEnabled: boolean; + + onBack: () => void; + + canSave: boolean; + /** Everything still owed before the edits can be saved, shown on the disabled Save button. */ + blockers: string[]; + saving: boolean; + onSave: () => void; + + /** Run the saved pipeline against its real input, delivering to its real destination. */ + onRun: () => void; + running: boolean; + /** Reprocess everything in the sources: clears the processed record, then runs at once. */ + onReprocess: () => void; + reprocessing: boolean; + onDelete: () => void; +} + +/** + * Edit mode's toolbar over an existing, live pipeline. The left is what it *is* - a back arrow, its + * name as the page title, a pencil to rename in place. The right is what you can *do to it*: pause + * or activate it (an operational toggle that acts at once, matching the Policies vocabulary), run it + * now, and - behind an overflow, since they are rare or destructive - reprocess its sources or delete + * it. Saving the chain edits is the primary action, on the far right. (Reading the definition is an + * inspect action, so it lives in the graph toolbar beside Test, not here.) + */ +export function PipelineEditHeader({ + name, + onNameChange, + enabled, + onTogglePause, + togglingEnabled, + onBack, + canSave, + blockers, + saving, + onSave, + onRun, + running, + onReprocess, + reprocessing, + onDelete, +}: PipelineEditHeaderProps) { + const { t } = useTranslation(); + const [renaming, setRenaming] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef(null); + // Enter and Escape both end the rename, which unmounts the input - and unmounting a focused input + // fires blur in a real browser (jsdom does not). Without this guard that blur would re-run the + // commit, so Escape would save the very draft it was meant to discard. The key handler sets this so + // the trailing blur is ignored; a plain click-away leaves it false and blur commits as normal. + const keyHandledRef = useRef(false); + + useEffect(() => { + if (renaming) inputRef.current?.select(); + }, [renaming]); + + function startRename() { + keyHandledRef.current = false; + setDraft(name); + setRenaming(true); + } + + // End the rename, committing the draft only when asked and only if non-empty (an all-whitespace + // rename would leave the pipeline titleless). + function finishRename(commit: boolean) { + keyHandledRef.current = true; + if (commit) { + const next = draft.trim(); + if (next) onNameChange(next); + } + setRenaming(false); + } + + // Clicking away commits; the unmount-triggered blur that follows a key press does not (the key + // already decided the outcome). + function handleBlur() { + if (keyHandledRef.current) { + keyHandledRef.current = false; + return; + } + finishRename(true); + } + + return ( +
+
+ + + + + {renaming ? ( + setDraft(e.target.value)} + onBlur={handleBlur} + onKeyDown={(e) => { + if (e.key === "Enter") finishRename(true); + if (e.key === "Escape") finishRename(false); + }} + /> + ) : ( + <> +

{name}

+ + + + + )} +
+ +
+ {/* Pause and Save both write the whole policy, so they are mutually exclusive: neither can + start while the other is committing, or the two writes race and the loser's version wins. */} + + + {/* Run and Reprocess both start a run, so only one at a time: each is disabled while the + other is in flight, matching the handler guards (a click otherwise silently no-ops). */} + + + {/* Rare and destructive actions kept off the row so they do not compete with running. */} + + + + + + + + } + > + {t("portal.pipelines.detail.clearHistory")} + + + + } + > + {t("portal.pipelines.detail.delete")} + + + + + {/* Wrapped in a span so the disabled button's hover still reaches the tooltip. */} + + + + + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css new file mode 100644 index 0000000000..9dc2d53bec --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css @@ -0,0 +1,48 @@ +/** + * The test control and the last run's outcome, directly above the graph. + */ + +.portal-pipeline-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Reading the definition sits at the far end of the bar, opposite Test. */ +.portal-pipeline-toolbar__definition { + margin-left: auto; +} + +/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the + backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ +.portal-pipeline-toolbar__result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-pipeline-toolbar__result-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--c-text); +} + +.portal-pipeline-toolbar__result-icon.is-ok { + color: var(--c-success); +} + +.portal-pipeline-toolbar__result-icon.is-bad { + color: var(--c-danger); +} + +/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); + it may wrap to keep a long backend message readable rather than clipping it. */ +.portal-pipeline-toolbar__result-error { + font-size: 0.8125rem; + color: var(--c-text-muted); + min-width: 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx new file mode 100644 index 0000000000..359aa9bfa1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineGraphToolbar", + component: PipelineGraphToolbar, + parameters: { layout: "padded" }, + args: { + stepCount: 2, + testing: false, + runResult: null, + onTest: () => {}, + onDownloadOutput: () => {}, + onViewDefinition: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Idle: just the test control. */ +export const Idle: Story = {}; + +/** A chain with no steps cannot be tested. */ +export const NoSteps: Story = { args: { stepCount: 0 } }; + +/** Mid test-run. */ +export const Testing: Story = { args: { testing: true } }; + +/** After a completed run: the outcome and its files sit beside the button. */ +export const Completed: Story = { + args: { + runResult: { + status: "completed", + completedSteps: 3, + stepCount: 3, + outputs: [ + { fileId: "f1", fileName: "claim-redacted.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }, +}; + +/** A failed run: the summary and the failure reason. */ +export const Failed: Story = { + args: { + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx new file mode 100644 index 0000000000..4aafb27766 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineGraphToolbar, + type PipelineGraphToolbarProps, +} from "@portal/components/pipelines/PipelineGraphToolbar"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderToolbar(overrides: Partial = {}) { + const handlers = { + onTest: vi.fn(), + onDownloadOutput: vi.fn(), + onViewDefinition: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineGraphToolbar", () => { + it("hands the chosen file to the test run", () => { + const handlers = renderToolbar(); + const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); + const input = + document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + expect(handlers.onTest).toHaveBeenCalledWith(file); + }); + + it("will not offer a test run on a chain with no steps", () => { + renderToolbar({ stepCount: 0 }); + expect( + screen.getByText("portal.pipelines.builder.testRun").closest("button"), + ).toBeDisabled(); + }); + + it("opens the definition from its icon", () => { + const handlers = renderToolbar(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.viewDefinition"), + ); + expect(handlers.onViewDefinition).toHaveBeenCalled(); + }); + + it("shows no result strip until a test has been run", () => { + renderToolbar(); + expect( + screen.queryByText(/portal.pipelines.inspector.status/), + ).not.toBeInTheDocument(); + }); + + it("shows why a test run failed, not only that it did", () => { + renderToolbar({ + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }); + expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); + }); + + it("reports a finished run and downloads the file clicked", () => { + const handlers = renderToolbar({ + runResult: { + status: "completed", + completedSteps: 2, + stepCount: 2, + outputs: [ + { fileId: "f1", fileName: "claim.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }); + fireEvent.click(screen.getByText("claim.pdf")); + expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ + fileId: "f1", + fileName: "claim.pdf", + }); + // A file the backend did not name still has to be reachable. + expect(screen.getByText("f2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx new file mode 100644 index 0000000000..30513f6a83 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx @@ -0,0 +1,147 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; +import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; +import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; +import { ActionIcon, Button, FilePicker, Spinner } from "@app/ui"; +import { type RunOutputFile } from "@portal/api/pipelines"; +import "@portal/components/pipelines/PipelineGraphToolbar.css"; + +/** + * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files + * plus the step it stopped at, so there is no per-node output to attach to a node. + */ +export interface RunResultSummary { + status: "running" | "completed" | "failed"; + completedSteps: number; + stepCount: number; + error?: string | null; + outputs?: RunOutputFile[]; +} + +export interface PipelineGraphToolbarProps { + /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ + stepCount: number; + /** Run the steps as they stand against one uploaded file, without saving or delivering. */ + onTest: (file: File) => void; + testing: boolean; + /** The last test run in this session, or null if there has not been one. */ + runResult: RunResultSummary | null; + onDownloadOutput: (output: RunOutputFile) => void; + /** Opens the definition (JSON + cURL) - an inspect action, sibling to Test, hence its home here. */ + onViewDefinition: () => void; +} + +/** + * The graph's own toolbar, above the canvas in both create and edit. It gathers the two ways to + * *inspect* what you are building - testing the chain against one file, and reading its definition - + * as opposed to committing (Save/Create) or operating on the live pipeline (Run now). A test run's + * progress shows on the graph's nodes, so the strip that summarises it belongs next to the graph too. + */ +export function PipelineGraphToolbar({ + stepCount, + onTest, + testing, + runResult, + onDownloadOutput, + onViewDefinition, +}: PipelineGraphToolbarProps) { + const { t } = useTranslation(); + + return ( +
+ file && onTest(file)} + leftSection={} + > + {t("portal.pipelines.builder.testRun")} + + + {runResult && ( + + )} + + {/* The graph is the visual definition; reading it as JSON/cURL sits at the far end of its bar. */} + + + + + +
+ ); +} + +interface RunResultStripProps { + result: RunResultSummary; + onDownload: (output: RunOutputFile) => void; +} + +/** What the last test run did, beside the button that started it. */ +function RunResultStrip({ result, onDownload }: RunResultStripProps) { + const { t } = useTranslation(); + const outputs = result.outputs ?? []; + + return ( +
+
+ {result.status === "running" && } + {result.status === "completed" && ( + + )} + {result.status === "failed" && ( + + )} + + {t(`portal.pipelines.inspector.status.${result.status}`, { + done: result.completedSteps, + count: result.stepCount, + })} + +
+ + {/* The reason it failed, where the failure is announced - not only on the node, which the user + has to know to click. */} + {result.status === "failed" && result.error && ( + + {result.error} + + )} + + {outputs.map((output) => ( + + ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css deleted file mode 100644 index f559e42795..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css +++ /dev/null @@ -1,133 +0,0 @@ -/** - * The builder's opening section: identity above the rule, actions below it. - */ - -.portal-pipeline-header { - display: flex; - flex-direction: column; - gap: 0.875rem; - padding: 1.125rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); -} - -/* Leaving the page and saving it are the same kind of decision, so they share a row - and the back - link is short, so the save pair always has room beside it. */ -.portal-pipeline-header__top { - display: flex; - align-items: center; - gap: 1rem; - flex-wrap: wrap; -} - -/* The back link is the shared Button restyled to a plain link, so re-assert that over the - design-system base (which imposes a fixed height, its own padding and an accent colour). */ -.portal-pipeline-header__back.sui-btn { - height: auto; - min-height: 0; - padding: 0; - font-size: 0.8125rem; - font-weight: 400; - color: var(--c-text-muted); -} - -.portal-pipeline-header__back.sui-btn:hover { - background: none; - color: var(--c-text); -} - -.portal-pipeline-header__identity { - display: flex; - align-items: center; - gap: 1.25rem; - flex-wrap: wrap; -} - -/* The shared Checkbox aligns its box to the top of the first text line, with a nudge tuned for its - own font size - that is for the label-plus-description case. This one is a single line, so centre - the box on it and leave the component's sizing alone (overriding the font size shifts the line - box and leaves the tick floating high). */ -.portal-pipeline-header__enabled.sui-check { - flex: none; - align-items: center; -} - -.portal-pipeline-header__enabled.sui-check .sui-check__box { - margin-top: 0; -} - -/* The name is the page's title, so it takes the room and reads at title size. */ -.portal-pipeline-header__name { - flex: 1 1 16rem; - min-width: 12rem; -} - -.portal-pipeline-header__name input { - font-size: 1rem; - font-weight: 500; -} - -/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ -.portal-pipeline-header__save { - display: flex; - align-items: center; - gap: 0.5rem; - margin-left: auto; - flex: none; -} - -.portal-pipeline-header__save .sui-btn { - flex: none; - white-space: nowrap; -} - -/* Operational actions: what you can do to this pipeline, kept off the identity row. */ -.portal-pipeline-header__actions { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.875rem; - border-top: 1px solid var(--c-border-subtle); -} - -/* Destructive, so it sits away from the rest rather than next in line. */ -.portal-pipeline-header__delete.sui-btn { - margin-left: auto; -} - -/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the - backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ -.portal-pipeline-header__result { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.875rem; - border-top: 1px solid var(--c-border-subtle); -} - -.portal-pipeline-header__result-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; - color: var(--c-text); -} - -.portal-pipeline-header__result-icon.is-ok { - color: var(--c-success); -} - -.portal-pipeline-header__result-icon.is-bad { - color: var(--c-danger); -} - -/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); - it may wrap to keep a long backend message readable rather than clipping it. */ -.portal-pipeline-header__result-error { - font-size: 0.8125rem; - color: var(--c-text-muted); - min-width: 0; -} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx deleted file mode 100644 index 41c73a5ade..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useState } from "react"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { - PipelineHeader, - type RunResultSummary, -} from "@portal/components/pipelines/PipelineHeader"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineHeader", - component: PipelineHeader, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -const noop = () => {}; - -/** The name and the enabled switch are live, so the section can be seen in both states. */ -function Playground({ - initialName, - isEdit, - initialEnabled = true, - runResult = null, - ...rest -}: { - initialName: string; - isEdit: boolean; - initialEnabled?: boolean; - runResult?: RunResultSummary | null; - saving?: boolean; - testing?: boolean; - running?: boolean; - canSave?: boolean; - stepCount?: number; -}) { - const [name, setName] = useState(initialName); - const [enabled, setEnabled] = useState(initialEnabled); - return ( - - ); -} - -/** An existing pipeline: everything is available. */ -export const Editing: Story = { - render: () => , -}; - -/** - * A pipeline that has never been saved. It can still be tested against a file, but there is - * nothing yet to run on a schedule, clear history for, or delete. - */ -export const New: Story = { - render: () => , -}; - -/** Paused: the pipeline exists but its trigger will not fire. */ -export const Paused: Story = { - render: () => ( - - ), -}; - -/** Mid test-run: the picker shows its own progress while the graph shows the steps. */ -export const Testing: Story = { - render: () => , -}; - -/** After a test run: the outcome and its files sit beside the button that started them. */ -export const WithRunResult: Story = { - render: () => ( - - ), -}; - -/** A failed run: the summary is here, the failing step's own message is on its node. */ -export const WithFailedRun: Story = { - render: () => ( - - ), -}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx deleted file mode 100644 index 2c5552be07..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - fireEvent, - render as baseRender, - screen, -} from "@testing-library/react"; -import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -import { - PipelineHeader, - type PipelineHeaderProps, -} from "@portal/components/pipelines/PipelineHeader"; - -const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: PortalTestProviders }); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -function renderHeader(overrides: Partial = {}) { - const handlers = { - onNameChange: vi.fn(), - onEnabledChange: vi.fn(), - onSave: vi.fn(), - onCancel: vi.fn(), - onBack: vi.fn(), - onTest: vi.fn(), - onRun: vi.fn(), - onClearHistory: vi.fn(), - onDelete: vi.fn(), - onViewDefinition: vi.fn(), - onDownloadOutput: vi.fn(), - }; - render( - , - ); - return handlers; -} - -describe("PipelineHeader", () => { - it("edits the pipeline's name and enabled state", () => { - const handlers = renderHeader(); - fireEvent.change( - screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), - { target: { value: "Renamed" } }, - ); - expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); - - fireEvent.click(screen.getByRole("checkbox")); - expect(handlers.onEnabledChange).toHaveBeenCalledWith(false); - }); - - it("offers run, clear history and delete only once the pipeline exists", () => { - renderHeader({ isEdit: false }); - expect( - screen.queryByText("portal.pipelines.detail.run"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.detail.delete"), - ).not.toBeInTheDocument(); - // A test run needs no saved record, so it stays: it is how you check the steps as you build. - expect( - screen.getByText("portal.pipelines.builder.testRun"), - ).toBeInTheDocument(); - }); - - it("labels the save action for what it will do", () => { - renderHeader({ isEdit: false }); - expect( - screen.getByText("portal.pipelines.composer.create"), - ).toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.composer.save"), - ).not.toBeInTheDocument(); - }); - - it("blocks saving until the pipeline is valid", () => { - renderHeader({ canSave: false }); - expect( - screen.getByText("portal.pipelines.composer.save").closest("button"), - ).toBeDisabled(); - }); - - it("hands the chosen file to the test run", () => { - const handlers = renderHeader(); - const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); - const input = - document.querySelector('input[type="file"]'); - expect(input).not.toBeNull(); - fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); - expect(handlers.onTest).toHaveBeenCalledWith(file); - }); - - it("will not offer a test run on a chain with no steps", () => { - renderHeader({ stepCount: 0 }); - expect( - screen.getByText("portal.pipelines.builder.testRun").closest("button"), - ).toBeDisabled(); - }); - - it("shows why a test run failed, not only that it did", () => { - renderHeader({ - runResult: { - status: "failed", - completedSteps: 1, - stepCount: 3, - error: "OCR failed: unreadable page", - }, - }); - expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); - }); - - it("runs and deletes from the row, clears history from the tray", () => { - const handlers = renderHeader(); - fireEvent.click(screen.getByText("portal.pipelines.detail.run")); - expect(handlers.onRun).toHaveBeenCalled(); - fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); - expect(handlers.onDelete).toHaveBeenCalled(); - - fireEvent.click( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ); - fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); - expect(handlers.onClearHistory).toHaveBeenCalled(); - }); - - it("leaves the page through cancel and back", () => { - const handlers = renderHeader(); - fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); - expect(handlers.onCancel).toHaveBeenCalled(); - fireEvent.click(screen.getByText("portal.pipelines.builder.back")); - expect(handlers.onBack).toHaveBeenCalled(); - }); - - it("keeps the occasional actions out of the row, behind a tray", () => { - renderHeader(); - // Running and testing earn a button each; reading the definition and wiping history do not. - expect( - screen.queryByText("portal.pipelines.builder.viewDefinition"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.detail.clearHistory"), - ).not.toBeInTheDocument(); - expect( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ).toBeInTheDocument(); - }); - - it("opens the definition from the tray", () => { - const handlers = renderHeader(); - fireEvent.click( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ); - fireEvent.click( - screen.getByText("portal.pipelines.builder.viewDefinition"), - ); - expect(handlers.onViewDefinition).toHaveBeenCalled(); - }); - - it("shows no run strip until a test has been run", () => { - renderHeader(); - expect( - screen.queryByText(/portal.pipelines.inspector.status/), - ).not.toBeInTheDocument(); - }); - - it("reports a finished run and downloads the file clicked", () => { - const handlers = renderHeader({ - runResult: { - status: "completed", - completedSteps: 2, - stepCount: 2, - outputs: [ - { fileId: "f1", fileName: "claim.pdf" }, - { fileId: "f2", fileName: null }, - ], - }, - }); - fireEvent.click(screen.getByText("claim.pdf")); - expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ - fileId: "f1", - fileName: "claim.pdf", - }); - // A file the backend did not name still has to be reachable. - expect(screen.getByText("f2")).toBeInTheDocument(); - }); -}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx deleted file mode 100644 index 25bfbd044f..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; -import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; -import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; -import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; -import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; -import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; -import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; -import { - ActionIcon, - Button, - Checkbox, - Dropdown, - FilePicker, - Input, - Spinner, -} from "@app/ui"; -import "@portal/components/pipelines/PipelineHeader.css"; - -/** One file a test run produced, downloadable from the result strip. */ -export interface RunOutputFile { - fileId: string; - fileName: string | null; -} - -/** - * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files - * plus the step it stopped at, so there is no per-node output to attach to a node. - */ -export interface RunResultSummary { - status: "running" | "completed" | "failed"; - completedSteps: number; - stepCount: number; - error?: string | null; - outputs?: RunOutputFile[]; -} - -export interface PipelineHeaderProps { - name: string; - onNameChange: (name: string) => void; - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - /** False for a pipeline that has never been saved: it cannot yet be run, cleared or deleted. */ - isEdit: boolean; - /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ - stepCount: number; - - canSave: boolean; - saving: boolean; - onSave: () => void; - onCancel: () => void; - onBack: () => void; - - /** Run the steps as they stand against one uploaded file, without saving or delivering. */ - onTest: (file: File) => void; - testing: boolean; - /** Run the saved pipeline against its real input, delivering to its real destination. */ - onRun: () => void; - running: boolean; - onClearHistory: () => void; - clearingHistory: boolean; - onDelete: () => void; - - /** Opens the definition (JSON + cURL), which is pipeline-scoped like the rest of this row. */ - onViewDefinition: () => void; - /** The last test run in this session, or null if there has not been one. */ - runResult: RunResultSummary | null; - onDownloadOutput: (output: RunOutputFile) => void; -} - -/** - * The pipeline's identity and its whole-pipeline actions, at the top of the builder. - * - * Split in two so neither half gets lost in a single crowded row: what the pipeline *is* (name, - * whether it is live) sits with the actions that leave the page, and what you can *do to it* sits - * below the rule. A test run is part of building, so it lives here rather than off in a corner - - * its progress shows on the graph's nodes and its results in the inspector. - */ -export function PipelineHeader({ - name, - onNameChange, - enabled, - onEnabledChange, - isEdit, - stepCount, - canSave, - saving, - onSave, - onCancel, - onBack, - onTest, - testing, - onRun, - running, - onClearHistory, - clearingHistory, - onDelete, - onViewDefinition, - runResult, - onDownloadOutput, -}: PipelineHeaderProps) { - const { t } = useTranslation(); - - return ( -
-
- -
- - -
-
- -
- onNameChange(e.target.value)} - /> - {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch - would imply it applies the moment it is flipped. No description - a second line beside - the single-line name field leaves the row ragged. */} - onEnabledChange(e.target.checked)} - label={t("portal.pipelines.builder.enabled")} - /> -
- -
- file && onTest(file)} - leftSection={} - > - {t("portal.pipelines.builder.testRun")} - - - {isEdit && ( - - )} - - {/* Occasional things - reading the definition, wiping the processed history - kept behind a - tray so they do not compete with running and testing, which is what this row is for. */} - - - - - - - - } - > - {t("portal.pipelines.builder.viewDefinition")} - - {isEdit && ( - - } - > - {t("portal.pipelines.detail.clearHistory")} - - )} - - - - {isEdit && ( - - )} -
- - {runResult && ( - - )} -
- ); -} - -interface RunResultStripProps { - result: RunResultSummary; - onDownload: (output: RunOutputFile) => void; -} - -/** What the last test run did, beside the button that started it. */ -function RunResultStrip({ result, onDownload }: RunResultStripProps) { - const { t } = useTranslation(); - const outputs = result.outputs ?? []; - - return ( -
-
- {result.status === "running" && } - {result.status === "completed" && ( - - )} - {result.status === "failed" && ( - - )} - - {t(`portal.pipelines.inspector.status.${result.status}`, { - done: result.completedSteps, - count: result.stepCount, - })} - -
- - {/* The reason it failed, where the failure is announced - not only on the node, which the user - has to know to click. */} - {result.status === "failed" && result.error && ( - - {result.error} - - )} - - {outputs.map((output) => ( - - ))} -
- ); -} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.css b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css index 0fc50597e8..b5aeaff6fc 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineInspector.css +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css @@ -7,9 +7,6 @@ flex-direction: column; gap: 0.875rem; padding: 1.125rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); /* The builder caps its columns so the page itself does not scroll, which means a settings form taller than the viewport has to scroll in here - otherwise its lower half is unreachable. */ max-height: 100%; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInspector.tsx index 49fccf7e9b..cac71b5790 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineInspector.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; import { Banner, EmptyState } from "@app/ui"; +import "@portal/theme/surface.css"; import "@portal/components/pipelines/PipelineInspector.css"; export interface PipelineInspectorProps { @@ -40,7 +41,7 @@ export function PipelineInspector({ if (message) { return ( -