diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml index 4a513956d3..fcd0f05613 100644 --- a/.github/ISSUE_TEMPLATE/1-bug.yml +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -98,6 +98,13 @@ body: description: | If you have any additional information that might help us understand and resolve the issue, provide it here. + - type: textarea + id: sample-files + attributes: + label: Sample Files + description: | + If possible, attach the PDF or other input files needed to reproduce the issue. Remove any sensitive information before sharing. + - type: markdown attributes: value: | diff --git a/.github/ISSUE_TEMPLATE/2-feature.yml b/.github/ISSUE_TEMPLATE/2-feature.yml index 810f3fcd8c..0463ff7342 100644 --- a/.github/ISSUE_TEMPLATE/2-feature.yml +++ b/.github/ISSUE_TEMPLATE/2-feature.yml @@ -67,6 +67,13 @@ body: description: | If you have any additional information, comments, or resources you think would support or be relevant to your feature request, include them here. + - type: textarea + id: sample-files + attributes: + label: Example Files + description: | + If the feature request depends on specific PDFs or other example files, attach them here when available. Remove any sensitive information before sharing. + - type: checkboxes id: search-confirmation attributes: diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index fb6a99cfca..c47c8e3ee7 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.2 +pkgver=2.14.3 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index 70bcee0423..d6159ddce3 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.2 +pkgver=2.14.3 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index e6e4f08230..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 4489c2f4af..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: @@ -37,6 +39,19 @@ updates: cooldown: default-days: 7 rebase-strategy: "auto" + groups: + ubuntu: + patterns: + - "ubuntu" + eclipse-temurin: + patterns: + - "eclipse-temurin" + uv: + patterns: + - "ghcr.io/astral-sh/uv" + gradle: + patterns: + - "gradle" - package-ecosystem: github-actions directory: / @@ -106,6 +121,10 @@ updates: patterns: - "@posthog/*" - "posthog-js" + storybook: + patterns: + - "storybook" + - "@storybook/*" supabase: patterns: - "@supabase/*" @@ -159,3 +178,11 @@ updates: cooldown: default-days: 7 rebase-strategy: "auto" + + - package-ecosystem: "uv" + directory: "/engine" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + rebase-strategy: "auto" 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 3238750171..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 @@ -106,7 +107,7 @@ jobs: - name: Publish stirling-pdf-desktop to AUR if: ${{ github.event_name == 'release' || inputs.dry_run == false }} - uses: KSXGitHub/github-actions-deploy-aur@da03e160361ce01bf087e790b6ffd196d7dccff7 # v4.1.3 + uses: KSXGitHub/github-actions-deploy-aur@084b0d9b15415bf9cdb65d44dad1efe37a354050 # v4.2.0 with: pkgname: stirling-pdf-desktop pkgbuild: .github/aur/stirling-pdf-desktop/PKGBUILD 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 e480243810..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) @@ -153,6 +153,9 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} 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" - name: Check Test Reports Exist if: always() diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 62c53f8154..93b8abf1e7 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 once per Valkey topology 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: >- @@ -332,7 +353,7 @@ jobs: MN_FILES: -f docker-compose-multinode.yml ${{ matrix.valkey != 'standalone' && format('-f docker-compose-multinode.valkey-{0}.yml', matrix.valkey) || '' }} 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 0fa9798ff5..133d940b28 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -1,8 +1,8 @@ name: Frontend lint, type-check, and build # Reusable workflow called from build.yml when frontend / testing sources -# change. Runs the consolidated `task frontend:check:all` (lint, types, -# unit tests, build) and uploads the dist artifact for downstream jobs. +# change. Runs `task frontend:check:all` and uploads the +# coverage + dist artifacts for downstream jobs. on: workflow_call: @@ -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 @@ -105,16 +105,6 @@ jobs: comment_id: existing.id, }); } - - name: Vitest coverage - # Separate from `frontend:check:all` so the quality-gate run stays - # uninstrumented (faster signal) and coverage stays an informational - # follow-up. Continue-on-error keeps the workflow green even when - # a handful of test files refuse to import (e.g. missing icon - # specifiers) - the summary still gets posted with whatever - # vitest managed to instrument. - id: frontend-coverage - continue-on-error: true - run: task frontend:test:coverage - name: Install uv if: always() uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 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 cb9742ca88..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 @@ -879,7 +852,7 @@ jobs: PYEOF - name: Upload merged artifacts for review - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-artifacts path: ./artifacts/ @@ -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 e4f6fe5d8e..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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3.29.5 + 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 039c16175e..af255de3cf 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,12 +17,12 @@ 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 - name: 30 days stale issues - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 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 8cc854bb79..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 @@ -63,13 +65,23 @@ app/core/src/main/resources/static/og_images/ app/core/src/main/resources/static/samples/ app/core/src/main/resources/static/manifest-classic.json app/core/src/main/resources/static/og-metadata.json +app/core/src/main/resources/static/og-metadata.saas.json app/core/src/main/resources/static/sw-folder-retry.js app/core/src/main/resources/static/robots.txt +app/core/src/main/resources/static/android-chrome-*.png +app/core/src/main/resources/static/mstile-*.png +app/core/src/main/resources/static/favicon.png +app/core/src/main/resources/static/safari-pinned-tab.svg app/core/src/main/resources/static/pdfium/ app/core/src/main/resources/static/pdfjs/ app/core/src/main/resources/static/vendor/ app/core/src/main/resources/static/**/*.gz app/core/src/main/resources/static/**/*.br +app/core/src/main/resources/static/css/cookieconsent.css +app/core/src/main/resources/static/css/cookieconsentCustomisation.css +app/core/src/main/resources/static/mockServiceWorker.js +app/core/src/main/resources/static/js/thirdParty/cookieconsent.umd.js +app/core/src/main/resources/static/images/google-drive.svg # Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc. # Gradle 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/desktop.yml b/.taskfiles/desktop.yml index 27f8dafde0..555e252a4e 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -195,7 +195,11 @@ tasks: # `desktop:build` run `jlink:clean` first to force a fresh build. - cmd: chmod -R u+w runtime/jre platforms: [linux, darwin] - - cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }" + # Single-quoted so Task's shell leaves `$_` and `$false` alone. Double + # quotes let it expand them as its own variables, and since neither is + # set the command PowerShell actually received was + # `ForEach-Object { .IsReadOnly = }`, which fails on every file. + - cmd: powershell -NoProfile -Command 'Get-ChildItem -Recurse -File runtime/jre | ForEach-Object { $_.IsReadOnly = $false }' platforms: [windows] status: - test -f runtime/jre/release diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 245933687c..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)" @@ -457,8 +521,17 @@ tasks: test:editor: desc: "Run editor tests" deps: [prepare] + vars: + COVERAGE: '{{.COVERAGE | default .CI | default "false"}}' cmds: - - npx vitest run --root editor + - > + npx vitest run --root editor + {{if eq .COVERAGE "true"}}--coverage + --coverage.provider=v8 + --coverage.reporter=text-summary + --coverage.reporter=json-summary + --coverage.reporter=html + --coverage.reportsDirectory=./coverage{{end}} test:watch: desc: "Run tests in watch mode" @@ -468,24 +541,9 @@ tasks: test:coverage: desc: "Run tests with coverage (one-shot; CI-friendly)." - deps: [prepare] cmds: - # `vitest run` makes this CI-safe (the bare `vitest` form enters watch - # mode). Explicit reporter list because v8 + json-summary is what the - # coverage-summary.py helper consumes; html/text are kept for humans. - # - # reportsDirectory is pinned to ./coverage relative to vitest's root - # (--root editor), so output lands at frontend/editor/coverage/. The - # CI upload step reads from that path. An earlier attempt with - # `./editor/coverage` double-nested into frontend/editor/editor/coverage; - # pinning future-proofs against vitest changing the default. - - > - npx vitest run --root editor --coverage - --coverage.provider=v8 - --coverage.reporter=text-summary - --coverage.reporter=json-summary - --coverage.reporter=html - --coverage.reportsDirectory=./coverage + - task: test:editor + vars: { COVERAGE: "true" } # ============================================================ # Code Generation diff --git a/.vscode/settings.json b/.vscode/settings.json index 39e4fec21e..ad5c38b976 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -73,40 +73,52 @@ "stirling", ], "java.project.resourceFilters": [ + ".cache/", + ".claude/", ".devcontainer/", ".git/", + ".git-blame-ignore-revs", + ".gitattributes", ".github/", + ".gitignore", ".gradle/", + ".pre-commit-config.yaml", + ".task/", + ".taskfiles/", ".venv/", ".venv*/", ".vscode/", - "bin/", - "app/core/bin/", + "app/.gitignore", + "app/build/", + "app/common/.gitignore", "app/common/bin/", - "app/proprietary/bin/", - "build/", - "app/core/build/", "app/common/build/", - "app/proprietary/build/", - "configs/", + "app/core/.gitignore", + "app/core/bin/", "app/core/configs/", - "customFiles/", "app/core/customFiles/", + "app/core/LOCAL_APPDATA_FONTCONFIG_CACHE/", + "app/core/logs/", + "app/core/pipeline/", + "app/core/storage/", + "app/proprietary/.gitignore", + "app/proprietary/bin/", + "app/proprietary/storage/", + "app/saas/.gitignore", + "app/saas/bin/", + "app/saas/build/", + "bin/", + "build/", + "devGuide/", + "devTools/", + "docker/", "docs/", - "exampleYmlFiles", + "engine/", + "frontend/", "gradle/", "images/", - "logs/", - "pipeline/", "scripts/", "testings/", - ".git-blame-ignore-revs", - ".gitattributes", - ".gitignore", - "app/core/.gitignore", - "app/common/.gitignore", - "app/proprietary/.gitignore", - ".pre-commit-config.yaml", ], // Enables signature help in Java. "java.signatureHelp.enabled": true, @@ -135,13 +147,57 @@ "html.format.indentHandlebars": true, "html.format.preserveNewLines": true, "html.format.maxPreserveNewLines": 2, - "stylelint.configFile": "devTools/.stylelintrc.json", + "stylelint.configFile": "${workspaceFolder}/devTools/.stylelintrc.json", + "css.lint.unknownAtRules": "ignore", + "scss.lint.unknownAtRules": "ignore", + "less.lint.unknownAtRules": "ignore", "java.project.sourcePaths": [ "app/core/src/main/java", "app/common/src/main/java", "app/proprietary/src/main/java" ], + "[javascript]": { + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + } + }, + "[javascriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + } + }, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "vscode.typescript-language-features", + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + } + }, + "[typescriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + } + }, + "oxc.enable.oxlint": true, + "oxc.enable.oxfmt": false, + "oxc.configPath": "frontend/oxlint.config.ts", + "oxc.requireConfig": true, + "oxc.lint.run": "onType", + "oxc.fixKind": "safe_fix", + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml", + // Keep TOML formatting compatible with .editorconfig and the pre-commit + // locale sorter. Key ordering itself is handled by task pre-commit:toml-sort. + "editor.insertSpaces": true, + "editor.tabSize": 4, + "editor.rulers": [127], + "evenBetterToml.formatter.alignEntries": false, + "evenBetterToml.formatter.alignComments": false, + "evenBetterToml.formatter.indentString": " ", + "evenBetterToml.formatter.columnWidth": 127, + "evenBetterToml.formatter.reorderKeys": false, + "evenBetterToml.formatter.reorderArrays": false, + "evenBetterToml.formatter.reorderInlineTables": false, + "evenBetterToml.formatter.trailingNewline": true, + "evenBetterToml.formatter.crlf": false } } 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/configuration/RuntimePathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java index 82921c0ba2..5be836f82a 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java @@ -73,7 +73,7 @@ public class RuntimePathConfig { defaultWatchedFolders, watchedFoldersDirs, pipeline != null ? pipeline.getWatchedFoldersDir() : null); - this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.get(0); + this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.getFirst(); this.pipelineFinishedFoldersPath = resolvePath( defaultFinishedFolders, 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 09ec1298b6..b6621934ce 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 @@ -1258,6 +1258,8 @@ public class ApplicationProperties { // 'https://app.example.com'). If not set, falls back to backendUrl. private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature + private boolean enableMobileSignature = + true; // Enable drawing signatures on a phone via QR code private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings(); private ServerCertificate serverCertificate = new ServerCertificate(); @@ -1316,6 +1318,13 @@ public class ApplicationProperties { @Data public static class Encryption { private boolean enabled = false; + + /** + * Emit an audit event for every decrypt of an encrypted blob. Compliance reviewers + * (HIPAA) expect read audit, so it defaults on; busy multi-user installs can disable. + * Denied decrypts and key lifecycle events are always audited regardless. + */ + private boolean auditReads = true; } @Data @@ -1519,7 +1528,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(); @@ -1530,10 +1539,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/model/oauth2/Provider.java b/app/common/src/main/java/stirling/software/common/model/oauth2/Provider.java index 185956fbb5..7a431baf26 100644 --- a/app/common/src/main/java/stirling/software/common/model/oauth2/Provider.java +++ b/app/common/src/main/java/stirling/software/common/model/oauth2/Provider.java @@ -60,54 +60,40 @@ public class Provider { } private UsernameAttribute validateUsernameAttribute(UsernameAttribute usernameAttribute) { - switch (name) { - case "google" -> { - return validateGoogleUsernameAttribute(usernameAttribute); - } - case "github" -> { - return validateGitHubUsernameAttribute(usernameAttribute); - } - case "keycloak" -> { - return validateKeycloakUsernameAttribute(usernameAttribute); - } - default -> { - return usernameAttribute; - } - } + return switch (name) { + case "google" -> validateGoogleUsernameAttribute(usernameAttribute); + case "github" -> validateGitHubUsernameAttribute(usernameAttribute); + case "keycloak" -> validateKeycloakUsernameAttribute(usernameAttribute); + default -> usernameAttribute; + }; } private UsernameAttribute validateKeycloakUsernameAttribute( UsernameAttribute usernameAttribute) { - switch (usernameAttribute) { - case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME, PREFERRED_USERNAME -> { - return usernameAttribute; - } + return switch (usernameAttribute) { + case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME, PREFERRED_USERNAME -> usernameAttribute; default -> throw new UnsupportedClaimException( String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName)); - } + }; } private UsernameAttribute validateGoogleUsernameAttribute(UsernameAttribute usernameAttribute) { - switch (usernameAttribute) { - case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> { - return usernameAttribute; - } + return switch (usernameAttribute) { + case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> usernameAttribute; default -> throw new UnsupportedClaimException( String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName)); - } + }; } private UsernameAttribute validateGitHubUsernameAttribute(UsernameAttribute usernameAttribute) { - switch (usernameAttribute) { - case LOGIN, EMAIL, NAME -> { - return usernameAttribute; - } + return switch (usernameAttribute) { + case LOGIN, EMAIL, NAME -> usernameAttribute; default -> throw new UnsupportedClaimException( String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName)); - } + }; } @Override diff --git a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java index 73f2d7f5ad..a9932582f2 100644 --- a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java +++ b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java @@ -361,8 +361,8 @@ public class PdfMarkdownConverter { if (xs.isEmpty()) { return List.of(lines); } - float minX = xs.get(0); - float maxX = xs.get(xs.size() - 1); + float minX = xs.getFirst(); + float maxX = xs.getLast(); float splitAt = (minX + maxX) / 2f; float biggestGap = 0; for (int i = 1; i < xs.size(); i++) { @@ -492,7 +492,7 @@ public class PdfMarkdownConverter { List> anchorGroups = new ArrayList<>(); List current = new ArrayList<>(); - current.add(cands.get(0)); + current.add(cands.getFirst()); for (int i = 1; i < cands.size(); i++) { float gap = cands.get(i - 1).y - cands.get(i).y; if (gap > splitThreshold) { @@ -513,8 +513,8 @@ public class PdfMarkdownConverter { if (anchors.size() < 2) { continue; } - float top = anchors.get(0).y; - float bottom = anchors.get(anchors.size() - 1).y; + float top = anchors.getFirst().y; + float bottom = anchors.getLast().y; // Each anchor seeds a row; absorb wrapped continuation lines (non-anchors within the // run's vertical span, with a little slack below the last row) into the anchor above. @@ -674,8 +674,8 @@ public class PdfMarkdownConverter { float minGutter = Math.max(10f, charWidth * 2.5f); List merged = new ArrayList<>(); for (float[] band : columns) { - if (!merged.isEmpty() && band[0] - merged.get(merged.size() - 1)[1] < minGutter) { - merged.get(merged.size() - 1)[1] = band[1]; + if (!merged.isEmpty() && band[0] - merged.getLast()[1] < minGutter) { + merged.getLast()[1] = band[1]; } else { merged.add(new float[] {band[0], band[1]}); } @@ -734,7 +734,7 @@ public class PdfMarkdownConverter { } } StringBuilder sb = new StringBuilder(); - sb.append(buildGfmRow(rows.get(0), widths, cols)).append('\n'); + sb.append(buildGfmRow(rows.getFirst(), widths, cols)).append('\n'); sb.append('|'); for (int c = 0; c < cols; c++) { sb.append('-').append("-".repeat(widths[c])).append('-').append('|'); @@ -910,8 +910,8 @@ public class PdfMarkdownConverter { } // Only merge a sentence continuation between two text paragraphs, never into/out of a // table. - if (!(output.get(output.size() - 1) instanceof String last) - || !(pageItems.get(0) instanceof String first)) { + if (!(output.getLast() instanceof String last) + || !(pageItems.getFirst() instanceof String first)) { return; } if (!first.isEmpty() @@ -932,13 +932,13 @@ public class PdfMarkdownConverter { for (Object e : elements) { if (e instanceof TableBlock tb && !out.isEmpty() - && out.get(out.size() - 1) instanceof TableBlock prev + && out.getLast() instanceof TableBlock prev && columnsMatch(flatten(prev.rows()), flatten(tb.rows()))) { List> merged = new ArrayList<>(prev.rows()); List> tail = tb.rows(); if (!tail.isEmpty() && !prev.rows().isEmpty() - && rowText(tail.get(0)).equals(rowText(prev.rows().get(0)))) { + && rowText(tail.getFirst()).equals(rowText(prev.rows().getFirst()))) { tail = tail.subList(1, tail.size()); } merged.addAll(tail); @@ -971,7 +971,7 @@ public class PdfMarkdownConverter { continue; } if (e instanceof TableBlock tb && !tb.rows().isEmpty()) { - return rowText(tb.rows().get(0)); + return rowText(tb.rows().getFirst()); } return 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/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index dd49ab3f51..aa9bcaa640 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -157,7 +157,7 @@ public class InternalApiClient { boolean hasFilePart = body.values().stream() .flatMap(java.util.List::stream) - .anyMatch(v -> v instanceof Resource); + .anyMatch(Resource.class::isInstance); if (isAiTool && !hasFilePart) { headers.setContentType(MediaType.MULTIPART_FORM_DATA); } 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/ChecksumUtils.java b/app/common/src/main/java/stirling/software/common/util/ChecksumUtils.java index ab39ca5da8..16cd26add0 100644 --- a/app/common/src/main/java/stirling/software/common/util/ChecksumUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/ChecksumUtils.java @@ -140,20 +140,25 @@ public class ChecksumUtils { for (String algorithm : algorithms) { String key = algorithm; // keep original key for output - switch (algorithm.toUpperCase(Locale.ROOT)) { - case "CRC32": - checksums.put(key, new CRC32()); - break; - case "ADLER32": - checksums.put(key, new Adler32()); - break; - default: - try { - // For MessageDigest, pass the original name (case-insensitive per JCA) - digests.put(key, MessageDigest.getInstance(algorithm)); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("Unsupported algorithm: " + algorithm, e); - } + Object digestOrChecksum = + switch (algorithm.toUpperCase(Locale.ROOT)) { + case "CRC32" -> new CRC32(); + case "ADLER32" -> new Adler32(); + default -> { + try { + // For MessageDigest, pass the original name (case-insensitive + // per JCA) + yield MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException( + "Unsupported algorithm: " + algorithm, e); + } + } + }; + if (digestOrChecksum instanceof Checksum checksum) { + checksums.put(key, checksum); + } else { + digests.put(key, (MessageDigest) digestOrChecksum); } } diff --git a/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java b/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java index 0ec94fd51e..4aa49bd26f 100644 --- a/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java @@ -751,7 +751,7 @@ public class ExceptionUtils { String targetDescription; if (errorInfo.affectedPages() != null && !errorInfo.affectedPages().isEmpty()) { if (errorInfo.affectedPages().size() == 1) { - targetDescription = "page " + errorInfo.affectedPages().get(0); + targetDescription = "page " + errorInfo.affectedPages().getFirst(); } else { targetDescription = "pages " @@ -848,7 +848,7 @@ public class ExceptionUtils { } // Use the first page number, or null if none found - Integer pageNumber = affectedPages.isEmpty() ? null : affectedPages.get(0); + Integer pageNumber = affectedPages.isEmpty() ? null : affectedPages.getFirst(); return new GhostscriptErrorInfo( ErrorCode.GHOSTSCRIPT_PAGE_DRAWING, diff --git a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 3018a26fd9..1f971d8d9e 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -114,7 +114,7 @@ public enum FormFieldTypeSupport { return; } - PDAnnotationWidget widget = checkBox.getWidgets().get(0); + PDAnnotationWidget widget = checkBox.getWidgets().getFirst(); PDAppearanceCharacteristicsDictionary appearanceChars = widget.getAppearanceCharacteristics(); diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index 25cb4b5825..a1862d379c 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -88,28 +88,16 @@ public class FormUtils { * text) */ public String detectFieldType(PDField field) { - if (field instanceof PDSignatureField) { - return FIELD_TYPE_SIGNATURE; - } - if (field instanceof PDPushButton) { - return FIELD_TYPE_BUTTON; - } - if (field instanceof PDTextField) { - return FIELD_TYPE_TEXT; - } - if (field instanceof PDCheckBox) { - return FIELD_TYPE_CHECKBOX; - } - if (field instanceof PDComboBox) { - return FIELD_TYPE_COMBOBOX; - } - if (field instanceof PDListBox) { - return FIELD_TYPE_LISTBOX; - } - if (field instanceof PDRadioButton) { - return FIELD_TYPE_RADIO; - } - return FIELD_TYPE_TEXT; + return switch (field) { + case PDSignatureField ignored -> FIELD_TYPE_SIGNATURE; + case PDPushButton ignored -> FIELD_TYPE_BUTTON; + case PDTextField ignored -> FIELD_TYPE_TEXT; + case PDCheckBox ignored -> FIELD_TYPE_CHECKBOX; + case PDComboBox ignored -> FIELD_TYPE_COMBOBOX; + case PDListBox ignored -> FIELD_TYPE_LISTBOX; + case PDRadioButton ignored -> FIELD_TYPE_RADIO; + case null, default -> FIELD_TYPE_TEXT; + }; } public List extractFormFields(PDDocument document) { @@ -583,22 +571,17 @@ public class FormUtils { continue; } String type = info.type(); - Object value; - switch (type) { - case FIELD_TYPE_CHECKBOX: - value = isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE; - break; - case FIELD_TYPE_LISTBOX: - if (info.multiSelect()) { - value = new ArrayList<>(); - } else { - value = safeDefault(info.value()); - } - break; - case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE: - continue; // skip non-fillable - default: - value = safeDefault(info.value()); + Object value = + switch (type) { + case FIELD_TYPE_CHECKBOX -> + isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE; + case FIELD_TYPE_LISTBOX -> + info.multiSelect() ? new ArrayList<>() : safeDefault(info.value()); + case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE -> null; + default -> safeDefault(info.value()); + }; + if (value == null) { + continue; // skip non-fillable } record.put(info.name(), value); } @@ -949,44 +932,44 @@ public class FormUtils { if (selection == null || selection.trim().isEmpty()) return null; List filtered = filterChoiceSelections(List.of(selection), allowedOptions, fieldName); - return filtered.isEmpty() ? null : filtered.get(0); + return filtered.isEmpty() ? null : filtered.getFirst(); } private void applyValueToField(PDField field, String value, boolean strict) throws IOException { try { - if (field instanceof PDTextField textField) { - setTextValue(textField, value); - } else if (field instanceof PDCheckBox checkBox) { - LinkedHashSet candidateStates = collectCheckBoxStates(checkBox); - boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates); - try { - if (shouldCheck) { - checkBox.check(); - } else { - checkBox.unCheck(); - } - } catch (IOException checkProblem) { - log.warn( - "Failed to set checkbox state for '{}': {}", - field.getFullyQualifiedName(), - checkProblem.getMessage(), - checkProblem); - if (strict) { - throw checkProblem; + switch (field) { + case PDTextField textField -> setTextValue(textField, value); + case PDCheckBox checkBox -> { + LinkedHashSet candidateStates = collectCheckBoxStates(checkBox); + boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates); + try { + if (shouldCheck) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + } catch (IOException checkProblem) { + log.warn( + "Failed to set checkbox state for '{}': {}", + field.getFullyQualifiedName(), + checkProblem.getMessage(), + checkProblem); + if (strict) { + throw checkProblem; + } } } - } else if (field instanceof PDRadioButton radioButton) { - if (value != null && !value.isBlank()) { - radioButton.setValue(value); + case PDRadioButton radioButton -> { + if (value != null && !value.isBlank()) { + radioButton.setValue(value); + } } - } else if (field instanceof PDChoice choiceField) { - applyChoiceValue(choiceField, value); - } else if (field instanceof PDPushButton) { - log.debug("Ignore Push button"); - } else if (field instanceof PDSignatureField) { - log.debug("Skipping signature field '{}'", field.getFullyQualifiedName()); - } else { - field.setValue(value != null ? value : ""); + case PDChoice choiceField -> applyChoiceValue(choiceField, value); + case PDPushButton ignored -> log.debug("Ignore Push button"); + case PDSignatureField ignored -> + log.debug("Skipping signature field '{}'", field.getFullyQualifiedName()); + case null -> log.warn("Attempted to set value on null field"); + default -> field.setValue(value != null ? value : ""); } } catch (Exception e) { log.warn( @@ -1306,37 +1289,42 @@ public class FormUtils { List resolveOptions(PDTerminalField field) { try { - if (field instanceof PDChoice choice) { - LinkedHashSet allowed = new LinkedHashSet<>(); - List exportValues = choice.getOptionsExportValues(); - List displayValues = choice.getOptionsDisplayValues(); + return switch (field) { + case PDChoice choice -> { + LinkedHashSet allowed = new LinkedHashSet<>(); + List exportValues = choice.getOptionsExportValues(); + List displayValues = choice.getOptionsDisplayValues(); - if (exportValues != null) { - exportValues.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .forEach(allowed::add); + if (exportValues != null) { + exportValues.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .forEach(allowed::add); + } + if (displayValues != null) { + displayValues.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .forEach(allowed::add); + } + yield new ArrayList<>(allowed); } - if (displayValues != null) { - displayValues.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .forEach(allowed::add); + case PDRadioButton radio -> { + List exports = radio.getExportValues(); + yield exports != null && !exports.isEmpty() + ? new ArrayList<>(exports) + : Collections.emptyList(); } - return new ArrayList<>(allowed); - } else if (field instanceof PDRadioButton radio) { - List exports = radio.getExportValues(); - if (exports != null && !exports.isEmpty()) { - return new ArrayList<>(exports); + case PDCheckBox checkBox -> { + List exports = checkBox.getExportValues(); + yield exports != null && !exports.isEmpty() + ? new ArrayList<>(exports) + : Collections.emptyList(); } - } else if (field instanceof PDCheckBox checkBox) { - List exports = checkBox.getExportValues(); - if (exports != null && !exports.isEmpty()) { - return new ArrayList<>(exports); - } - } + case null, default -> Collections.emptyList(); + }; } catch (Exception e) { log.debug( "Failed to resolve options for field '{}': {}", @@ -1465,7 +1453,7 @@ public class FormUtils { // Only check options for choice-type fields (combobox, listbox, radio) if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { - String optionCandidate = cleanLabel(options.get(0)); + String optionCandidate = cleanLabel(options.getFirst()); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } @@ -1557,7 +1545,7 @@ public class FormUtils { continue; } - PDAnnotationWidget widget = widgets.get(0); + PDAnnotationWidget widget = widgets.getFirst(); PDRectangle originalRectangle = cloneRectangle(widget.getRectangle()); PDPage page = resolveWidgetPage(document, widget, null); if (page == null || originalRectangle == null) { @@ -2446,19 +2434,19 @@ public class FormUtils { private static int firstWidgetPageIndex(FormFieldWithCoordinates f) { return (f.getWidgets() != null && !f.getWidgets().isEmpty()) - ? f.getWidgets().get(0).getPageIndex() + ? f.getWidgets().getFirst().getPageIndex() : -1; } private static float firstWidgetY(FormFieldWithCoordinates f) { return (f.getWidgets() != null && !f.getWidgets().isEmpty()) - ? f.getWidgets().get(0).getY() + ? f.getWidgets().getFirst().getY() : 0; } private static float firstWidgetX(FormFieldWithCoordinates f) { return (f.getWidgets() != null && !f.getWidgets().isEmpty()) - ? f.getWidgets().get(0).getX() + ? f.getWidgets().getFirst().getX() : 0; } diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 52f79f733a..1d7320ca84 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -12,6 +12,7 @@ import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -54,6 +55,10 @@ public class GeneralUtils { private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; private final String PYTHON_SCRIPTS_DIR = "python"; + + // Extracted once per run. Rewriting a script while another request is exec-ing it + // races wherever rename is not atomic, such as 9p or NFS bind mounts. + private final Map EXTRACTED_SCRIPTS = new ConcurrentHashMap<>(); private final RegexPatternUtils patternCache = RegexPatternUtils.getInstance(); // Valid size units used for convertSizeToBytes validation and parsing private final Set VALID_SIZE_UNITS = Set.of("B", "KB", "MB", "GB", "TB"); @@ -1025,17 +1030,30 @@ public class GeneralUtils { } Path scriptsDir = Path.of(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR); - Files.createDirectories(scriptsDir); - Path target = scriptsDir.resolve(scriptName); - ClassPathResource res = - new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName); - if (!res.exists()) { - log.error("Resource not found: {}", res.getPath()); - throw new IOException("Resource not found: " + res.getPath()); + + Path cached = EXTRACTED_SCRIPTS.get(scriptName); + if (cached != null && Files.isRegularFile(cached)) { + return cached; + } + + synchronized (EXTRACTED_SCRIPTS) { + cached = EXTRACTED_SCRIPTS.get(scriptName); + if (cached != null && Files.isRegularFile(cached)) { + return cached; + } + + Files.createDirectories(scriptsDir); + ClassPathResource res = + new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + EXTRACTED_SCRIPTS.put(scriptName, target); + return target; } - copyResourceToFile(res, target); - return target; } /* diff --git a/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java b/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java index 979ad25e99..030751f3e3 100644 --- a/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java @@ -26,29 +26,27 @@ import lombok.extern.slf4j.Slf4j; public class ImageProcessingUtils { static BufferedImage convertColorType(BufferedImage sourceImage, String colorType) { - BufferedImage convertedImage; - switch (colorType) { - case "greyscale": - convertedImage = + return switch (colorType) { + case "greyscale" -> { + BufferedImage convertedImage = new BufferedImage( sourceImage.getWidth(), sourceImage.getHeight(), BufferedImage.TYPE_BYTE_GRAY); convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null); - break; - case "blackwhite": - convertedImage = + yield convertedImage; + } + case "blackwhite" -> { + BufferedImage convertedImage = new BufferedImage( sourceImage.getWidth(), sourceImage.getHeight(), BufferedImage.TYPE_BYTE_BINARY); convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null); - break; - default: // full color - convertedImage = sourceImage; - break; - } - return convertedImage; + yield convertedImage; + } + default -> sourceImage; + }; } public static byte[] getImageData(BufferedImage image) { diff --git a/app/common/src/main/java/stirling/software/common/util/PDFToFile.java b/app/common/src/main/java/stirling/software/common/util/PDFToFile.java index feac968c55..ce164e9db2 100644 --- a/app/common/src/main/java/stirling/software/common/util/PDFToFile.java +++ b/app/common/src/main/java/stirling/software/common/util/PDFToFile.java @@ -330,7 +330,7 @@ public class PDFToFile { if (outputFiles.size() == 1) { // Return single output file - File outputFile = outputFiles.get(0); + File outputFile = outputFiles.getFirst(); if ("txt:Text".equals(outputFormat)) { outputFormat = "txt"; } diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index eb36711c0f..1a8786bf1d 100644 --- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -307,7 +307,7 @@ public class ProcessExecutor { boolean isQpdf = commandToRun != null && !commandToRun.isEmpty() - && commandToRun.get(0).contains("qpdf"); + && commandToRun.getFirst().contains("qpdf"); if (!outputLines.isEmpty()) { String outputMessage = String.join("\n", outputLines); @@ -370,7 +370,7 @@ public class ProcessExecutor { } // Check if this is a UNO conversion by looking for unoconvert executable - String executable = command.get(0); + String executable = command.getFirst(); if (executable != null) { // Extract basename from path for matching String basename = executable; @@ -504,7 +504,7 @@ public class ProcessExecutor { } // Validate executable (first argument) - String executable = command.get(0); + String executable = command.getFirst(); if (executable == null || executable.isBlank()) { throw new IllegalArgumentException("Command executable must not be empty"); } diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index a653ae6c0b..f85880df5d 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -56,8 +56,10 @@ public class RequestUriUtils { return true; } - // Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed) - if (normalizedUri.startsWith("/mobile-scanner")) { + // Mobile pages reached by scanning a QR code (peer-to-peer, no backend auth + // needed): /mobile-scanner uploads photos, /mobile-sign draws a signature. + if (normalizedUri.startsWith("/mobile-scanner") + || normalizedUri.startsWith("/mobile-sign")) { return true; } diff --git a/app/common/src/main/java/stirling/software/common/util/YamlHelper.java b/app/common/src/main/java/stirling/software/common/util/YamlHelper.java index 8fb6896f67..2715f50dfb 100644 --- a/app/common/src/main/java/stirling/software/common/util/YamlHelper.java +++ b/app/common/src/main/java/stirling/software/common/util/YamlHelper.java @@ -114,7 +114,7 @@ public class YamlHelper { for (NodeTuple tuple : mappingNode.getValue()) { ScalarNode keyNode = (tuple.getKeyNode() instanceof ScalarNode sk) ? sk : null; - if (keyNode == null || !keyNode.getValue().equals(keys.get(0))) { + if (keyNode == null || !keyNode.getValue().equals(keys.getFirst())) { updatedTuples.add(tuple); continue; } diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java index 861efa8509..9bf78b6af3 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java @@ -2,6 +2,7 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +66,24 @@ class FileStorageOwnershipTest { assertThrows(SecurityException.class, () -> fs.deleteFile(id)); } + @Test + void systemDeleteOfAnotherUsersFile_allowed_soJobCleanupDoesNotOrphanIt(@TempDir Path tempDir) + throws IOException { + // An admin sweeping every user's finished jobs is not the owner of their files. The + // ownership-checked delete throws there, which used to drop the job record and leave the + // files stranded on disk with nothing left able to reference them. + AtomicReference user = new AtomicReference<>("alice"); + FileStorage fs = newStorageWithCurrentUser(tempDir, user); + String id = fs.storeBytes("alice's file".getBytes(), "x.bin"); + user.set("admin"); + + assertThrows(SecurityException.class, () -> fs.deleteFile(id)); + assertTrue(fs.deleteFileAsSystem(id), "System delete must not be blocked by ownership"); + + user.set("alice"); + assertThrows(IOException.class, () -> fs.retrieveBytes(id), "File should really be gone"); + } + @Test void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir) throws IOException { diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerCleanupTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerCleanupTest.java new file mode 100644 index 0000000000..050004dc1f --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerCleanupTest.java @@ -0,0 +1,263 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.ResultFile; + +/** + * Covers the on-demand cleanup path and the input-copy tracking that makes it complete. An async + * submit persists a copy of the upload as well as its results; before both were tracked, only the + * results were ever deleted and the input copy stayed on disk indefinitely. + */ +class TaskManagerCleanupTest { + + @Mock private FileStorage fileStorage; + @Mock private JobStore jobStore; + @Mock private ClusterBackplane clusterBackplane; + + @InjectMocks private TaskManager taskManager; + + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node"); + lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true); + lenient().when(fileStorage.deleteFileAsSystem(anyString())).thenReturn(true); + ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); + ReflectionTestUtils.setField(taskManager, "pendingJobExpiryMinutes", 1440); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @SuppressWarnings("unchecked") + private Map jobResults() { + return (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); + } + + /** Complete a job with a single result file, as an async file-producing job would. */ + private void completeWithFile(String jobId, String fileId) { + taskManager.setFileResult(jobId, fileId, "out.pdf", MediaType.APPLICATION_PDF_VALUE); + taskManager.setComplete(jobId); + } + + @Test + void forcedCleanupRemovesFinishedJobsRegardlessOfAge() { + String jobId = "fresh-job"; + taskManager.createTask(jobId); + completeWithFile(jobId, "result-file"); + + // The scheduled sweep leaves it alone: it completed well inside the retention window. + taskManager.cleanupOldJobs(); + assertTrue(jobResults().containsKey(jobId), "Scheduled cleanup should respect the expiry"); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.jobsRemoved()); + assertEquals(1, summary.filesDeleted()); + assertEquals(0, summary.jobsRetained()); + assertFalse(jobResults().containsKey(jobId)); + verify(fileStorage).deleteFileAsSystem("result-file"); + verify(jobStore).delete(jobId); + } + + @Test + void forcedCleanupDeletesThePersistedInputCopy() { + String jobId = "job-with-input"; + taskManager.createTask(jobId); + assertTrue(taskManager.registerInputFile(jobId, "input-file")); + completeWithFile(jobId, "result-file"); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.jobsRemoved()); + assertEquals(2, summary.filesDeleted(), "Both the result and the input copy must go"); + verify(fileStorage).deleteFileAsSystem("result-file"); + verify(fileStorage).deleteFileAsSystem("input-file"); + } + + @Test + void scheduledCleanupAlsoDeletesThePersistedInputCopy() { + String jobId = "expired-job"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + completeWithFile(jobId, "result-file"); + + JobResult result = taskManager.getJobResult(jobId); + ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(1)); + + taskManager.cleanupOldJobs(); + + assertFalse(jobResults().containsKey(jobId)); + verify(fileStorage).deleteFileAsSystem("result-file"); + verify(fileStorage).deleteFileAsSystem("input-file"); + } + + @Test + void forcedCleanupLeavesRunningJobsAlone() { + String running = "running-job"; + taskManager.createTask(running); + taskManager.registerInputFile(running, "in-flight-input"); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(0, summary.jobsRemoved()); + assertEquals(0, summary.filesDeleted()); + assertEquals(1, summary.jobsRetained()); + assertTrue(jobResults().containsKey(running)); + // Deleting a running job's input mid-flight would break it. + verify(fileStorage, never()).deleteFileAsSystem(anyString()); + } + + @Test + void forcedCleanupSkipsJobsTheFilterRejects() { + taskManager.createTask("alice:job"); + completeWithFile("alice:job", "alice-file"); + taskManager.createTask("bob:job"); + completeWithFile("bob:job", "bob-file"); + + TaskManager.CleanupSummary summary = + taskManager.cleanupFinishedJobsNow(id -> id.startsWith("alice:")); + + assertEquals(1, summary.jobsRemoved()); + assertEquals(1, summary.jobsRetained()); + assertFalse(jobResults().containsKey("alice:job")); + assertTrue(jobResults().containsKey("bob:job"), "Another user's job must survive"); + verify(fileStorage).deleteFileAsSystem("alice-file"); + verify(fileStorage, never()).deleteFileAsSystem("bob-file"); + } + + @Test + void forcedCleanupIsIdempotent() { + String jobId = "job-to-clean"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + completeWithFile(jobId, "result-file"); + + taskManager.cleanupFinishedJobsNow(id -> true); + TaskManager.CleanupSummary second = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(0, second.jobsRemoved()); + assertEquals(0, second.filesDeleted()); + } + + @Test + void forcedCleanupRunsEvenWhenTheBackplaneOwnsScheduledExpiry() { + // The scheduled sweep defers to the backplane TTL in cluster mode, but an explicit + // request to release this node's storage still has to do something. + when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false); + String jobId = "clustered-job"; + taskManager.createTask(jobId); + completeWithFile(jobId, "result-file"); + + taskManager.cleanupOldJobs(); + assertTrue(jobResults().containsKey(jobId)); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.jobsRemoved()); + assertFalse(jobResults().containsKey(jobId)); + } + + @Test + void cleanupCountsOnlyFilesThatWereActuallyDeleted() { + // A file already gone (a retry deleted it, say) must not be counted as freed. + String jobId = "partially-cleaned"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "already-gone"); + completeWithFile(jobId, "result-file"); + when(fileStorage.deleteFileAsSystem("already-gone")).thenReturn(false); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(1, summary.filesDeleted()); + } + + @Test + void cleanupSurvivesAFileStorageFailure() { + String jobId = "job-with-unhappy-storage"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + completeWithFile(jobId, "result-file"); + when(fileStorage.deleteFileAsSystem("result-file")) + .thenThrow(new RuntimeException("disk on fire")); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + // The job is still released and the remaining file still deleted. + assertEquals(1, summary.jobsRemoved()); + assertEquals(1, summary.filesDeleted()); + assertFalse(jobResults().containsKey(jobId)); + verify(fileStorage).deleteFileAsSystem("input-file"); + } + + @Test + void registerInputFileRejectsAnUnknownJob() { + assertFalse(taskManager.registerInputFile("no-such-job", "input-file")); + } + + @Test + void registerInputFileIgnoresDuplicatesAndBlanks() { + String jobId = "dedupe-job"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + taskManager.registerInputFile(jobId, "input-file"); + taskManager.registerInputFile(jobId, " "); + taskManager.registerInputFile(jobId, null); + + List inputFileIds = taskManager.getJobResult(jobId).getInputFileIds(); + + assertEquals(List.of("input-file"), inputFileIds); + } + + @Test + void multiFileResultsAndTheInputCopyAreAllDeleted() { + String jobId = "split-job"; + taskManager.createTask(jobId); + taskManager.registerInputFile(jobId, "input-file"); + JobResult result = taskManager.getJobResult(jobId); + result.completeWithFiles( + List.of( + ResultFile.builder() + .fileId("page-1") + .fileName("1.pdf") + .contentType(MediaType.APPLICATION_PDF_VALUE) + .fileSize(10L) + .build(), + ResultFile.builder() + .fileId("page-2") + .fileName("2.pdf") + .contentType(MediaType.APPLICATION_PDF_VALUE) + .fileSize(10L) + .build())); + + TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true); + + assertEquals(3, summary.filesDeleted()); + verify(fileStorage).deleteFileAsSystem("page-1"); + verify(fileStorage).deleteFileAsSystem("page-2"); + verify(fileStorage).deleteFileAsSystem("input-file"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java index 0a02039ab3..1871e31a3f 100644 --- a/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java @@ -290,7 +290,8 @@ class TaskManagerMoreTest { ReflectionTestUtils.setField(job, "complete", true); ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2)); - when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked")); + when(fileStorage.deleteFileAsSystem("doomed")) + .thenThrow(new RuntimeException("locked")); // Must not propagate; the job is still removed afterwards. taskManager.cleanupOldJobs(); diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java index 9d880d3451..cb423d977c 100644 --- a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -258,7 +258,7 @@ class TaskManagerTest { .build(); ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile)); - when(fileStorage.deleteFile("file-id")).thenReturn(true); + when(fileStorage.deleteFileAsSystem("file-id")).thenReturn(true); // Obtain access to the private jobResults map Map jobResultsMap = @@ -281,7 +281,7 @@ class TaskManagerTest { assertFalse(jobResultsMap.containsKey(oldJobId)); assertTrue(jobResultsMap.containsKey(recentJobId)); assertTrue(jobResultsMap.containsKey(activeJobId)); - verify(fileStorage).deleteFile("file-id"); + verify(fileStorage).deleteFileAsSystem("file-id"); } @Test @@ -308,7 +308,7 @@ class TaskManagerTest { // Assert: nothing was removed locally, and no jobStore.delete was issued. assertTrue(jobResultsMap.containsKey(oldJobId)); verify(jobStore, never()).delete(anyString()); - verify(fileStorage, never()).deleteFile(anyString()); + verify(fileStorage, never()).deleteFileAsSystem(anyString()); } @Test diff --git a/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java b/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java index 4ec7164514..b3491f7183 100644 --- a/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java +++ b/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java @@ -721,7 +721,7 @@ class PDFToFileTest { .thenAnswer( invocation -> { List args = invocation.getArgument(0); - String outputPath = args.get(args.size() - 1); + String outputPath = args.getLast(); Files.write(Path.of(outputPath), "Fake DOCX content".getBytes()); return mockExecutorResult; }); diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 1912f3808b..0e399c1fae 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -73,6 +73,13 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner")); } + @Test + void testIsStaticResource_mobileSignPath() { + // The phone-side signature drawing page, reached from the Sign tool QR code. + assertTrue(RequestUriUtils.isStaticResource("/mobile-sign")); + assertTrue(RequestUriUtils.isStaticResource("/app", "/app/mobile-sign")); + } + @Test void testIsStaticResource_portalShell() { // The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable. diff --git a/app/core/build.gradle b/app/core/build.gradle index 5e90672f75..76828445a3 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -312,8 +312,9 @@ tasks.register('cleanFrontendAssets', Delete) { delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) } // Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are // copied from the frontend build. Remove stale ones so renamed/removed tools don't linger. - // api-landing.html and mobile-upload.html are real backend source files, not generated artifacts. - delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html']) + // api-landing.html, mobile-upload.html and mobile-sign.html are real backend source files, + // not generated artifacts. + delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html', 'mobile-sign.html']) // Nested prerendered route pages (e.g. settings/people.html) delete new File(resourcesStaticDir, 'settings') } @@ -330,11 +331,27 @@ tasks.register('copyApiLandingPage', Copy) { } } +tasks.register('copyBackendOnlySamples', Copy) { + group = 'frontend' + description = 'Copy frontend sample files for backend-only mode' + from(new File(frontendEditorDir, 'public/samples')) + into(new File(resourcesStaticDir, 'samples')) + dependsOn cleanFrontendAssets + onlyIf { !buildWithFrontend } +} + // Ensure copyFrontendAssets runs after spotless tasks tasks.named('copyFrontendAssets').configure { mustRunAfter tasks.matching { it.name.startsWith('spotless') } } +// Cleanup removes frontend-generated resources that are also visible to the resource/formatting +// task graph. Keep all Spotless tasks ahead of cleanup so they never snapshot a path that has +// just been removed. +tasks.named('cleanFrontendAssets').configure { + mustRunAfter tasks.matching { it.name.startsWith('spotless') } +} + if (buildWithFrontend) { println "Editor frontend build enabled - JAR will include React frontend (mode=${frontendMode})" processResources.dependsOn copyFrontendAssets @@ -342,6 +359,7 @@ if (buildWithFrontend) { println "Frontend build disabled - JAR will be backend-only with API landing page" // When not building the UI, ensure any stale frontend assets are removed and use API landing page processResources.dependsOn copyApiLandingPage + processResources.dependsOn copyBackendOnlySamples } bootJar.dependsOn ':common:jar' diff --git a/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java index 9625f9e671..ff6d1b87bd 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java @@ -1,6 +1,5 @@ package stirling.software.SPDF.config; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.servlet.MultipartConfigFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,7 +21,11 @@ import stirling.software.SPDF.controller.web.UploadLimitService; @Slf4j public class MultipartConfiguration { - @Autowired private UploadLimitService uploadLimitService; + private final UploadLimitService uploadLimitService; + + public MultipartConfiguration(UploadLimitService uploadLimitService) { + this.uploadLimitService = uploadLimitService; + } /** * Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java index f777680223..5dbccb649d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java @@ -297,24 +297,25 @@ public class BookletImpositionController { // Apply rotation if needed (rotate about origin), then translate to keep in cell switch (rot) { - case 90: + case 90 -> { cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0)); // After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0, // r.getWidth()] cs.transform(Matrix.getTranslateInstance(0, -r.getWidth())); - break; - case 180: + } + case 180 -> { cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0)); cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight())); - break; - case 270: + } + case 270 -> { cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0)); // After 270° CCW, the content spans x in [0, r.getHeight()] and y in // [-r.getWidth(), 0] cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0)); - break; - default: + } + default -> { // 0°: no-op + } } // Reuse LayerUtility passed from caller diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index db26ca58d1..77a97b9b05 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -9,7 +9,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.List; -import java.util.regex.Pattern; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; @@ -61,8 +60,6 @@ import stirling.software.jpdfium.doc.PdfBookmarkEditor.BookmarkTree; @Slf4j @RequiredArgsConstructor public class MergeController { - - private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$"); private final CustomPDFDocumentFactory pdfDocumentFactory; private final TempFileManager tempFileManager; @@ -164,30 +161,6 @@ public class MergeController { }; } - private String[] parseClientFileIds(String clientFileIds) { - if (clientFileIds == null || clientFileIds.trim().isEmpty()) { - return new String[0]; - } - try { - String trimmed = clientFileIds.trim(); - if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - String inside = trimmed.substring(1, trimmed.length() - 1).trim(); - if (inside.isEmpty()) { - return new String[0]; - } - String[] parts = inside.split(","); - String[] result = new String[parts.length]; - for (int i = 0; i < parts.length; i++) { - result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll(""); - } - return result; - } - } catch (Exception e) { - log.warn("Failed to parse client file IDs: {}", clientFileIds, e); - } - return new String[0]; - } - private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) { PDDocumentOutline outline = new PDDocumentOutline(); mergedDocument.getDocumentCatalog().setDocumentOutline(outline); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index a3ed09fe5d..7f93fb3d64 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -125,17 +125,14 @@ public class UIDataController { pipelineConfigs.add(content); } - for (String config : pipelineConfigs) { + for (int i = 0; i < jsonFiles.size(); i++) { + String config = pipelineConfigs.get(i); Map jsonContent = objectMapper.readValue( config, new TypeReference>() {}); String name = (String) jsonContent.get("name"); if (name == null || name.isEmpty()) { - String filename = - jsonFiles - .get(pipelineConfigs.indexOf(config)) - .getFileName() - .toString(); + String filename = jsonFiles.get(i).getFileName().toString(); name = filename.substring(0, filename.lastIndexOf('.')); } Map configWithName = new HashMap<>(); @@ -301,20 +298,14 @@ public class UIDataController { } private static String getFormatFromExtension(String extension) { - switch (extension) { - case "ttf": - return "truetype"; - case "woff": - return "woff"; - case "woff2": - return "woff2"; - case "eot": - return "embedded-opentype"; - case "svg": - return "svg"; - default: - return ""; - } + return switch (extension) { + case "ttf" -> "truetype"; + case "woff" -> "woff"; + case "woff2" -> "woff2"; + case "eot" -> "embedded-opentype"; + case "svg" -> "svg"; + default -> ""; + }; } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java index 40f1c8be45..b30e8d8c4d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java @@ -200,7 +200,7 @@ public class ConvertImgPDFController { } if (webpFiles.size() == 1) { - Path webpFilePath = webpFiles.get(0); + Path webpFilePath = webpFiles.getFirst(); byte[] webpBytes = Files.readAllBytes(webpFilePath); Files.deleteIfExists(tempFile); tempFile = null; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java index 94a9ed44f2..0b1bfc8e44 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java @@ -160,7 +160,7 @@ public class ConvertSvgToPDF { String outputFilename = filenames.isEmpty() ? "combined_svgs.pdf" - : GeneralUtils.generateFilename(filenames.get(0), "_combined.pdf"); + : GeneralUtils.generateFilename(filenames.getFirst(), "_combined.pdf"); log.info("Successfully combined {} SVGs into single PDF", sanitizedSvgs.size()); @@ -216,7 +216,7 @@ public class ConvertSvgToPDF { try { if (convertedPdfs.size() == 1) { - ConvertedPdf pdf = convertedPdfs.get(0); + ConvertedPdf pdf = convertedPdfs.getFirst(); TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); try { Files.write(tempOut.getPath(), pdf.content); @@ -231,7 +231,7 @@ public class ConvertSvgToPDF { filenames.isEmpty() ? "converted_svgs.zip" : GeneralUtils.generateFilename( - filenames.get(0), "_converted_svgs.zip"); + filenames.getFirst(), "_converted_svgs.zip"); TempFile zipFile = createZipFromPdfs(convertedPdfs); return WebResponseUtils.zipFileToWebResponse(zipFile, zipFilename); } catch (IOException e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java index 1141d503aa..22dff8f996 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java @@ -85,7 +85,7 @@ public class ExtractCSVController { if (csvEntries.isEmpty()) { return ResponseEntity.noContent().build(); } else if (csvEntries.size() == 1) { - return createCsvResponse(csvEntries.get(0), baseName); + return createCsvResponse(csvEntries.getFirst(), baseName); } else { return createZipResponse(csvEntries, baseName); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java index 60333191d3..34c3da0739 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java @@ -116,7 +116,9 @@ public class AutoRenameController { mergedLineInfos.sort( Comparator.comparing((LineInfo li) -> li.fontSize).reversed()); String title = - mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text; + mergedLineInfos.isEmpty() + ? null + : mergedLineInfos.getFirst().text; return title != null ? title diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java index 39ba6e54f5..f513391d7a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java @@ -336,7 +336,7 @@ public class AutoSplitPdfController { } if (!splitDocuments.isEmpty() && !isValidQrCode) { - splitDocuments.get(splitDocuments.size() - 1).addPage(document.getPage(page)); + splitDocuments.getLast().addPage(document.getPage(page)); } else if (page == 0) { PDDocument firstDocument = new PDDocument(); firstDocument.addPage(document.getPage(page)); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index fcbfcea62e..cd2b809760 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -269,7 +269,7 @@ public class CompressController { if (references.isEmpty()) continue; // Get the first instance of this image - PDImageXObject originalImage = getOriginalImage(doc, references.get(0)); + PDImageXObject originalImage = getOriginalImage(doc, references.getFirst()); // Track original size int originalSize = (int) originalImage.getCOSObject().getLength(); @@ -1170,7 +1170,7 @@ public class CompressController { List references = entry.getValue(); if (references.isEmpty()) continue; - PDImageXObject originalImage = getOriginalImage(doc, references.get(0)); + PDImageXObject originalImage = getOriginalImage(doc, references.getFirst()); int originalSize = (int) originalImage.getCOSObject().getLength(); stats.totalOriginalBytes += originalSize; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 8044050025..36beb6610c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -194,6 +194,9 @@ public class ConfigController { configData.put( "enableMobileScanner", applicationProperties.getSystem().isEnableMobileScanner()); + configData.put( + "enableMobileSignature", + applicationProperties.getSystem().isEnableMobileSignature()); configData.put( "mobileScannerConvertToPdf", applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf()); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java index daa8f35121..816b95d604 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java @@ -214,7 +214,7 @@ public class ExtractImageScansController { } else { // Return the processed image as a response - byte[] imageBytes = processedImageBytes.get(0); + byte[] imageBytes = processedImageBytes.getFirst(); finalOutput = tempFileManager.createManagedTempFile(".png"); try (OutputStream out = Files.newOutputStream(finalOutput.getPath())) { out.write(imageBytes); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java index c04911ee57..0bfa6c0337 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java @@ -62,12 +62,17 @@ public class MobileScannerController { } /** - * Check if mobile scanner feature is enabled + * Check if any feature backed by these transfer sessions is enabled. The mobile scanner and + * mobile signature drawing share this session/upload API, so the endpoints stay available while + * either feature is on; each flag independently controls only its own UI. * * @return Error response if disabled, null if enabled */ private ResponseEntity> checkFeatureEnabled() { - if (!applicationProperties.getSystem().isEnableMobileScanner()) { + boolean anyEnabled = + applicationProperties.getSystem().isEnableMobileScanner() + || applicationProperties.getSystem().isEnableMobileSignature(); + if (!anyEnabled) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body( Map.of( @@ -275,7 +280,8 @@ public class MobileScannerController { @Parameter(description = "Filename to download", required = true) @PathVariable String filename) { - if (!applicationProperties.getSystem().isEnableMobileScanner()) { + if (!applicationProperties.getSystem().isEnableMobileScanner() + && !applicationProperties.getSystem().isEnableMobileSignature()) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index c268f48d8c..58acfda82d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -407,7 +407,7 @@ public class CertSignController { PDAcroForm acroForm = new PDAcroForm(doc); doc.getDocumentCatalog().setAcroForm(acroForm); PDSignatureField signatureField = new PDSignatureField(acroForm); - PDAnnotationWidget widget = signatureField.getWidgets().get(0); + PDAnnotationWidget widget = signatureField.getWidgets().getFirst(); List acroFormFields = acroForm.getFields(); acroForm.setSignaturesExist(true); acroForm.setAppendOnly(true); 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/RedactExecuteService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java index c43abcf666..10fceee45f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java @@ -634,7 +634,7 @@ class RedactExecuteService { PageColumnLayout layout = PageColumnLayout.fromLineBoxes(extractor.getLineBoxes(), pageWidth); if (layout.columnCount() > 1) { - float[] g = layout.gutters().get(0); + float[] g = layout.gutters().getFirst(); log.info( "[redact/execute] page {} layout: 2 cols, gutter x=[{}, {}]", pageIdx + 1, diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java index bb1e0e48d5..4b0cc6e6b0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java @@ -63,7 +63,7 @@ public class RemoveCertSignController { // Remove signature fields safely List fieldsToRemove = acroForm.getFields().stream() - .filter(field -> field instanceof PDSignatureField) + .filter(PDSignatureField.class::isInstance) .toList(); if (!fieldsToRemove.isEmpty()) { 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/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 7689d109fe..89d4bb9956 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -80,6 +80,8 @@ public class ReactRoutingController { private boolean saasLandingExists = false; private String cachedMobileUploadHtml; private boolean mobileUploadHtmlExists = false; + private String cachedMobileSignHtml; + private boolean mobileSignHtmlExists = false; @PostConstruct public void init() { @@ -103,10 +105,12 @@ public class ReactRoutingController { } // Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't - // load the React /mobile-scanner route from the local backend. Cache the self-contained - // static upload page to serve at that route in desktop mode instead. + // load the React /mobile-scanner or /mobile-sign routes from the local backend. Cache the + // self-contained static pages to serve at those routes in desktop mode instead. this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html"); this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null; + this.cachedMobileSignHtml = readStaticHtml("mobile-sign.html"); + this.mobileSignHtmlExists = this.cachedMobileSignHtml != null; // Check for external index.html first (customFiles/static/) Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html"); @@ -268,6 +272,17 @@ public class ReactRoutingController { return serveIndexHtml(request); } + @GetMapping(value = "/mobile-sign", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveMobileSign(HttpServletRequest request) { + if (isDesktopMode() && mobileSignHtmlExists) { + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(cachedMobileSignHtml); + } + return serveIndexHtml(request); + } + @GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity serveTauriAuthCallback(HttpServletRequest request) { // cachedCallbackHtml is always initialized in @PostConstruct diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/UploadLimitService.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/UploadLimitService.java index a15f14af98..7065ac4d79 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/UploadLimitService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/UploadLimitService.java @@ -2,7 +2,6 @@ package stirling.software.SPDF.controller.web; import java.util.Locale; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; @@ -13,7 +12,11 @@ import stirling.software.common.model.ApplicationProperties; @Slf4j public class UploadLimitService { - @Autowired private ApplicationProperties applicationProperties; + private final ApplicationProperties applicationProperties; + + public UploadLimitService(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } public long getUploadLimit() { String raw = 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/SPDF/service/PdfJsonConversionService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java index fea3b52fc5..bc2e49853e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java @@ -2688,7 +2688,7 @@ public class PdfJsonConversionService { // Find which page the field is on PDAnnotationWidget widget = - field.getWidgets().isEmpty() ? null : field.getWidgets().get(0); + field.getWidgets().isEmpty() ? null : field.getWidgets().getFirst(); if (widget != null) { PDPage fieldPage = widget.getPage(); if (fieldPage != null) { @@ -3164,7 +3164,7 @@ public class PdfJsonConversionService { && imageObjectNames != null && !imageObjectNames.isEmpty() && !targetTokens.isEmpty()) { - Object previous = targetTokens.get(targetTokens.size() - 1); + Object previous = targetTokens.getLast(); if (previous instanceof COSName cosName && imageObjectNames.contains(cosName.getName())) { targetTokens.remove(targetTokens.size() - 1); @@ -5246,7 +5246,7 @@ public class PdfJsonConversionService { throws IOException { if (OperatorName.DRAW_OBJECT.equals(operator.getName()) && !operands.isEmpty() - && operands.get(0) instanceof COSName name) { + && operands.getFirst() instanceof COSName name) { currentXObjectName = name; } super.processOperator(operator, operands); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageService.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageService.java index d0a0ad2dae..4ac18596f2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageService.java @@ -420,7 +420,7 @@ public class PdfJsonImageService { throws IOException { if (OperatorName.DRAW_OBJECT.equals(operator.getName()) && !operands.isEmpty() - && operands.get(0) instanceof COSName name) { + && operands.getFirst() instanceof COSName name) { currentXObjectName = name; } super.processOperator(operator, operands); 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 bab5dce539..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; @@ -137,7 +138,7 @@ public class JobController { if (result.hasFiles() && !result.hasMultipleFiles()) { try { List files = result.getAllResultFiles(); - ResultFile singleFile = files.get(0); + ResultFile singleFile = files.getFirst(); byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId()); return ResponseEntity.ok() @@ -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 b8bbfd237b..6e3d0d1dd8 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -188,6 +188,7 @@ system: backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. + enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured. mobileScannerSettings: convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is. imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true. @@ -292,26 +293,15 @@ storage: linkExpirationDays: 3 # Number of days before share links expire signing: enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA] - # ==================================================================================== - # ENCRYPTION AT REST - PRO / ENTERPRISE LICENSE REQUIRED TO ENABLE - # ==================================================================================== - # Encrypts stored files (AES-256 envelope encryption, per-team keys). The master key is - # resolved in this order: - # 1. stirling.security.fileEncryptionKey property - # 2. STIRLING_FILE_ENCRYPTION_KEY environment variable - # 3. an auto-generated configs/file-encryption.key (single-node only; cluster mode - # requires an explicitly shared key on every node) - # Generate a key with: openssl rand -base64 32 - # - # *** BACK UP THE MASTER KEY. Losing it makes every encrypted stored file *** - # *** permanently unrecoverable. Verify backups against the key fingerprint logged *** - # *** at startup. *** - # - # Enabling encrypts new writes only (existing files stay readable as plaintext). - # Disabling later only stops encrypting new writes - existing encrypted files remain - # readable as long as the key material is present. + # Encryption at rest for stored files (AES-256, per-team keys). Requires a Pro or + # Enterprise licence. Key setup, cluster requirements, the encrypt-existing migration, + # the revocation kill switch and master-key rotation are documented in + # devGuide/STORAGE_ENCRYPTION_AT_REST.md + # WARNING: back up the master key (configs/file-encryption.key by default) - losing it + # makes every encrypted stored file permanently unrecoverable. encryption: enabled: false # set to 'true' to encrypt stored files at rest + auditReads: true # audit every decrypt of an encrypted file (denied decrypts and key lifecycle events are always audited). NOTE: audit events require an Enterprise licence; encryption itself works on Pro. userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only. autoPipeline: outputFolder: "" # Output folder for processed pipeline files (leave empty for default) @@ -323,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/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index b13070e8b5..fa852846b3 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -1,5 +1,12 @@ { "dependencies": [ + { + "moduleName": "ch.obermuhlner:big-math", + "moduleUrl": "https://github.com/eobermuhlner/big-math", + "moduleVersion": "2.0.0", + "moduleLicense": "MIT License", + "moduleLicenseUrl": "https://raw.githubusercontent.com/eobermuhlner/big-math/master/LICENSE.txt" + }, { "moduleName": "ch.qos.logback:logback-classic", "moduleUrl": "http://www.qos.ch", @@ -52,7 +59,7 @@ { "moduleName": "com.drewnoakes:metadata-extractor", "moduleUrl": "https://drewnoakes.com/code/exif/", - "moduleVersion": "2.20.0", + "moduleVersion": "2.21.0", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -70,6 +77,13 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.fasterxml.jackson.core:jackson-core", + "moduleUrl": "https://github.com/FasterXML/jackson-core", + "moduleVersion": "2.22.1", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.fasterxml.jackson.core:jackson-databind", "moduleUrl": "https://github.com/FasterXML/jackson", @@ -77,6 +91,13 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.fasterxml.jackson.core:jackson-databind", + "moduleUrl": "https://github.com/FasterXML/jackson", + "moduleVersion": "2.22.1", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", "moduleUrl": "https://github.com/FasterXML/jackson-dataformats-text", @@ -84,6 +105,13 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", + "moduleUrl": "https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jdk8", + "moduleVersion": "2.21.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", "moduleUrl": "https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310", @@ -98,6 +126,13 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.fasterxml.jackson:jackson-bom", + "moduleUrl": "https://github.com/FasterXML/jackson-bom", + "moduleVersion": "2.22.1", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.fasterxml:classmate", "moduleUrl": "https://github.com/FasterXML/java-classmate", @@ -111,17 +146,10 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "com.github.bbottema:jetbrains-runtime-annotations", - "moduleUrl": "https://github.com/bbottema/jetbrains-runtime-nullability-annotations", - "moduleVersion": "1.0.2", - "moduleLicense": "The Apache Software License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "com.github.bbottema:rtf-to-html", "moduleUrl": "http:///github.com/bbottema/rtf-to-html", - "moduleVersion": "1.1.1", + "moduleVersion": "2.0.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -147,10 +175,17 @@ { "moduleName": "com.github.junrar:junrar", "moduleUrl": "https://github.com/junrar/junrar", - "moduleVersion": "7.5.10", + "moduleVersion": "8.0.0", "moduleLicense": "UnRar License", "moduleLicenseUrl": "https://github.com/junrar/junrar/blob/master/LICENSE" }, + { + "moduleName": "com.github.mwiede:jsch", + "moduleUrl": "https://github.com/mwiede/jsch", + "moduleVersion": "0.2.23", + "moduleLicense": "Revised BSD", + "moduleLicenseUrl": "https://github.com/mwiede/jsch/blob/master/LICENSE.txt" + }, { "moduleName": "com.github.stephenc.jcip:jcip-annotations", "moduleUrl": "http://stephenc.github.com/jcip-annotations", @@ -186,6 +221,13 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.google.crypto.tink:tink", + "moduleUrl": "http://github.com/tink-crypto/tink-java", + "moduleVersion": "1.23.0", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.google.errorprone:error_prone_annotations", "moduleUrl": "https://errorprone.info/error_prone_annotations", @@ -227,6 +269,20 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.google.protobuf:protobuf-java", + "moduleUrl": "https://developers.google.com/protocol-buffers/", + "moduleVersion": "4.33.6", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause" + }, + { + "moduleName": "com.google.re2j:re2j", + "moduleUrl": "http://github.com/google/re2j", + "moduleVersion": "1.2", + "moduleLicense": "Go License", + "moduleLicenseUrl": "https://golang.org/LICENSE" + }, { "moduleName": "com.google.zxing:core", "moduleUrl": "https://github.com/zxing/zxing/core", @@ -234,6 +290,13 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.googlecode.java-ipv6:java-ipv6", + "moduleUrl": "https://github.com/janvanbesien/java-ipv6/", + "moduleVersion": "0.17", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.googlecode.owasp-java-html-sanitizer:java10-shim", "moduleUrl": "https://github.com/OWASP/java-html-sanitizer", @@ -269,6 +332,41 @@ "moduleLicense": "MPL 2.0", "moduleLicenseUrl": "https://www.mozilla.org/en-US/MPL/2.0/" }, + { + "moduleName": "com.hierynomus:asn-one", + "moduleUrl": "https://github.com/hierynomus/asn-one", + "moduleVersion": "0.6.0", + "moduleLicense": "The Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "com.hierynomus:smbj", + "moduleUrl": "https://github.com/hierynomus/smbj", + "moduleVersion": "0.14.0", + "moduleLicense": "The Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "com.hubspot.immutables:immutables-exceptions", + "moduleUrl": "https://github.com/HubSpot/hubspot-immutables/tree/58628096ac99b286fe4f8bfe12aa3cff0f0589d3", + "moduleVersion": "1.9", + "moduleLicense": "The Apache License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "com.hubspot.jinjava:jinjava", + "moduleUrl": "https://github.com/HubSpot/jinjava", + "moduleVersion": "2.8.4", + "moduleLicense": "The Apache License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "com.hubspot:algebra", + "moduleUrl": "https://github.com/HubSpot/algebra/tree/5d42983fd3a26539df9ba2cbeac32a1bddce0494", + "moduleVersion": "1.5", + "moduleLicense": "The Apache License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.nimbusds:content-type", "moduleUrl": "https://connect2id.com", @@ -321,49 +419,49 @@ { "moduleName": "com.sanctionco.jmail:jmail", "moduleUrl": "https://github.com/RohanNagar/jmail", - "moduleVersion": "1.6.3", + "moduleVersion": "2.2.0", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/mit-license.php" }, { "moduleName": "com.stirling:jpdfium", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.2", + "moduleVersion": "1.0.4", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-darwin-arm64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.2", + "moduleVersion": "1.0.4", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-darwin-x64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.2", + "moduleVersion": "1.0.4", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-linux-arm64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.2", + "moduleVersion": "1.0.4", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-linux-x64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.2", + "moduleVersion": "1.0.4", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-windows-x64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.2", + "moduleVersion": "1.0.4", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -391,7 +489,7 @@ { "moduleName": "com.sun.xml.bind:jaxb-core", "moduleUrl": "https://www.eclipse.org", - "moduleVersion": "4.0.7", + "moduleVersion": "4.0.9", "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception", "moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html" }, @@ -657,6 +755,13 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "commons-net:commons-net", + "moduleUrl": "https://commons.apache.org/proper/commons-net/", + "moduleVersion": "3.11.1", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "de.rototor.pdfbox:graphics2d", "moduleVersion": "3.0.5", @@ -1067,6 +1172,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.engio:mbassador", + "moduleUrl": "https://github.com/bennidi/mbassador", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT license", + "moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php" + }, { "moduleName": "net.java.dev.stax-utils:stax-utils", "moduleUrl": "http://java.net/projects/stax-utils/", @@ -1088,6 +1200,13 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.sf.saxon:Saxon-HE", + "moduleUrl": "http://www.saxonica.com/", + "moduleVersion": "12.8", + "moduleLicense": "Mozilla Public License Version 2.0", + "moduleLicenseUrl": "http://www.mozilla.org/MPL/2.0/" + }, { "moduleName": "net.shibboleth:shib-networking", "moduleVersion": "9.1.6", @@ -1251,7 +1370,7 @@ { "moduleName": "org.apache.pdfbox:fontbox", "moduleUrl": "https://pdfbox.apache.org", - "moduleVersion": "3.0.7", + "moduleVersion": "3.0.8", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -1263,44 +1382,44 @@ }, { "moduleName": "org.apache.pdfbox:jbig2-imageio", - "moduleVersion": "3.0.4", - "moduleLicense": "Apache License, Version 2.0", + "moduleVersion": "3.0.5", + "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.apache.pdfbox:pdfbox", "moduleUrl": "https://pdfbox.apache.org", - "moduleVersion": "3.0.7", + "moduleVersion": "3.0.8", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.apache.pdfbox:pdfbox-io", "moduleUrl": "https://pdfbox.apache.org", - "moduleVersion": "3.0.7", + "moduleVersion": "3.0.8", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.apache.pdfbox:preflight", "moduleUrl": "https://pdfbox.apache.org", - "moduleVersion": "3.0.7", + "moduleVersion": "3.0.8", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.apache.pdfbox:xmpbox", "moduleUrl": "https://pdfbox.apache.org", - "moduleVersion": "3.0.7", + "moduleVersion": "3.0.8", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.apache.poi:poi", "moduleUrl": "https://poi.apache.org/", - "moduleVersion": "5.2.5", + "moduleVersion": "5.4.1", "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.apache.poi:poi", @@ -1326,9 +1445,9 @@ { "moduleName": "org.apache.poi:poi-scratchpad", "moduleUrl": "https://poi.apache.org/", - "moduleVersion": "5.2.5", + "moduleVersion": "5.4.1", "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.apache.santuario:xmlsec", @@ -1477,21 +1596,21 @@ { "moduleName": "org.bouncycastle:bcpkix-jdk18on", "moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/", - "moduleVersion": "1.84", + "moduleVersion": "1.85", "moduleLicense": "Bouncy Castle Licence", "moduleLicenseUrl": "https://www.bouncycastle.org/licence.html" }, { "moduleName": "org.bouncycastle:bcprov-jdk18on", "moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/", - "moduleVersion": "1.84", + "moduleVersion": "1.85", "moduleLicense": "Bouncy Castle Licence", "moduleLicenseUrl": "https://www.bouncycastle.org/licence.html" }, { "moduleName": "org.bouncycastle:bcutil-jdk18on", "moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/", - "moduleVersion": "1.84", + "moduleVersion": "1.85", "moduleLicense": "Bouncy Castle Licence", "moduleLicenseUrl": "https://www.bouncycastle.org/licence.html" }, @@ -1502,6 +1621,13 @@ "moduleLicense": "The MIT License", "moduleLicenseUrl": "http://opensource.org/licenses/MIT" }, + { + "moduleName": "org.checkerframework:checker-qual", + "moduleUrl": "https://checkerframework.org/", + "moduleVersion": "3.55.1", + "moduleLicense": "The MIT License", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, { "moduleName": "org.commonmark:commonmark", "moduleVersion": "0.28.0", @@ -1829,6 +1955,13 @@ "moduleLicense": "Apache License 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.javassist:javassist", + "moduleUrl": "https://www.javassist.org/", + "moduleVersion": "3.30.2-GA", + "moduleLicense": "MPL 1.1", + "moduleLicenseUrl": "https://www.mozilla.org/en-US/MPL/1.1/" + }, { "moduleName": "org.jboss.logging:jboss-logging", "moduleUrl": "https://www.jboss.org", @@ -1839,7 +1972,7 @@ { "moduleName": "org.jetbrains:annotations", "moduleUrl": "https://github.com/JetBrains/java-annotations", - "moduleVersion": "24.0.1", + "moduleVersion": "26.1.0", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -1986,7 +2119,7 @@ { "moduleName": "org.postgresql:postgresql", "moduleUrl": "https://jdbc.postgresql.org/", - "moduleVersion": "42.7.11", + "moduleVersion": "42.7.13", "moduleLicense": "BSD-2-Clause", "moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html" }, @@ -1999,26 +2132,26 @@ }, { "moduleName": "org.simplejavamail:core-module", - "moduleVersion": "8.12.6", + "moduleVersion": "9.2.0", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.simplejavamail:outlook-message-parser", "moduleUrl": "https://github.com/bbottema/outlook-message-parser", - "moduleVersion": "1.14.1", + "moduleVersion": "1.16.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.simplejavamail:outlook-module", - "moduleVersion": "8.12.6", + "moduleVersion": "9.2.0", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.simplejavamail:simple-java-mail", - "moduleVersion": "8.12.6", + "moduleVersion": "9.2.0", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2594,44 +2727,44 @@ }, { "moduleName": "org.verapdf:core", - "moduleVersion": "1.28.2", + "moduleVersion": "1.30.2", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)", "moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0" }, { "moduleName": "org.verapdf:feature-reporting", - "moduleVersion": "1.28.2", + "moduleVersion": "1.30.2", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)", "moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0" }, { "moduleName": "org.verapdf:metadata-fixer", - "moduleVersion": "1.28.2", + "moduleVersion": "1.30.2", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)", "moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0" }, { "moduleName": "org.verapdf:parser", - "moduleVersion": "1.28.2", + "moduleVersion": "1.30.2", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)", "moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0" }, { "moduleName": "org.verapdf:pdf-model", "moduleUrl": "https://github.com/veraPDF/veraPDF-model/", - "moduleVersion": "1.28.2", + "moduleVersion": "1.30.2", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)", "moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0" }, { "moduleName": "org.verapdf:validation-model", - "moduleVersion": "1.28.2", + "moduleVersion": "1.30.2", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)", "moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0" }, { "moduleName": "org.verapdf:verapdf-xmp-core", - "moduleVersion": "1.28.2", + "moduleVersion": "1.30.2", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)", "moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0" }, @@ -2649,6 +2782,13 @@ "moduleLicense": "MIT", "moduleLicenseUrl": "https://github.com/webjars/webjars-locator-lite/blob/main/LICENSE.md" }, + { + "moduleName": "org.xmlresolver:xmlresolver", + "moduleUrl": "https://github.com/xmlresolver/xmlresolver", + "moduleVersion": "5.3.3", + "moduleLicense": "Apache License version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.yaml:snakeyaml", "moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml", diff --git a/app/core/src/main/resources/static/android-chrome-192x192.png b/app/core/src/main/resources/static/android-chrome-192x192.png deleted file mode 100644 index 4219bb8403..0000000000 Binary files a/app/core/src/main/resources/static/android-chrome-192x192.png and /dev/null differ diff --git a/app/core/src/main/resources/static/android-chrome-512x512.png b/app/core/src/main/resources/static/android-chrome-512x512.png deleted file mode 100644 index 19bc603ec5..0000000000 Binary files a/app/core/src/main/resources/static/android-chrome-512x512.png and /dev/null differ 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/css/cookieconsent.css b/app/core/src/main/resources/static/css/cookieconsent.css deleted file mode 100644 index fdcc6ba6cb..0000000000 --- a/app/core/src/main/resources/static/css/cookieconsent.css +++ /dev/null @@ -1 +0,0 @@ -#cc-main{background:transparent;color:var(--cc-primary-color);font-family:var(--cc-font-family);font-size:16px;font-weight:400;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;line-height:1.15;position:relative;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;position:fixed;z-index:var(--cc-z-index)}#cc-main :after,#cc-main :before,#cc-main a,#cc-main button,#cc-main div,#cc-main h2,#cc-main input,#cc-main p,#cc-main span{all:unset;box-sizing:border-box}#cc-main .pm__badge,#cc-main button{all:initial;box-sizing:border-box;color:unset;visibility:unset}#cc-main .pm__badge,#cc-main a,#cc-main button,#cc-main input{-webkit-appearance:none;appearance:none;cursor:pointer;font-family:inherit;font-size:100%;line-height:normal;margin:0;outline:revert;outline-offset:2px;overflow:hidden}#cc-main table,#cc-main tbody,#cc-main td,#cc-main th,#cc-main thead,#cc-main tr{all:revert;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit}:root{--cc-font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";--cc-modal-border-radius:.5rem;--cc-btn-border-radius:.4rem;--cc-modal-transition-duration:.25s;--cc-link-color:var(--cc-btn-primary-bg);--cc-modal-margin:1rem;--cc-z-index:2147483647;--cc-bg:#fff;--cc-primary-color:#2c2f31;--cc-secondary-color:#5e6266;--cc-btn-primary-bg:#30363c;--cc-btn-primary-color:#fff;--cc-btn-primary-border-color:var(--cc-btn-primary-bg);--cc-btn-primary-hover-bg:#000;--cc-btn-primary-hover-color:#fff;--cc-btn-primary-hover-border-color:var(--cc-btn-primary-hover-bg);--cc-btn-secondary-bg:#eaeff2;--cc-btn-secondary-color:var(--cc-primary-color);--cc-btn-secondary-border-color:var(--cc-btn-secondary-bg);--cc-btn-secondary-hover-bg:#d4dae0;--cc-btn-secondary-hover-color:#000;--cc-btn-secondary-hover-border-color:#d4dae0;--cc-separator-border-color:#f0f4f7;--cc-toggle-on-bg:var(--cc-btn-primary-bg);--cc-toggle-off-bg:#667481;--cc-toggle-on-knob-bg:#fff;--cc-toggle-off-knob-bg:var(--cc-toggle-on-knob-bg);--cc-toggle-enabled-icon-color:var(--cc-bg);--cc-toggle-disabled-icon-color:var(--cc-bg);--cc-toggle-readonly-bg:#d5dee2;--cc-toggle-readonly-knob-bg:#fff;--cc-toggle-readonly-knob-icon-color:var(--cc-toggle-readonly-bg);--cc-section-category-border:var(--cc-cookie-category-block-bg);--cc-cookie-category-block-bg:#f0f4f7;--cc-cookie-category-block-border:#f0f4f7;--cc-cookie-category-block-hover-bg:#e9eff4;--cc-cookie-category-block-hover-border:#e9eff4;--cc-cookie-category-expanded-block-bg:transparent;--cc-cookie-category-expanded-block-hover-bg:#dee4e9;--cc-overlay-bg:rgba(0,0,0,.65);--cc-webkit-scrollbar-bg:var(--cc-section-category-border);--cc-webkit-scrollbar-hover-bg:var(--cc-btn-primary-hover-bg);--cc-footer-bg:var(--cc-btn-secondary-bg);--cc-footer-color:var(--cc-secondary-color);--cc-footer-border-color:#e4eaed;--cc-pm-toggle-border-radius:4em}#cc-main.cc--rtl{direction:rtl}#cc-main .cm__title,#cc-main a,#cc-main b,#cc-main em,#cc-main strong{font-weight:600}#cc-main button>span{pointer-events:none}#cc-main .cc__link,#cc-main a{background-image:linear-gradient(currentColor,currentColor);background-position:0 100%;background-repeat:no-repeat;background-size:0 1px;font-weight:600;position:relative;transition:background-size .25s,color .25s ease}#cc-main .cc__link:hover,#cc-main a:hover{background-size:100% 1px;color:var(--cc-primary-color)}#cc-main .cc__link{color:var(--cc-link-color)}#cc-main .cm__desc,#cc-main .pm__body{overscroll-behavior:auto contain;scrollbar-width:thin}@media screen and (min-width:640px){#cc-main ::-webkit-scrollbar,#cc-main ::-webkit-scrollbar-thumb,#cc-main ::-webkit-scrollbar-track{all:revert}#cc-main ::-webkit-scrollbar-thumb{-webkit-background-clip:padding-box;background-clip:padding-box;background:var(--cc-toggle-readonly-bg);border:.25rem solid var(--cc-bg);border-radius:1rem}#cc-main ::-webkit-scrollbar-thumb:hover{background:var(--cc-toggle-off-bg)}#cc-main ::-webkit-scrollbar{background:transparent;width:12px}}html.disable--interaction.show--consent,html.disable--interaction.show--consent body{height:auto!important;overflow:hidden!important}@media (prefers-reduced-motion){#cc-main{--cc-modal-transition-duration:0s}}.cc--darkmode{--cc-bg:#161a1c;--cc-primary-color:#ebf3f6;--cc-secondary-color:#aebbc5;--cc-btn-primary-bg:#c2d0e0;--cc-btn-primary-color:var(--cc-bg);--cc-btn-primary-border-color:var(--cc-btn-primary-bg);--cc-btn-primary-hover-bg:#98a7b6;--cc-btn-primary-hover-color:#000;--cc-btn-primary-hover-border-color:var(--cc-btn-primary-hover-bg);--cc-btn-secondary-bg:#242c31;--cc-btn-secondary-color:var(--cc-primary-color);--cc-btn-secondary-border-color:var(--cc-btn-secondary-bg);--cc-btn-secondary-hover-bg:#353d43;--cc-btn-secondary-hover-color:#fff;--cc-btn-secondary-hover-border-color:var(--cc-btn-secondary-hover-bg);--cc-separator-border-color:#222a30;--cc-toggle-on-bg:var(--cc-btn-primary-bg);--cc-toggle-off-bg:#525f6b;--cc-toggle-on-knob-bg:var(--cc-btn-primary-color);--cc-toggle-off-knob-bg:var(--cc-btn-primary-color);--cc-toggle-enabled-icon-color:var(--cc-btn-primary-color);--cc-toggle-disabled-icon-color:var(--cc-btn-primary-color);--cc-toggle-readonly-bg:#343e45;--cc-toggle-readonly-knob-bg:#5f6b72;--cc-toggle-readonly-knob-icon-color:var(--cc-toggle-readonly-bg);--cc-section-category-border:#1e2428;--cc-cookie-category-block-bg:#1e2428;--cc-cookie-category-block-border:var(--cc-section-category-border);--cc-cookie-category-block-hover-bg:#242c31;--cc-cookie-category-block-hover-border:#232a2f;--cc-cookie-category-expanded-block-bg:transparent;--cc-cookie-category-expanded-block-hover-bg:var(--cc-toggle-readonly-bg);--cc-overlay-bg:rgba(0,0,0,.65);--cc-webkit-scrollbar-bg:var(--cc-section-category-border);--cc-webkit-scrollbar-hover-bg:var(--cc-btn-primary-hover-bg);--cc-footer-bg:#0c0e0f;--cc-footer-color:var(--cc-secondary-color);--cc-footer-border-color:#060809}.cc--darkmode #cc-main{color-scheme:dark}#cc-main .cm{background:var(--cc-bg);border-radius:var(--cc-modal-border-radius);box-shadow:0 .625em 1.875em rgba(0,0,2,.3);display:flex;flex-direction:column;max-width:24rem;opacity:0;overflow:hidden;position:fixed;transform:translateY(1.6em);visibility:hidden;z-index:1}#cc-main .cm--top{top:var(--cc-modal-margin)}#cc-main .cm--middle{top:50%;transform:translateY(calc(-50% + 1.6em))}#cc-main .cm--bottom{bottom:var(--cc-modal-margin)}#cc-main .cm--center{left:var(--cc-modal-margin);margin:0 auto;right:var(--cc-modal-margin);width:unset}#cc-main .cm--left{left:var(--cc-modal-margin);margin-right:var(--cc-modal-margin)}#cc-main .cm--right{margin-left:var(--cc-modal-margin);right:var(--cc-modal-margin)}#cc-main .cm__body{display:flex;flex-direction:column;justify-content:space-between;position:relative}#cc-main .cm__btns,#cc-main .cm__links{padding:1rem 1.3rem;width:unset}#cc-main .cm__texts{display:flex;flex:1;flex-direction:column;justify-content:center;padding:1rem 0 0}#cc-main .cm__desc,#cc-main .cm__title{padding:0 1.3rem}#cc-main .cm__title{font-size:1.05em}#cc-main .cm__title+.cm__desc{margin-top:1.1em}#cc-main .cm__desc{color:var(--cc-secondary-color);font-size:.9em;line-height:1.5;max-height:40vh;overflow-x:visible;overflow-y:auto;padding-bottom:1em}#cc-main .cm__btns{border-top:1px solid var(--cc-separator-border-color);display:flex;flex-direction:column;justify-content:center}#cc-main .cm__btn-group{display:grid;grid-auto-columns:minmax(0,1fr)}#cc-main .cm__btn+.cm__btn,#cc-main .cm__btn-group+.cm__btn-group{margin-top:.375rem}#cc-main .cm--flip .cm__btn+.cm__btn,#cc-main .cm--flip .cm__btn-group+.cm__btn-group{margin-bottom:.375rem;margin-top:0}#cc-main .cm--inline .cm__btn+.cm__btn{margin-left:.375rem;margin-top:0}#cc-main .cm--inline.cm--flip .cm__btn+.cm__btn{margin-bottom:0;margin-left:0;margin-right:.375rem}#cc-main .cm--inline.cm--flip .cm__btn-group+.cm__btn-group{margin-bottom:.375rem;margin-right:0}#cc-main .cm--wide .cm__btn+.cm__btn,#cc-main .cm--wide .cm__btn-group+.cm__btn-group{margin-left:.375rem;margin-top:0}#cc-main .cm--wide.cm--flip .cm__btn+.cm__btn,#cc-main .cm--wide.cm--flip .cm__btn-group+.cm__btn-group{margin-bottom:0;margin-right:.375rem}#cc-main .cm--bar:not(.cm--inline) .cm__btn-group--uneven,#cc-main .cm--wide .cm__btn-group--uneven{display:flex;flex:1;justify-content:space-between}#cc-main .cm--bar:not(.cm--inline).cm--flip .cm__btn-group--uneven,#cc-main .cm--wide.cm--flip .cm__btn-group--uneven{flex-direction:row-reverse}#cc-main .cm__btn{background:var(--cc-btn-primary-bg);border:1px solid var(--cc-btn-primary-border-color);border-radius:var(--cc-btn-border-radius);color:var(--cc-btn-primary-color);font-size:.82em;font-weight:600;min-height:42px;padding:.5em 1em;text-align:center}#cc-main .cm__btn:hover{background:var(--cc-btn-primary-hover-bg);border-color:var(--cc-btn-primary-hover-border-color);color:var(--cc-btn-primary-hover-color)}#cc-main .cm__btn--secondary{background:var(--cc-btn-secondary-bg);border-color:var(--cc-btn-secondary-border-color);color:var(--cc-btn-secondary-color)}#cc-main .cm__btn--secondary:hover{background:var(--cc-btn-secondary-hover-bg);border-color:var(--cc-btn-secondary-hover-border-color);color:var(--cc-btn-secondary-hover-color)}#cc-main .cm__btn--close{border-radius:0;border-bottom-left-radius:var(--cc-btn-border-radius);border-right:none;border-top:none;display:none;font-size:1em;height:42px;min-width:auto!important;overflow:hidden;padding:0!important;position:absolute;right:0;top:0;width:42px}#cc-main .cm__btn--close svg{stroke:var(--cc-btn-primary-color);transform:scale(.5);transition:stroke .15s ease}#cc-main .cm__btn--close:hover svg{stroke:var(--cc-btn-primary-hover-color)}#cc-main .cm__btn--close.cm__btn--secondary svg{stroke:var(--cc-btn-secondary-color)}#cc-main .cm__btn--close.cm__btn--secondary:hover svg{stroke:var(--cc-btn-secondary-hover-color)}#cc-main .cm__btn--close+.cm__texts .cm__title{padding-right:3rem}#cc-main .cm--inline .cm__btn-group{grid-auto-flow:column}#cc-main .cm__footer{background:var(--cc-footer-bg);border-top:1px solid var(--cc-footer-border-color);color:var(--cc-footer-color);padding:.4em 0 .5em}#cc-main .cm__links{display:flex;flex-direction:row;padding-bottom:0;padding-top:0}#cc-main .cm__link-group{display:flex;flex-direction:row;font-size:.8em;width:100%}#cc-main .cm__link-group>*+*{margin-left:1.3rem}#cc-main .cm--flip .cm__btn:last-child{grid-row:1}#cc-main .cm--inline.cm--flip .cm__btn:last-child{grid-column:1}#cc-main .cm--box .cm__btn--close{display:block}#cc-main .cm--box.cm--flip .cm__btns{flex-direction:column-reverse}#cc-main .cm--box.cm--wide{max-width:36em}#cc-main .cm--box.cm--wide .cm__btns{flex-direction:row;justify-content:space-between}#cc-main .cm--box.cm--wide .cm__btn-group{grid-auto-flow:column}#cc-main .cm--box.cm--wide .cm__btn{min-width:120px;padding-left:1.8em;padding-right:1.8em}#cc-main .cm--box.cm--wide.cm--flip .cm__btns{flex-direction:row-reverse}#cc-main .cm--box.cm--wide.cm--flip .cm__btn:last-child{grid-column:1}#cc-main .cm--cloud{max-width:54em;width:unset}#cc-main .cm--cloud .cm__body{flex-direction:row}#cc-main .cm--cloud .cm__texts{flex:1}#cc-main .cm--cloud .cm__desc{max-height:9.4em}#cc-main .cm--cloud .cm__btns{border-left:1px solid var(--cc-separator-border-color);border-top:none;max-width:23em}#cc-main .cm--cloud .cm__btn-group{flex-direction:column}#cc-main .cm--cloud .cm__btn{min-width:19em}#cc-main .cm--cloud.cm--flip .cm__btn-group,#cc-main .cm--cloud.cm--flip .cm__btns{flex-direction:column-reverse}#cc-main .cm--cloud.cm--inline .cm__btn-group{flex-direction:row}#cc-main .cm--cloud.cm--inline .cm__btn{min-width:10em}#cc-main .cm--cloud.cm--inline.cm--flip .cm__btn-group{flex-direction:row-reverse}#cc-main .cm--bar{border-radius:0;left:0;margin:0;max-width:unset;opacity:1;right:0;transform:translateY(0);width:100vw;--cc-modal-transition-duration:.35s}#cc-main .cm--bar.cm--top{top:0;transform:translateY(-100%)}#cc-main .cm--bar.cm--bottom{bottom:0;transform:translateY(100%)}#cc-main .cm--bar .cm__body,#cc-main .cm--bar .cm__links{margin:0 auto;max-width:55em;width:100%}#cc-main .cm--bar .cm__body{padding:.5em 0 .9em}#cc-main .cm--bar .cm__btns{border-top:none;flex-direction:row;justify-content:space-between}#cc-main .cm--bar .cm__btn-group{grid-auto-flow:column}#cc-main .cm--bar:not(.cm--inline) .cm__btn+.cm__btn,#cc-main .cm--bar:not(.cm--inline) .cm__btn-group+.cm__btn-group{margin-left:.375rem;margin-top:0}#cc-main .cm--bar .cm__btn{min-width:120px;padding-left:2em;padding-right:2em}#cc-main .cm--bar.cm--flip:not(.cm--inline) .cm__btn+.cm__btn,#cc-main .cm--bar.cm--flip:not(.cm--inline) .cm__btn-group+.cm__btn-group{margin-bottom:0;margin-left:0;margin-right:.375rem}#cc-main .cm--bar.cm--flip .cm__btns{flex-direction:row-reverse}#cc-main .cm--bar.cm--flip .cm__btn:last-child{grid-column:1}#cc-main .cm--bar.cm--inline .cm__body,#cc-main .cm--bar.cm--inline .cm__links{max-width:74em}#cc-main .cm--bar.cm--inline .cm__body{flex-direction:row;padding:0}#cc-main .cm--bar.cm--inline .cm__btns{flex-direction:column;justify-content:center;max-width:23em}#cc-main .cm--bar.cm--inline.cm--flip .cm__btns{flex-direction:column-reverse}#cc-main .cc--anim .cm,#cc-main .cc--anim.cm-wrapper:before{transition:opacity var(--cc-modal-transition-duration) ease,visibility var(--cc-modal-transition-duration) ease,transform var(--cc-modal-transition-duration) ease}#cc-main .cc--anim .cm__btn,#cc-main .cc--anim .cm__close{transition:background-color .15s ease,border-color .15s ease,color .15s ease}.disable--interaction #cc-main .cm-wrapper:before{background:var(--cc-overlay-bg);bottom:0;content:"";left:0;opacity:0;position:fixed;right:0;top:0;visibility:hidden;z-index:0}.show--consent #cc-main .cc--anim .cm{opacity:1;transform:translateY(0);visibility:visible!important}.show--consent #cc-main .cc--anim .cm--middle{transform:translateY(-50%)}.show--consent #cc-main .cc--anim .cm--bar{transform:translateY(0)}.show--consent #cc-main .cc--anim.cm-wrapper:before{opacity:1;visibility:visible}#cc-main.cc--rtl .cm__btn--close{border-bottom-left-radius:unset;border-bottom-right-radius:var(--cc-btn-border-radius);left:0;right:unset}#cc-main.cc--rtl .cm__btn--close+.cm__texts .cm__title{padding-left:3rem!important;padding-right:1.3rem}#cc-main.cc--rtl .cm--inline .cm__btn+.cm__btn{margin-left:0;margin-right:.375rem}#cc-main.cc--rtl .cm--inline.cm--flip .cm__btn+.cm__btn{margin-left:.375rem;margin-right:0}#cc-main.cc--rtl .cm:not(.cm--inline).cm--bar .cm__btn+.cm__btn,#cc-main.cc--rtl .cm:not(.cm--inline).cm--bar .cm__btn-group+.cm__btn-group,#cc-main.cc--rtl .cm:not(.cm--inline).cm--wide .cm__btn+.cm__btn,#cc-main.cc--rtl .cm:not(.cm--inline).cm--wide .cm__btn-group+.cm__btn-group{margin-left:0;margin-right:.375rem}#cc-main.cc--rtl .cm:not(.cm--inline).cm--bar.cm--flip .cm__btn+.cm__btn,#cc-main.cc--rtl .cm:not(.cm--inline).cm--wide.cm--flip .cm__btn+.cm__btn{margin-left:.375rem;margin-right:0}#cc-main.cc--rtl .cm__link-group>*+*{margin-left:0;margin-right:1.3rem}@media screen and (max-width:640px){#cc-main{--cc-modal-margin:.5em}#cc-main .cm{max-width:none!important;width:auto!important}#cc-main .cm__body{flex-direction:column!important;padding:0!important}#cc-main .cm__btns,#cc-main .cm__desc,#cc-main .cm__links,#cc-main .cm__title{padding-left:1.1rem!important;padding-right:1.1rem!important}#cc-main .cm__btns{border-left:none!important;border-top:1px solid var(--cc-separator-border-color)!important;flex-direction:column!important;max-width:none!important;min-width:auto!important}#cc-main .cm__btn+.cm__btn,#cc-main .cm__btn-group+.cm__btn-group{margin:.375rem 0 0!important}#cc-main .cm--flip .cm__btn+.cm__btn,#cc-main .cm--flip .cm__btn-group+.cm__btn-group{margin-bottom:.375rem!important;margin-top:0!important}#cc-main .cm__btn-group{display:flex!important;flex-direction:column!important;min-width:auto!important}#cc-main .cm__btn{flex:auto!important}#cc-main .cm__link-group{justify-content:center!important}#cc-main .cm--flip .cm__btn-group,#cc-main .cm--flip .cm__btns{flex-direction:column-reverse!important}}#cc-main .pm-wrapper{position:relative;z-index:2}#cc-main .pm{background:var(--cc-bg);border-radius:var(--cc-modal-border-radius);box-shadow:0 .625em 1.875em rgba(0,0,2,.3);display:flex;flex-direction:column;opacity:0;overflow:hidden;position:fixed;visibility:hidden;width:100%;width:unset;z-index:1}#cc-main svg{fill:none;width:100%}#cc-main .pm__body,#cc-main .pm__footer,#cc-main .pm__header{padding:1em 1.4em}#cc-main .pm__header{align-items:center;border-bottom:1px solid var(--cc-separator-border-color);display:flex;justify-content:space-between}#cc-main .pm__title{align-items:center;display:flex;flex:1;font-weight:600;margin-right:2em}#cc-main .pm__close-btn{background:var(--cc-btn-secondary-bg);border:1px solid var(--cc-btn-secondary-border-color);border-radius:var(--cc-btn-border-radius);height:40px;position:relative;transition:all .15s ease;width:40px}#cc-main .pm__close-btn span{display:flex;height:100%;width:100%}#cc-main .pm__close-btn svg{stroke:var(--cc-btn-secondary-color);transform:scale(.5);transition:stroke .15s ease}#cc-main .pm__close-btn:hover{background:var(--cc-btn-secondary-hover-bg);border-color:var(--cc-btn-secondary-hover-border-color)}#cc-main .pm__close-btn:hover svg{stroke:var(--cc-btn-secondary-hover-color)}#cc-main .pm__body{flex:1;overflow-y:auto;overflow-y:overlay}#cc-main .pm__section,#cc-main .pm__section--toggle{border-radius:var(--cc-btn-border-radius);display:flex;flex-direction:column;margin-bottom:.5em}#cc-main .pm__section--toggle .pm__section-desc-wrapper{border:1px solid var(--cc-cookie-category-block-border);border-radius:var(--cc-btn-border-radius);border-top:none;border-top-left-radius:0;border-top-right-radius:0;display:none;margin-top:0!important;overflow:hidden}#cc-main .pm__section{border:1px solid var(--cc-separator-border-color);padding:1em;transition:background-color .25s ease,border-color .25s ease}#cc-main .pm__section:first-child{border:none;margin-bottom:2em;margin-top:0;padding:0;transition:none}#cc-main .pm__section:not(:first-child):hover{background:var(--cc-cookie-category-block-bg);border-color:var(--cc-cookie-category-block-border)}#cc-main .pm__section-toggles+.pm__section{margin-top:2em}#cc-main .pm__section--toggle{background:var(--cc-cookie-category-block-bg);border-top:none;margin-bottom:.375rem}#cc-main .pm__section--toggle .pm__section-title{align-items:center;background:var(--cc-cookie-category-block-bg);border:1px solid var(--cc-cookie-category-block-border);display:flex;justify-content:space-between;min-height:58px;padding:1.1em 5.4em 1.1em 1.2em;position:relative;transition:background-color .25s ease,border-color .25s ease;width:100%}#cc-main .pm__section--toggle .pm__section-title:hover{background:var(--cc-cookie-category-block-hover-bg);border-color:var(--cc-cookie-category-block-hover-border)}#cc-main .pm__section--toggle .pm__section-desc{margin-top:0;padding:1em}#cc-main .pm__section--toggle.is-expanded{--cc-cookie-category-block-bg:var(--cc-cookie-category-expanded-block-bg);--cc-cookie-category-block-border:var(--cc-cookie-category-expanded-block-hover-bg)}#cc-main .pm__section--toggle.is-expanded .pm__section-title{border-bottom-left-radius:0;border-bottom-right-radius:0}#cc-main .pm__section--toggle.is-expanded .pm__section-arrow svg{transform:scale(.5) rotate(180deg)}#cc-main .pm__section--toggle.is-expanded .pm__section-desc-wrapper{display:flex}#cc-main .pm__section--expandable .pm__section-title{cursor:pointer;padding-left:3.4em}#cc-main .pm__section--expandable .pm__section-arrow{background:var(--cc-toggle-readonly-bg);border-radius:100%;display:flex;height:20px;justify-content:center;left:18px;pointer-events:none;position:absolute;width:20px}#cc-main .pm__section--expandable .pm__section-arrow svg{stroke:var(--cc-btn-secondary-color);transform:scale(.5)}#cc-main .pm__section-title-wrapper{align-items:center;display:flex;position:relative}#cc-main .pm__section-title-wrapper+.pm__section-desc-wrapper{margin-top:.85em}#cc-main .pm__section-title{border-radius:var(--cc-btn-border-radius);font-size:.95em;font-weight:600}#cc-main .pm__badge{align-items:center;background:var(--cc-btn-secondary-bg);border-radius:5em;color:var(--cc-secondary-color);display:flex;flex:none;font-size:.8em;font-weight:600;height:23px;justify-content:center;margin-left:1em;min-width:23px;overflow:hidden;padding:0 .6em 1px;position:relative;text-align:center;white-space:nowrap;width:auto}#cc-main .pm__service-counter{background:var(--cc-btn-primary-bg);color:var(--cc-btn-primary-color);padding:0;width:23px}#cc-main .pm__service-counter[data-counterlabel]{padding:0 .6em 1px;width:auto}#cc-main .section__toggle,#cc-main .section__toggle-wrapper,#cc-main .toggle__icon,#cc-main .toggle__label{border-radius:var(--cc-pm-toggle-border-radius);height:23px;transform:translateZ(0);width:50px}#cc-main .section__toggle-wrapper{cursor:pointer;position:absolute;right:18px;z-index:1}#cc-main .toggle-service{height:19px;position:relative;right:0;width:42px}#cc-main .toggle-service .section__toggle,#cc-main .toggle-service .toggle__icon,#cc-main .toggle-service .toggle__label{height:19px;width:42px}#cc-main .toggle-service .toggle__icon{position:relative}#cc-main .toggle-service .toggle__icon-circle{height:19px;width:19px}#cc-main .toggle-service .section__toggle:checked~.toggle__icon .toggle__icon-circle{transform:translateX(23px)}#cc-main .pm__section--toggle:nth-child(2) .section__toggle-wrapper:after{display:none!important}#cc-main .section__toggle{border:0;cursor:pointer;display:block;left:0;margin:0;position:absolute;top:0}#cc-main .section__toggle:disabled{cursor:not-allowed}#cc-main .toggle__icon{background:var(--cc-toggle-off-bg);box-shadow:0 0 0 1px var(--cc-toggle-off-bg);display:flex;flex-direction:row;pointer-events:none;position:absolute;transition:all .25s ease}#cc-main .toggle__icon-circle{background:var(--cc-toggle-off-knob-bg);border:none;border-radius:var(--cc-pm-toggle-border-radius);box-shadow:0 1px 2px rgba(24,32,3,.36);display:block;height:23px;left:0;position:absolute;top:0;transition:transform .25s ease,background-color .25s ease;width:23px}#cc-main .toggle__icon-off,#cc-main .toggle__icon-on{height:100%;position:absolute;transition:opacity .15s ease;width:100%}#cc-main .toggle__icon-on{opacity:0;transform:rotate(45deg)}#cc-main .toggle__icon-on svg{stroke:var(--cc-toggle-on-bg);transform:scale(.55) rotate(-45deg)}#cc-main .toggle__icon-off{opacity:1}#cc-main .toggle__icon-off svg{stroke:var(--cc-toggle-off-bg);transform:scale(.55)}#cc-main .section__toggle:checked~.toggle__icon{background:var(--cc-toggle-on-bg);box-shadow:0 0 0 1px var(--cc-toggle-on-bg)}#cc-main .section__toggle:checked~.toggle__icon .toggle__icon-circle{background-color:var(--cc-toggle-on-knob-bg);transform:translateX(27px)}#cc-main .section__toggle:checked~.toggle__icon .toggle__icon-on{opacity:1}#cc-main .section__toggle:checked~.toggle__icon .toggle__icon-off{opacity:0}#cc-main .section__toggle:checked:disabled~.toggle__icon{background:var(--cc-toggle-readonly-bg);box-shadow:0 0 0 1px var(--cc-toggle-readonly-bg)}#cc-main .section__toggle:checked:disabled~.toggle__icon .toggle__icon-circle{background:var(--cc-toggle-readonly-knob-bg);box-shadow:none}#cc-main .section__toggle:checked:disabled~.toggle__icon svg{stroke:var(--cc-toggle-readonly-knob-icon-color)}#cc-main .toggle__label{opacity:0;overflow:hidden;pointer-events:none;position:absolute;top:0;z-index:-1}#cc-main .pm__section-desc-wrapper{color:var(--cc-secondary-color);display:flex;flex-direction:column;font-size:.9em}#cc-main .pm__section-desc-wrapper>:not(:last-child){border-bottom:1px solid var(--cc-cookie-category-block-border)}#cc-main .pm__section-services{display:flex;flex-direction:column}#cc-main .pm__service{align-items:center;display:flex;justify-content:space-between;padding:.4em 1.2em;position:relative;transition:background-color .15s ease}#cc-main .pm__service:hover{background-color:var(--cc-cookie-category-block-hover-bg)}#cc-main .pm__service-header{align-items:center;display:flex;margin-right:1em;width:100%}#cc-main .pm__service-icon{border:2px solid;border-radius:100%;height:8px;margin-left:6px;margin-right:20px;margin-top:1px;min-width:8px}#cc-main .pm__service-title{font-size:.95em;width:100%;word-break:break-word}#cc-main .pm__section-desc{line-height:1.5em}#cc-main .pm__section-table{border-collapse:collapse;font-size:.9em;margin:0;overflow:hidden;padding:0;text-align:left;width:100%}#cc-main .pm__table-caption{text-align:left}#cc-main .pm__table-caption,#cc-main .pm__table-head>tr{border-bottom:1px dashed var(--cc-separator-border-color);color:var(--cc-primary-color);font-weight:600}#cc-main .pm__table-tr{transition:background-color .15s ease}#cc-main .pm__table-tr:hover{background:var(--cc-cookie-category-block-hover-bg)}#cc-main .pm__table-caption,#cc-main .pm__table-td,#cc-main .pm__table-th{padding:.625em .625em .625em 1.2em;vertical-align:top}#cc-main .pm__footer{border-top:1px solid var(--cc-separator-border-color);display:flex;justify-content:space-between}#cc-main .pm__btn-group{display:flex}#cc-main .pm__btn+.pm__btn,#cc-main .pm__btn-group+.pm__btn-group{margin-left:.375rem}#cc-main .pm--flip .pm__btn+.pm__btn,#cc-main .pm--flip .pm__btn-group+.pm__btn-group{margin-left:0;margin-right:.375rem}#cc-main .pm__btn{background:var(--cc-btn-primary-bg);border:1px solid var(--cc-btn-primary-border-color);border-radius:var(--cc-btn-border-radius);color:var(--cc-btn-primary-color);flex:auto;font-size:.82em;font-weight:600;min-height:42px;min-width:110px;padding:.5em 1.5em;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}#cc-main .pm__btn:hover{background:var(--cc-btn-primary-hover-bg);border-color:var(--cc-btn-primary-hover-border-color);color:var(--cc-btn-primary-hover-color)}#cc-main .pm__btn--secondary{background:var(--cc-btn-secondary-bg);border-color:var(--cc-btn-secondary-border-color);color:var(--cc-btn-secondary-color)}#cc-main .pm__btn--secondary:hover{background:var(--cc-btn-secondary-hover-bg);border-color:var(--cc-btn-secondary-hover-border-color);color:var(--cc-btn-secondary-hover-color)}#cc-main .pm--box{height:calc(100% - 2em);left:var(--cc-modal-margin);margin:0 auto;max-height:37.5em;max-width:43em;right:var(--cc-modal-margin);top:50%;transform:translateY(calc(-50% + 1.6em))}#cc-main .pm--box.pm--flip .pm__btn-group,#cc-main .pm--box.pm--flip .pm__footer{flex-direction:row-reverse}#cc-main .pm--bar{border-radius:0;bottom:0;height:100%;margin:0;max-height:none;max-width:29em;opacity:1;top:0;width:100%;--cc-modal-transition-duration:.35s}#cc-main .pm--bar .pm__section-table,#cc-main .pm--bar .pm__table-body,#cc-main .pm--bar .pm__table-td,#cc-main .pm--bar .pm__table-th,#cc-main .pm--bar .pm__table-tr{display:block}#cc-main .pm--bar .pm__table-head{display:none}#cc-main .pm--bar .pm__table-caption{display:block}#cc-main .pm--bar .pm__table-tr:not(:last-child){border-bottom:1px solid var(--cc-separator-border-color)}#cc-main .pm--bar .pm__table-td{display:flex;justify-content:space-between}#cc-main .pm--bar .pm__table-td:before{color:var(--cc-primary-color);content:attr(data-column);flex:1;font-weight:600;min-width:100px;overflow:hidden;padding-right:2em;text-overflow:ellipsis}#cc-main .pm--bar .pm__table-td>div{flex:3}#cc-main .pm--bar:not(.pm--wide) .pm__body,#cc-main .pm--bar:not(.pm--wide) .pm__footer,#cc-main .pm--bar:not(.pm--wide) .pm__header{padding:1em 1.3em}#cc-main .pm--bar:not(.pm--wide) .pm__btn-group,#cc-main .pm--bar:not(.pm--wide) .pm__footer{flex-direction:column}#cc-main .pm--bar:not(.pm--wide) .pm__btn+.pm__btn,#cc-main .pm--bar:not(.pm--wide) .pm__btn-group+.pm__btn-group{margin:.375rem 0 0}#cc-main .pm--bar:not(.pm--wide).pm--flip .pm__btn-group,#cc-main .pm--bar:not(.pm--wide).pm--flip .pm__footer{flex-direction:column-reverse}#cc-main .pm--bar:not(.pm--wide).pm--flip .pm__btn+.pm__btn,#cc-main .pm--bar:not(.pm--wide).pm--flip .pm__btn-group+.pm__btn-group{margin-bottom:.375rem;margin-top:0}#cc-main .pm--bar:not(.pm--wide) .pm__badge{display:none}#cc-main .pm--bar.pm--left{left:0;transform:translateX(-100%)}#cc-main .pm--bar.pm--right{right:0;transform:translateX(100%)}#cc-main .pm--bar.pm--wide{max-width:35em}#cc-main .pm--bar.pm--wide .pm__body,#cc-main .pm--bar.pm--wide .pm__footer,#cc-main .pm--bar.pm--wide .pm__header{padding:1em 1.4em}#cc-main .pm--bar.pm--wide.pm--flip .pm__btn-group,#cc-main .pm--bar.pm--wide.pm--flip .pm__footer{flex-direction:row-reverse}#cc-main .pm-overlay{background:var(--cc-overlay-bg);bottom:0;content:"";left:0;opacity:0;position:fixed;right:0;top:0;visibility:hidden;z-index:1}#cc-main .cc--anim .pm,#cc-main .cc--anim .pm-overlay{transition:opacity var(--cc-modal-transition-duration) ease,visibility var(--cc-modal-transition-duration) ease,transform var(--cc-modal-transition-duration) ease}.show--preferences #cc-main .cc--anim .pm{opacity:1;visibility:visible!important}.show--preferences #cc-main .cc--anim .pm--box{transform:translateY(-50%)}.show--preferences #cc-main .cc--anim .pm--bar{transform:translateX(0)}.show--preferences #cc-main .cc--anim .pm-overlay{opacity:1;visibility:visible}#cc-main.cc--rtl .pm__service-header{margin-left:1em;margin-right:0}#cc-main.cc--rtl .pm__section-arrow{left:unset;right:18px}#cc-main.cc--rtl .section__toggle-wrapper{left:18px;right:unset;transform-origin:left}#cc-main.cc--rtl .toggle-service{left:0}#cc-main.cc--rtl .pm__service-icon{margin-left:20px;margin-right:5px}#cc-main.cc--rtl .pm__section--toggle .pm__section-title{padding-left:5.4em;padding-right:1.2em}#cc-main.cc--rtl .pm__section--expandable .pm__section-title{padding-right:3.4em}#cc-main.cc--rtl .pm__badge{margin-left:unset;margin-right:1em}#cc-main.cc--rtl .toggle__icon-circle{transform:translateX(27px)}#cc-main.cc--rtl .toggle-service .toggle__icon-circle{transform:translateX(23px)}#cc-main.cc--rtl .section__toggle:checked~.toggle__icon .toggle__icon-circle{transform:translateX(0)}#cc-main.cc--rtl .pm__table-td,#cc-main.cc--rtl .pm__table-th{padding-left:unset;padding-right:1.2em;text-align:right}#cc-main.cc--rtl .pm__table-td{padding-left:unset;padding-right:1.2em}#cc-main.cc--rtl .pm__table-td:before{padding-left:2em;padding-right:unset}#cc-main.cc--rtl .pm__btn+.pm__btn,#cc-main.cc--rtl .pm__btn-group+.pm__btn-group{margin-left:0;margin-right:.375rem}#cc-main.cc--rtl .pm--flip .pm__btn+.pm__btn,#cc-main.cc--rtl .pm--flip .pm__btn-group+.pm__btn-group{margin-left:.375rem;margin-right:0}#cc-main.cc--rtl .pm--flip.pm--bar:not(.pm--wide) .pm__btn+.pm__btn,#cc-main.cc--rtl .pm--flip.pm--bar:not(.pm--wide) .pm__btn-group+.pm__btn-group{margin-left:0}@media screen and (max-width:640px){#cc-main .pm{border-radius:0;bottom:0;height:auto;left:0;max-height:100%;max-width:none!important;right:0;top:0;transform:translateY(1.6em)}#cc-main .pm__body,#cc-main .pm__footer,#cc-main .pm__header{padding:.9em!important}#cc-main .pm__badge{display:none}#cc-main .pm__section-table,#cc-main .pm__table-body,#cc-main .pm__table-caption,#cc-main .pm__table-td,#cc-main .pm__table-th,#cc-main .pm__table-tr{display:block}#cc-main .pm__table-head{display:none}#cc-main .pm__table-tr:not(:last-child){border-bottom:1px solid var(--cc-separator-border-color)}#cc-main .pm__table-td{display:flex;justify-content:space-between}#cc-main .pm__table-td:before{color:var(--cc-primary-color);content:attr(data-column);flex:1;font-weight:600;min-width:100px;overflow:hidden;padding-right:2em;text-overflow:ellipsis}#cc-main .pm__table-td>div{flex:3}#cc-main .pm__btn-group,#cc-main .pm__footer{flex-direction:column!important}#cc-main .pm__btn-group{display:flex!important}#cc-main .pm__btn+.pm__btn,#cc-main .pm__btn-group+.pm__btn-group{margin:.375rem 0 0!important}#cc-main .pm--flip .pm__btn+.pm__btn,#cc-main .pm--flip .pm__btn-group+.pm__btn-group{margin-bottom:.375rem!important;margin-top:0!important}#cc-main .pm--flip .pm__btn-group,#cc-main .pm--flip .pm__footer{flex-direction:column-reverse!important}.show--preferences #cc-main .cc--anim .pm{transform:translateY(0)!important}} \ No newline at end of file diff --git a/app/core/src/main/resources/static/css/cookieconsentCustomisation.css b/app/core/src/main/resources/static/css/cookieconsentCustomisation.css deleted file mode 100644 index fd1a8ff355..0000000000 --- a/app/core/src/main/resources/static/css/cookieconsentCustomisation.css +++ /dev/null @@ -1,205 +0,0 @@ -/* Light theme variables */ -:root { - --cc-bg: #ffffff; - --cc-primary-color: #1c1c1c; - --cc-secondary-color: #666666; - - --cc-btn-primary-bg: #007bff; - --cc-btn-primary-color: #ffffff; - --cc-btn-primary-border-color: #007bff; - --cc-btn-primary-hover-bg: #0056b3; - --cc-btn-primary-hover-color: #ffffff; - --cc-btn-primary-hover-border-color: #0056b3; - - --cc-btn-secondary-bg: #f1f3f4; - --cc-btn-secondary-color: #1c1c1c; - --cc-btn-secondary-border-color: #f1f3f4; - --cc-btn-secondary-hover-bg: #007bff; - --cc-btn-secondary-hover-color: #ffffff; - --cc-btn-secondary-hover-border-color: #007bff; - - --cc-separator-border-color: #e0e0e0; - - --cc-toggle-on-bg: #007bff; - --cc-toggle-off-bg: #667481; - --cc-toggle-on-knob-bg: #ffffff; - --cc-toggle-off-knob-bg: #ffffff; - - --cc-toggle-enabled-icon-color: #ffffff; - --cc-toggle-disabled-icon-color: #ffffff; - - --cc-toggle-readonly-bg: #f1f3f4; - --cc-toggle-readonly-knob-bg: #79747e; - --cc-toggle-readonly-knob-icon-color: #f1f3f4; - - --cc-section-category-border: #e0e0e0; - - --cc-cookie-category-block-bg: #f1f3f4; - --cc-cookie-category-block-border: #f1f3f4; - --cc-cookie-category-block-hover-bg: #e9eff4; - --cc-cookie-category-block-hover-border: #e9eff4; - - --cc-cookie-category-expanded-block-bg: #f1f3f4; - --cc-cookie-category-expanded-block-hover-bg: #e9eff4; - - --cc-footer-bg: #ffffff; - --cc-footer-color: #1c1c1c; - --cc-footer-border-color: #ffffff; -} - -/* Dark theme variables */ -.cc--darkmode { - --cc-bg: #2d2d2d; - --cc-primary-color: #e5e5e5; - --cc-secondary-color: #b0b0b0; - - --cc-btn-primary-bg: #4dabf7; - --cc-btn-primary-color: #ffffff; - --cc-btn-primary-border-color: #4dabf7; - --cc-btn-primary-hover-bg: #3d3d3d; - --cc-btn-primary-hover-color: #ffffff; - --cc-btn-primary-hover-border-color: #3d3d3d; - - --cc-btn-secondary-bg: #3d3d3d; - --cc-btn-secondary-color: #ffffff; - --cc-btn-secondary-border-color: #3d3d3d; - --cc-btn-secondary-hover-bg: #4dabf7; - --cc-btn-secondary-hover-color: #ffffff; - --cc-btn-secondary-hover-border-color: #4dabf7; - - --cc-separator-border-color: #555555; - - --cc-toggle-on-bg: #4dabf7; - --cc-toggle-off-bg: #667481; - --cc-toggle-on-knob-bg: #2d2d2d; - --cc-toggle-off-knob-bg: #2d2d2d; - - --cc-toggle-enabled-icon-color: #2d2d2d; - --cc-toggle-disabled-icon-color: #2d2d2d; - - --cc-toggle-readonly-bg: #555555; - --cc-toggle-readonly-knob-bg: #8e8e8e; - --cc-toggle-readonly-knob-icon-color: #555555; - - --cc-section-category-border: #555555; - - --cc-cookie-category-block-bg: #3d3d3d; - --cc-cookie-category-block-border: #3d3d3d; - --cc-cookie-category-block-hover-bg: #4d4d4d; - --cc-cookie-category-block-hover-border: #4d4d4d; - - --cc-cookie-category-expanded-block-bg: #3d3d3d; - --cc-cookie-category-expanded-block-hover-bg: #4d4d4d; - - --cc-footer-bg: #2d2d2d; - --cc-footer-color: #e5e5e5; - --cc-footer-border-color: #2d2d2d; -} -.cm__body { - max-width: 90% !important; - flex-direction: row !important; - align-items: center !important; -} - -.cm__desc { - max-width: 70rem !important; -} - -.cm__btns { - flex-direction: row-reverse !important; - gap: 10px !important; - padding-top: 3.4rem !important; -} - -@media only screen and (max-width: 1400px) { - .cm__body { - max-width: 90% !important; - flex-direction: column !important; - align-items: normal !important; - } - - .cm__btns { - padding-top: 1rem !important; - } -} - -/* Toggle visibility fixes */ -#cc-main .section__toggle { - opacity: 0 !important; /* Keep invisible but functional */ -} - -#cc-main .toggle__icon { - display: flex !important; - align-items: center !important; - justify-content: flex-start !important; -} - -#cc-main .toggle__icon-circle { - display: block !important; - position: absolute !important; - transition: transform 0.25s ease !important; -} - -#cc-main .toggle__icon-on, -#cc-main .toggle__icon-off { - display: flex !important; - align-items: center !important; - justify-content: center !important; - position: absolute !important; - width: 100% !important; - height: 100% !important; -} - -/* Ensure toggles are visible in both themes */ -#cc-main .toggle__icon { - background: var(--cc-toggle-off-bg) !important; - border: 1px solid var(--cc-toggle-off-bg) !important; -} - -#cc-main .section__toggle:checked ~ .toggle__icon { - background: var(--cc-toggle-on-bg) !important; - border: 1px solid var(--cc-toggle-on-bg) !important; -} - -/* Ensure toggle text is visible */ -#cc-main .pm__section-title { - color: var(--cc-primary-color) !important; -} - -#cc-main .pm__section-desc { - color: var(--cc-secondary-color) !important; -} - -/* Make sure the modal has proper contrast */ -#cc-main .pm { - background: var(--cc-bg) !important; - color: var(--cc-primary-color) !important; -} - -/* Lower z-index so cookie banner appears behind onboarding modals */ -#cc-main { - z-index: 100 !important; -} - -/* Ensure consent modal text is visible in both themes */ -#cc-main .cm { - background: var(--cc-bg) !important; - color: var(--cc-primary-color) !important; -} - -#cc-main .cm__title { - color: var(--cc-primary-color) !important; -} - -#cc-main .cm__desc { - color: var(--cc-primary-color) !important; -} - -#cc-main .cm__footer { - color: var(--cc-primary-color) !important; -} - -#cc-main .cm__footer-links a, -#cc-main .cm__link { - color: var(--cc-primary-color) !important; -} 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.png b/app/core/src/main/resources/static/favicon.png deleted file mode 100644 index 5edc6eae26..0000000000 Binary files a/app/core/src/main/resources/static/favicon.png and /dev/null 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/Files.svg b/app/core/src/main/resources/static/images/Files.svg deleted file mode 100644 index be11edc054..0000000000 --- a/app/core/src/main/resources/static/images/Files.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/arrow-right-short.svg b/app/core/src/main/resources/static/images/arrow-right-short.svg deleted file mode 100644 index 86978c4ffa..0000000000 --- a/app/core/src/main/resources/static/images/arrow-right-short.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/book.svg b/app/core/src/main/resources/static/images/book.svg deleted file mode 100644 index 4634bfcb91..0000000000 --- a/app/core/src/main/resources/static/images/book.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/clipboard.svg b/app/core/src/main/resources/static/images/clipboard.svg deleted file mode 100644 index f46fc6d28f..0000000000 --- a/app/core/src/main/resources/static/images/clipboard.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/discord.svg b/app/core/src/main/resources/static/images/discord.svg deleted file mode 100644 index 1f1592dbac..0000000000 --- a/app/core/src/main/resources/static/images/discord.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/docker.svg b/app/core/src/main/resources/static/images/docker.svg deleted file mode 100644 index ab6b8b05d7..0000000000 --- a/app/core/src/main/resources/static/images/docker.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/file-earmark-pdf.svg b/app/core/src/main/resources/static/images/file-earmark-pdf.svg deleted file mode 100644 index af24849081..0000000000 --- a/app/core/src/main/resources/static/images/file-earmark-pdf.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/github.svg b/app/core/src/main/resources/static/images/github.svg deleted file mode 100644 index 82a1aeb7d4..0000000000 --- a/app/core/src/main/resources/static/images/github.svg +++ /dev/null @@ -1 +0,0 @@ -Github-colorCreated with Sketch. \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/google-drive.svg b/app/core/src/main/resources/static/images/google-drive.svg deleted file mode 100644 index 03b2f21290..0000000000 --- a/app/core/src/main/resources/static/images/google-drive.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/redact-auto.svg b/app/core/src/main/resources/static/images/redact-auto.svg deleted file mode 100644 index 7c29b0fc4b..0000000000 --- a/app/core/src/main/resources/static/images/redact-auto.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/redact-manual.svg b/app/core/src/main/resources/static/images/redact-manual.svg deleted file mode 100644 index 62296625e3..0000000000 --- a/app/core/src/main/resources/static/images/redact-manual.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/core/src/main/resources/static/images/rename.svg b/app/core/src/main/resources/static/images/rename.svg deleted file mode 100644 index 97946a086d..0000000000 --- a/app/core/src/main/resources/static/images/rename.svg +++ /dev/null @@ -1 +0,0 @@ -rename \ 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/main/resources/static/images/split-auto.svg b/app/core/src/main/resources/static/images/split-auto.svg deleted file mode 100644 index 1aa08fcc39..0000000000 --- a/app/core/src/main/resources/static/images/split-auto.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/core/src/main/resources/static/images/split-chapters.svg b/app/core/src/main/resources/static/images/split-chapters.svg deleted file mode 100644 index 4a3ad37d1a..0000000000 --- a/app/core/src/main/resources/static/images/split-chapters.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/core/src/main/resources/static/images/split-size.svg b/app/core/src/main/resources/static/images/split-size.svg deleted file mode 100644 index 9fd1840fce..0000000000 --- a/app/core/src/main/resources/static/images/split-size.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/core/src/main/resources/static/images/update.svg b/app/core/src/main/resources/static/images/update.svg deleted file mode 100644 index b043120221..0000000000 --- a/app/core/src/main/resources/static/images/update.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/js/thirdParty/cookieconsent.umd.js b/app/core/src/main/resources/static/js/thirdParty/cookieconsent.umd.js deleted file mode 100644 index 7f85a316a1..0000000000 --- a/app/core/src/main/resources/static/js/thirdParty/cookieconsent.umd.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! -* CookieConsent 3.1.0 -* https://github.com/orestbida/cookieconsent -* Author Orest Bida -* Released under the MIT License -*/ -var e,t;e=this,t=function(e){'use strict';const t='opt-in',o='opt-out',n='show--consent',s='show--preferences',a='disable--interaction',c='data-category',r='div',i='button',l='aria-hidden',d='btn-group',f='click',_='data-role',u='consentModal',p='preferencesModal';class g{constructor(){this.t={mode:t,revision:0,autoShow:!0,lazyHtmlGeneration:!0,autoClearCookies:!0,manageScriptTags:!0,hideFromBots:!0,cookie:{name:'cc_cookie',expiresAfterDays:182,domain:'',path:'/',secure:!0,sameSite:'Lax'}},this.o={i:{},l:'',_:{},u:{},p:{},m:[],v:!1,h:null,C:null,S:null,M:'',T:!0,D:!1,k:!1,A:!1,N:!1,H:[],V:!1,j:!0,I:[],L:!1,F:'',P:!1,O:[],R:[],B:[],$:[],G:!1,J:!1,U:!1,q:[],K:[],W:[],X:{},Y:{},Z:{},ee:{},te:{},oe:[]},this.ne={se:{},ae:{}},this.ce={},this.re={ie:'cc:onFirstConsent',le:'cc:onConsent',de:'cc:onChange',fe:'cc:onModalShow',_e:'cc:onModalHide',ue:'cc:onModalReady'}}}const m=new g,b=(e,t)=>e.indexOf(t),v=(e,t)=>-1!==b(e,t),y=e=>Array.isArray(e),h=e=>'string'==typeof e,C=e=>!!e&&'object'==typeof e&&!y(e),w=e=>'function'==typeof e,S=e=>Object.keys(e),x=e=>Array.from(new Set(e)),M=()=>document.activeElement,T=e=>e.preventDefault(),D=(e,t)=>e.querySelectorAll(t),k=e=>e.dispatchEvent(new Event('change')),E=e=>{const t=document.createElement(e);return e===i&&(t.type=e),t},A=(e,t,o)=>e.setAttribute(t,o),N=(e,t,o)=>{e.removeAttribute(o?'data-'+t:t)},H=(e,t,o)=>e.getAttribute(o?'data-'+t:t),V=(e,t)=>e.appendChild(t),j=(e,t)=>e.classList.add(t),I=(e,t)=>j(e,'cm__'+t),L=(e,t)=>j(e,'pm__'+t),F=(e,t)=>e.classList.remove(t),P=e=>{if('object'!=typeof e)return e;if(e instanceof Date)return new Date(e.getTime());let t=Array.isArray(e)?[]:{};for(let o in e){let n=e[o];t[o]=P(n)}return t},O=()=>{const e={},{O:t,X:o,Y:n}=m.o;for(const s of t)e[s]=G(n[s],S(o[s]));return e},R=(e,t)=>dispatchEvent(new CustomEvent(e,{detail:t})),B=(e,t,o,n)=>{e.addEventListener(t,o),n&&m.o.m.push({pe:e,ge:t,me:o})},$=()=>{const e=m.t.cookie.expiresAfterDays;return w(e)?e(m.o.F):e},G=(e,t)=>{const o=e||[],n=t||[];return o.filter((e=>!v(n,e))).concat(n.filter((e=>!v(o,e))))},J=e=>{m.o.R=x(e),m.o.F=(()=>{let e='custom';const{R:t,O:o,B:n}=m.o,s=t.length;return s===o.length?e='all':s===n.length&&(e='necessary'),e})()},U=(e,t,o,n)=>{const s='accept-',{show:a,showPreferences:c,hide:r,hidePreferences:i,acceptCategory:l}=t,d=e||document,_=e=>D(d,`[data-cc="${e}"]`),u=(e,t)=>{T(e),l(t),i(),r()},p=_('show-preferencesModal'),g=_('show-consentModal'),b=_(s+'all'),v=_(s+'necessary'),y=_(s+'custom'),h=m.t.lazyHtmlGeneration;for(const e of p)A(e,'aria-haspopup','dialog'),B(e,f,(e=>{T(e),c()})),h&&(B(e,'mouseenter',(e=>{T(e),m.o.N||o(t,n)}),!0),B(e,'focus',(()=>{m.o.N||o(t,n)})));for(let e of g)A(e,'aria-haspopup','dialog'),B(e,f,(e=>{T(e),a(!0)}),!0);for(let e of b)B(e,f,(e=>{u(e,'all')}),!0);for(let e of y)B(e,f,(e=>{u(e)}),!0);for(let e of v)B(e,f,(e=>{u(e,[])}),!0)},z=(e,t)=>{e&&(t&&(e.tabIndex=-1),e.focus(),t&&e.removeAttribute('tabindex'))},q=(e,t)=>{const o=n=>{n.target.removeEventListener('transitionend',o),'opacity'===n.propertyName&&'1'===getComputedStyle(e).opacity&&z((e=>1===e?m.ne.be:m.ne.ve)(t))};B(e,'transitionend',o)};let K;const Q=e=>{clearTimeout(K),e?j(m.ne.ye,a):K=setTimeout((()=>{F(m.ne.ye,a)}),500)},W=['M 19.5 4.5 L 4.5 19.5 M 4.5 4.501 L 19.5 19.5','M 3.572 13.406 L 8.281 18.115 L 20.428 5.885','M 21.999 6.94 L 11.639 17.18 L 2.001 6.82 '],X=(e=0,t=1.5)=>``,Y=e=>{const t=m.ne,o=m.o;(e=>{const n=e===t.he,s=o.i.disablePageInteraction?t.ye:n?t.Ce:t.ye;B(s,'keydown',(t=>{if('Tab'!==t.key||!(n?o.k&&!o.A:o.A))return;const s=M(),a=n?o.q:o.K;0!==a.length&&(t.shiftKey?s!==a[0]&&e.contains(s)||(T(t),z(a[1])):s!==a[1]&&e.contains(s)||(T(t),z(a[0])))}),!0)})(e)},Z=['[href]',i,'input','details','[tabindex]'].map((e=>e+':not([tabindex="-1"])')).join(','),ee=e=>{const{o:t,ne:o}=m,n=(e,t)=>{const o=D(e,Z);t[0]=o[0],t[1]=o[o.length-1]};1===e&&t.D&&n(o.he,t.q),2===e&&t.N&&n(o.we,t.K)},te=(e,t,o)=>{const{de:n,le:s,ie:a,_e:c,ue:r,fe:i}=m.ce,l=m.re;if(t){const n={modalName:t};return e===l.fe?w(i)&&i(n):e===l._e?w(c)&&c(n):(n.modal=o,w(r)&&r(n)),R(e,n)}const d={cookie:m.o.p};e===l.ie?w(a)&&a(P(d)):e===l.le?w(s)&&s(P(d)):(d.changedCategories=m.o.I,d.changedServices=m.o.ee,w(n)&&n(P(d))),R(e,P(d))},oe=(e,t)=>{try{return e()}catch(e){return!t&&console.warn('CookieConsent:',e),!1}},ne=e=>{const{Y:t,ee:o,O:n,X:s,oe:a,p:r,I:i}=m.o;for(const e of n){const n=o[e]||t[e]||[];for(const o of n){const n=s[e][o];if(!n)continue;const{onAccept:a,onReject:c}=n;!n.Se&&v(t[e],o)?(n.Se=!0,w(a)&&a()):n.Se&&!v(t[e],o)&&(n.Se=!1,w(c)&&c())}}if(!m.t.manageScriptTags)return;const l=a,d=e||r.categories||[],f=(e,n)=>{if(n>=e.length)return;const s=a[n];if(s.xe)return f(e,n+1);const r=s.Me,l=s.Te,_=s.De,u=v(d,l),p=!!_&&v(t[l],_);if(!_&&!s.ke&&u||!_&&s.ke&&!u&&v(i,l)||_&&!s.ke&&p||_&&s.ke&&!p&&v(o[l]||[],_)){s.xe=!0;const t=H(r,'type',!0);N(r,'type',!!t),N(r,c);let o=H(r,'src',!0);o&&N(r,'src',!0);const a=E('script');a.textContent=r.innerHTML;for(const{nodeName:e}of r.attributes)A(a,e,r[e]||H(r,e));t&&(a.type=t),o?a.src=o:o=r.src;const i=!!o&&(!t||['text/javascript','module'].includes(t));if(i&&(a.onload=a.onerror=()=>{f(e,++n)}),r.replaceWith(a),i)return}f(e,++n)};f(l,0)},se='bottom',ae='left',ce='center',re='right',ie='inline',le='wide',de='pm--',fe=['middle','top',se],_e=[ae,ce,re],ue={box:{Ee:[le,ie],Ae:fe,Ne:_e,He:se,Ve:re},cloud:{Ee:[ie],Ae:fe,Ne:_e,He:se,Ve:ce},bar:{Ee:[ie],Ae:fe.slice(1),Ne:[],He:se,Ve:''}},pe={box:{Ee:[],Ae:[],Ne:[],He:'',Ve:''},bar:{Ee:[le],Ae:[],Ne:[ae,re],He:'',Ve:ae}},ge=e=>{const t=m.o.i.guiOptions,o=t&&t.consentModal,n=t&&t.preferencesModal;0===e&&me(m.ne.he,ue,o,'cm--','box','cm'),1===e&&me(m.ne.we,pe,n,de,'box','pm')},me=(e,t,o,n,s,a)=>{e.className=a;const c=o&&o.layout,r=o&&o.position,i=o&&o.flipButtons,l=!o||!1!==o.equalWeightButtons,d=c&&c.split(' ')||[],f=d[0],_=d[1],u=f in t?f:s,p=t[u],g=v(p.Ee,_)&&_,b=r&&r.split(' ')||[],y=b[0],h=n===de?b[0]:b[1],C=v(p.Ae,y)?y:p.He,w=v(p.Ne,h)?h:p.Ve,S=t=>{t&&j(e,n+t)};S(u),S(g),S(C),S(w),i&&S('flip');const x=a+'__btn--secondary';if('cm'===a){const{je:e,Ie:t}=m.ne;e&&(l?F(e,x):j(e,x)),t&&(l?F(t,x):j(t,x))}else{const{Le:e}=m.ne;e&&(l?F(e,x):j(e,x))}},be=(e,t)=>{const o=m.o,n=m.ne,{hide:s,hidePreferences:a,acceptCategory:c}=e,u=e=>{c(e),a(),s()},g=o.u&&o.u.preferencesModal;if(!g)return;const b=g.title,v=g.closeIconLabel,y=g.acceptAllBtn,w=g.acceptNecessaryBtn,x=g.savePreferencesBtn,M=g.sections||[],T=y||w||x;if(n.Fe)n.Pe=E(r),L(n.Pe,'body');else{n.Fe=E(r),j(n.Fe,'pm-wrapper');const e=E('div');j(e,'pm-overlay'),V(n.Fe,e),B(e,f,a),n.we=E(r),j(n.we,'pm'),A(n.we,'role','dialog'),A(n.we,l,!0),A(n.we,'aria-modal',!0),A(n.we,'aria-labelledby','pm__title'),B(n.ye,'keydown',(e=>{27===e.keyCode&&a()}),!0),n.Oe=E(r),L(n.Oe,'header'),n.Re=E('h2'),L(n.Re,'title'),n.Re.id='pm__title',n.Be=E(i),L(n.Be,'close-btn'),A(n.Be,'aria-label',g.closeIconLabel||''),B(n.Be,f,a),n.$e=E('span'),n.$e.innerHTML=X(),V(n.Be,n.$e),n.Ge=E(r),L(n.Ge,'body'),n.Je=E(r),L(n.Je,'footer');var D=E(r);j(D,'btns');var k=E(r),N=E(r);L(k,d),L(N,d),V(n.Je,k),V(n.Je,N),V(n.Oe,n.Re),V(n.Oe,n.Be),n.ve=E(r),A(n.ve,'tabIndex',-1),V(n.we,n.ve),V(n.we,n.Oe),V(n.we,n.Ge),T&&V(n.we,n.Je),V(n.Fe,n.we)}let H;b&&(n.Re.innerHTML=b,v&&A(n.Be,'aria-label',v)),M.forEach(((e,t)=>{const s=e.title,a=e.description,c=e.linkedCategory,d=c&&o.P[c],_=e.cookieTable,u=_&&_.body,p=_&&_.caption,m=u&&u.length>0,b=!!d,v=b&&o.X[c],y=C(v)&&S(v)||[],w=b&&(!!a||!!m||S(v).length>0);var x=E(r);if(L(x,'section'),w||a){var M=E(r);L(M,'section-desc-wrapper')}let T=y.length;if(w&&T>0){const e=E(r);L(e,'section-services');for(const t of y){const o=v[t],n=o&&o.label||t,s=E(r),a=E(r),i=E(r),l=E(r);L(s,'service'),L(l,'service-title'),L(a,'service-header'),L(i,'service-icon');const f=ve(n,t,d,!0,c);l.innerHTML=n,V(a,i),V(a,l),V(s,a),V(s,f),V(e,s)}V(M,e)}if(s){var D=E(r),k=E(b?i:r);if(L(D,'section-title-wrapper'),L(k,'section-title'),k.innerHTML=s,V(D,k),b){const e=E('span');e.innerHTML=X(2,3.5),L(e,'section-arrow'),V(D,e),x.className+='--toggle';const t=ve(s,c,d);let o=g.serviceCounterLabel;if(T>0&&h(o)){let e=E('span');L(e,'badge'),L(e,'service-counter'),A(e,l,!0),A(e,'data-servicecounter',T),o&&(o=o.split('|'),o=o.length>1&&T>1?o[1]:o[0],A(e,'data-counterlabel',o)),e.innerHTML=T+(o?' '+o:''),V(k,e)}if(w){L(x,'section--expandable');var N=c+'-desc';A(k,'aria-expanded',!1),A(k,'aria-controls',N)}V(D,t)}else A(k,'role','heading'),A(k,'aria-level','3');V(x,D)}if(a){var I=E('p');L(I,'section-desc'),I.innerHTML=a,V(M,I)}if(w&&(A(M,l,'true'),M.id=N,((e,t,o)=>{B(k,f,(()=>{t.classList.contains('is-expanded')?(F(t,'is-expanded'),A(o,'aria-expanded','false'),A(e,l,'true')):(j(t,'is-expanded'),A(o,'aria-expanded','true'),A(e,l,'false'))}))})(M,x,k),m)){const e=E('table'),o=E('thead'),s=E('tbody');if(p){const t=E('caption');L(t,'table-caption'),t.innerHTML=p,e.appendChild(t)}L(e,'section-table'),L(o,'table-head'),L(s,'table-body');const a=_.headers,c=S(a),i=n.Ue.createDocumentFragment(),l=E('tr');for(const e of c){const o=a[e],n=E('th');n.id='cc__row-'+o+t,A(n,'scope','col'),L(n,'table-th'),n.innerHTML=o,V(i,n)}V(l,i),V(o,l);const d=n.Ue.createDocumentFragment();for(const e of u){const o=E('tr');L(o,'table-tr');for(const n of c){const s=a[n],c=e[n],i=E('td'),l=E(r);L(i,'table-td'),A(i,'data-column',s),A(i,'headers','cc__row-'+s+t),l.insertAdjacentHTML('beforeend',c),V(i,l),V(o,i)}V(d,o)}V(s,d),V(e,o),V(e,s),V(M,e)}(w||a)&&V(x,M);const P=n.Pe||n.Ge;b?(H||(H=E(r),L(H,'section-toggles')),H.appendChild(x)):H=null,V(P,H||x)})),y&&(n.ze||(n.ze=E(i),L(n.ze,'btn'),A(n.ze,_,'all'),V(k,n.ze),B(n.ze,f,(()=>u('all')))),n.ze.innerHTML=y),w&&(n.Le||(n.Le=E(i),L(n.Le,'btn'),A(n.Le,_,'necessary'),V(k,n.Le),B(n.Le,f,(()=>u([])))),n.Le.innerHTML=w),x&&(n.qe||(n.qe=E(i),L(n.qe,'btn'),L(n.qe,'btn--secondary'),A(n.qe,_,'save'),V(N,n.qe),B(n.qe,f,(()=>u()))),n.qe.innerHTML=x),n.Pe&&(n.we.replaceChild(n.Pe,n.Ge),n.Ge=n.Pe),ge(1),o.N||(o.N=!0,te(m.re.ue,p,n.we),t(e),V(n.Ce,n.Fe),Y(n.we),setTimeout((()=>j(n.Fe,'cc--anim')),100)),ee(2)};function ve(e,t,o,n,s){const a=m.o,r=m.ne,i=E('label'),d=E('input'),_=E('span'),u=E('span'),p=E('span'),g=E('span'),b=E('span');if(g.innerHTML=X(1,3),b.innerHTML=X(0,3),d.type='checkbox',j(i,'section__toggle-wrapper'),j(d,'section__toggle'),j(g,'toggle__icon-on'),j(b,'toggle__icon-off'),j(_,'toggle__icon'),j(u,'toggle__icon-circle'),j(p,'toggle__label'),A(_,l,'true'),n?(j(i,'toggle-service'),A(d,c,s),r.ae[s][t]=d):r.se[t]=d,n?(e=>{B(d,'change',(()=>{const t=r.ae[e],o=r.se[e];a.Z[e]=[];for(let o in t){const n=t[o];n.checked&&a.Z[e].push(n.value)}o.checked=a.Z[e].length>0}))})(s):(e=>{B(d,f,(()=>{const t=r.ae[e],o=d.checked;a.Z[e]=[];for(let n in t)t[n].checked=o,o&&a.Z[e].push(n)}))})(t),d.value=t,p.textContent=e.replace(/<.*>.*<\/.*>/gm,''),V(u,b),V(u,g),V(_,u),a.T)(o.readOnly||o.enabled)&&(d.checked=!0);else if(n){const e=a.Y[s];d.checked=o.readOnly||v(e,t)}else v(a.R,t)&&(d.checked=!0);return o.readOnly&&(d.disabled=!0),V(i,d),V(i,_),V(i,p),i}const ye=()=>{const e=E('span');return m.ne.Ke||(m.ne.Ke=e),e},he=(e,t)=>{const o=m.o,n=m.ne,{hide:s,showPreferences:a,acceptCategory:c}=e,p=o.u&&o.u.consentModal;if(!p)return;const g=p.acceptAllBtn,b=p.acceptNecessaryBtn,v=p.showPreferencesBtn,y=p.closeIconLabel,h=p.footer,C=p.label,w=p.title,S=e=>{s(),c(e)};if(!n.Qe){n.Qe=E(r),n.he=E(r),n.We=E(r),n.Xe=E(r),n.Ye=E(r),j(n.Qe,'cm-wrapper'),j(n.he,'cm'),I(n.We,'body'),I(n.Xe,'texts'),I(n.Ye,'btns'),A(n.he,'role','dialog'),A(n.he,'aria-modal','true'),A(n.he,l,'false'),A(n.he,'aria-describedby','cm__desc'),C?A(n.he,'aria-label',C):w&&A(n.he,'aria-labelledby','cm__title');const e='box',t=o.i.guiOptions,s=t&&t.consentModal,a=(s&&s.layout||e).split(' ')[0]===e;w&&y&&a&&(n.Ie||(n.Ie=E(i),n.Ie.innerHTML=X(),I(n.Ie,'btn'),I(n.Ie,'btn--close'),B(n.Ie,f,(()=>{S([])})),V(n.We,n.Ie)),A(n.Ie,'aria-label',y)),V(n.We,n.Xe),(g||b||v)&&V(n.We,n.Ye),n.be=E(r),A(n.be,'tabIndex',-1),V(n.he,n.be),V(n.he,n.We),V(n.Qe,n.he)}w&&(n.Ze||(n.Ze=E('h2'),n.Ze.className=n.Ze.id='cm__title',V(n.Xe,n.Ze)),n.Ze.innerHTML=w);let x=p.description;if(x&&(o.V&&(x=x.replace('{{revisionMessage}}',o.j?'':p.revisionMessage||'')),n.et||(n.et=E('p'),n.et.className=n.et.id='cm__desc',V(n.Xe,n.et)),n.et.innerHTML=x),g&&(n.tt||(n.tt=E(i),V(n.tt,ye()),I(n.tt,'btn'),A(n.tt,_,'all'),B(n.tt,f,(()=>{S('all')}))),n.tt.firstElementChild.innerHTML=g),b&&(n.je||(n.je=E(i),V(n.je,ye()),I(n.je,'btn'),A(n.je,_,'necessary'),B(n.je,f,(()=>{S([])}))),n.je.firstElementChild.innerHTML=b),v&&(n.ot||(n.ot=E(i),V(n.ot,ye()),I(n.ot,'btn'),I(n.ot,'btn--secondary'),A(n.ot,_,'show'),B(n.ot,'mouseenter',(()=>{o.N||be(e,t)})),B(n.ot,f,a)),n.ot.firstElementChild.innerHTML=v),n.nt||(n.nt=E(r),I(n.nt,d),g&&V(n.nt,n.tt),b&&V(n.nt,n.je),(g||b)&&V(n.We,n.nt),V(n.Ye,n.nt)),n.ot&&!n.st&&(n.st=E(r),n.je&&n.tt?(I(n.st,d),V(n.st,n.ot),V(n.Ye,n.st)):(V(n.nt,n.ot),I(n.nt,d+'--uneven'))),h){if(!n.ct){let e=E(r),t=E(r);n.ct=E(r),I(e,'footer'),I(t,'links'),I(n.ct,'link-group'),V(t,n.ct),V(e,t),V(n.he,e)}n.ct.innerHTML=h}ge(0),o.D||(o.D=!0,te(m.re.ue,u,n.he),t(e),V(n.Ce,n.Qe),Y(n.he),setTimeout((()=>j(n.Qe,'cc--anim')),100)),ee(1),U(n.We,e,be,t)},Ce=e=>{if(!h(e))return null;if(e in m.o._)return e;let t=e.slice(0,2);return t in m.o._?t:null},we=()=>m.o.l||m.o.i.language.default,Se=e=>{e&&(m.o.l=e)},xe=async e=>{const t=m.o;let o=Ce(e)?e:we(),n=t._[o];if(h(n)?n=await(async e=>{try{const t=await fetch(e);return await t.json()}catch(e){return console.error(e),!1}})(n):w(n)&&(n=await n()),!n)throw`Could not load translation for the '${o}' language`;return t.u=n,Se(o),!0},Me=()=>{let e=m.o.i.language.rtl,t=m.ne.Ce;e&&t&&(y(e)||(e=[e]),v(e,m.o.l)?j(t,'cc--rtl'):F(t,'cc--rtl'))},Te=()=>{const e=m.ne;if(e.Ce)return;e.Ce=E(r),e.Ce.id='cc-main',e.Ce.setAttribute('data-nosnippet',''),Me();let t=m.o.i.root;t&&h(t)&&(t=document.querySelector(t)),(t||e.Ue.body).appendChild(e.Ce)},De=e=>oe((()=>localStorage.removeItem(e))),ke=(e,t)=>{if(t instanceof RegExp)return e.filter((e=>t.test(e)));{const o=b(e,t);return o>-1?[e[o]]:[]}},Ee=e=>{const{hostname:t,protocol:o}=location,{name:n,path:s,domain:a,sameSite:c,useLocalStorage:r,secure:i}=m.t.cookie,l=e?(()=>{const e=m.o.S,t=e?new Date-e:0;return 864e5*$()-t})():864e5*$(),d=new Date;d.setTime(d.getTime()+l),m.o.p.expirationTime=d.getTime();const f=JSON.stringify(m.o.p);let _=n+'='+encodeURIComponent(f)+(0!==l?'; expires='+d.toUTCString():'')+'; Path='+s+'; SameSite='+c;v(t,'.')&&(_+='; Domain='+a),i&&'https:'===o&&(_+='; Secure'),r?((e,t)=>{oe((()=>localStorage.setItem(e,t)))})(n,f):document.cookie=_,m.o.p},Ae=(e,t,o)=>{if(0===e.length)return;const n=o||m.t.cookie.domain,s=t||m.t.cookie.path,a='www.'===n.slice(0,4),c=a&&n.substring(4),r=(e,t)=>{t&&'.'!==t.slice(0,1)&&(t='.'+t),document.cookie=e+'=; path='+s+(t?'; domain='+t:'')+'; expires=Thu, 01 Jan 1970 00:00:01 GMT;'};for(const t of e)r(t,o),o||r(t,n),a&&r(t,c)},Ne=e=>{const t=e||m.t.cookie.name,o=m.t.cookie.useLocalStorage;return((e,t)=>{let o;return o=oe((()=>JSON.parse(t?e:decodeURIComponent(e))),!0)||{},o})(o?(n=t,oe((()=>localStorage.getItem(n)))||''):He(t,!0),o);var n},He=(e,t)=>{const o=document.cookie.match('(^|;)\\s*'+e+'\\s*=\\s*([^;]+)');return o?t?o.pop():e:''},Ve=e=>{const t=document.cookie.split(/;\s*/),o=[];for(const n of t){let t=n.split('=')[0];e?oe((()=>{e.test(t)&&o.push(t)})):o.push(t)}return o},je=(e,n=[])=>{((e,t)=>{const{O:o,R:n,B:s,N:a,Z:c,$:r,X:i}=m.o;let l=[];if(e){y(e)?l.push(...e):h(e)&&(l='all'===e?o:[e]);for(const e of o)c[e]=v(l,e)?S(i[e]):[]}else l=[...n,...r],a&&(l=(()=>{const e=m.ne.se;if(!e)return[];let t=[];for(let o in e)e[o].checked&&t.push(o);return t})());l=l.filter((e=>!v(o,e)||!v(t,e))),l.push(...s),J(l)})(e,n),(()=>{const e=m.o,{Z:t,B:o,Y:n,X:s,O:a}=e,c=a;e.te=P(n);for(const a of c){const c=s[a],r=S(c),i=t[a]&&t[a].length>0,l=v(o,a);if(0!==r.length){if(n[a]=[],l)n[a].push(...r);else if(i){const e=t[a];n[a].push(...e)}else n[a]=e.Z[a];n[a]=x(n[a])}}})(),(()=>{const e=m.o;e.I=m.t.mode===o&&e.T?G(e.$,e.R):G(e.R,e.p.categories);let n=e.I.length>0,s=!1;for(const t of e.O)e.ee[t]=G(e.Y[t],e.te[t]),e.ee[t].length>0&&(s=!0);const a=m.ne.se;for(const t in a)a[t].checked=v(e.R,t);for(const t of e.O){const o=m.ne.ae[t],n=e.Y[t];for(const e in o)o[e].checked=v(n,e)}e.C||(e.C=new Date),e.M||(e.M=([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,(e=>(e^crypto.getRandomValues(new Uint8Array(1))[0]&15>>e/4).toString(16)))),e.p={categories:P(e.R),revision:m.t.revision,data:e.h,consentTimestamp:e.C.toISOString(),consentId:e.M,services:P(e.Y),languageCode:m.o.l},e.S&&(e.p.lastConsentTimestamp=e.S.toISOString());let c=!1;const r=n||s;(e.T||r)&&(e.T&&(e.T=!1,c=!0),e.S=e.S?new Date:e.C,e.p.lastConsentTimestamp=e.S.toISOString(),Ee(),m.t.autoClearCookies&&(c||r)&&(e=>{const t=m.o,o=Ve(),n=(e=>{const t=m.o;return(e?t.O:t.I).filter((e=>{const o=t.P[e];return!!o&&!o.readOnly&&!!o.autoClear}))})(e);for(const e in t.ee)for(const n of t.ee[e]){const s=t.X[e][n].cookies;if(!v(t.Y[e],n)&&s)for(const e of s){const t=ke(o,e.name);Ae(t,e.path,e.domain)}}for(const s of n){const n=t.P[s].autoClear,a=n&&n.cookies||[],c=v(t.I,s),r=!v(t.R,s),i=c&&r;if(e?r:i){n.reloadPage&&i&&(t.L=!0);for(const e of a){const t=ke(o,e.name);Ae(t,e.path,e.domain)}}}})(c),ne()),c&&(te(m.re.ie),te(m.re.le),m.t.mode===t)||(r&&te(m.re.de),e.L&&(e.L=!1,location.reload()))})()},Ie=e=>{const t=m.o.T?[]:m.o.R;return v(t,e)},Le=(e,t)=>{const o=m.o.T?[]:m.o.Y[t]||[];return v(o,e)},Fe=(e,t,o)=>{let n=[];const s=e=>{if(h(e)){let t=He(e);''!==t&&n.push(t)}else n.push(...Ve(e))};if(y(e))for(let t of e)s(t);else s(e);Ae(n,t,o)},Pe=e=>{const{ne:t,o:o}=m;if(!o.k){if(!o.D){if(!e)return;he($e,Te)}o.k=!0,o.J=M(),o.v&&Q(!0),q(t.he,1),j(t.ye,n),A(t.he,l,'false'),setTimeout((()=>{z(m.ne.be)}),100),te(m.re.fe,u)}},Oe=()=>{const{ne:e,o:t,re:o}=m;t.k&&(t.k=!1,t.v&&Q(),z(e.Ke,!0),F(e.ye,n),A(e.he,l,'true'),z(t.J),t.J=null,te(o._e,u))},Re=()=>{const e=m.o;e.A||(e.N||be($e,Te),e.A=!0,e.k?e.U=M():e.J=M(),q(m.ne.we,2),j(m.ne.ye,s),A(m.ne.we,l,'false'),setTimeout((()=>{z(m.ne.ve)}),100),te(m.re.fe,p))},Be=()=>{const e=m.o;e.A&&(e.A=!1,(()=>{const e=Je(),t=m.o.P,o=m.ne.se,n=m.ne.ae,s=e=>v(m.o.$,e);for(const a in o){const c=!!t[a].readOnly;o[a].checked=c||(e?Ie(a):s(a));for(const t in n[a])n[a][t].checked=c||(e?Le(t,a):s(a))}})(),z(m.ne.$e,!0),F(m.ne.ye,s),A(m.ne.we,l,'true'),e.k?(z(e.U),e.U=null):(z(e.J),e.J=null),te(m.re._e,p))};var $e={show:Pe,hide:Oe,showPreferences:Re,hidePreferences:Be,acceptCategory:je};const Ge=(e,t)=>{const o=Ne(t);return e?o[e]:o},Je=()=>!m.o.T;e.acceptCategory=je,e.acceptService=(e,t)=>{const{O:o,X:n}=m.o;if(!(e&&t&&h(t)&&v(o,t)&&0!==S(n[t]).length))return!1;((e,t)=>{const o=m.o,{X:n,Z:s,N:a}=o,c=m.ne.ae[t]||{},r=m.ne.se[t]||{},i=S(n[t]);if(s[t]=[],h(e)){if('all'===e){if(s[t].push(...i),a)for(let e in c)c[e].checked=!0,k(c[e])}else if(v(i,e)&&s[t].push(e),a)for(let t in c)c[t].checked=e===t,k(c[t])}else if(y(e))for(let o of i){const n=v(e,o);n&&s[t].push(o),a&&(c[o].checked=n,k(c[o]))}const l=0===s[t].length;o.R=l?o.R.filter((e=>e!==t)):x([...o.R,t]),a&&(r.checked=!l,k(r))})(e,t),je()},e.acceptedCategory=Ie,e.acceptedService=Le,e.eraseCookies=Fe,e.getConfig=e=>{const t=m.t,o=m.o.i;return e?t[e]||o[e]:{...t,...o,cookie:{...t.cookie}}},e.getCookie=Ge,e.getUserPreferences=()=>{const{F:e,Y:t}=m.o,{accepted:o,rejected:n}=(()=>{const{T:e,R:t,O:o}=m.o;return{accepted:t,rejected:e?[]:o.filter((e=>!v(t,e)))}})();return P({acceptType:e,acceptedCategories:o,rejectedCategories:n,acceptedServices:t,rejectedServices:O()})},e.hide=Oe,e.hidePreferences=Be,e.loadScript=(e,t)=>{let o=document.querySelector('script[src="'+e+'"]');return new Promise((n=>{if(o)return n(!0);if(o=E('script'),C(t))for(const e in t)A(o,e,t[e]);o.onload=()=>n(!0),o.onerror=()=>{o.remove(),n(!1)},o.src=e,V(document.head,o)}))},e.reset=e=>{const{Ce:t,ye:o}=m.ne,{name:c,path:r,domain:i,useLocalStorage:l}=m.t.cookie;e&&(l?De(c):Fe(c,r,i));for(const{pe:e,ge:t,me:o}of m.o.m)e.removeEventListener(t,o);t&&t.remove(),o&&o.classList.remove(a,s,n);const d=new g;for(const e in m)m[e]=d[e];window._ccRun=!1},e.run=async e=>{const{o:t,t:n,re:s}=m,a=window;if(!a._ccRun){if(a._ccRun=!0,(e=>{const{ne:t,t:n,o:s}=m,a=n,r=s,{cookie:i}=a,l=m.ce,d=e.cookie,f=e.categories,_=S(f)||[],u=navigator,p=document;t.Ue=p,t.ye=p.documentElement,i.domain=location.hostname,r.i=e,r.P=f,r.O=_,r._=e.language.translations,r.v=!!e.disablePageInteraction,l.ie=e.onFirstConsent,l.le=e.onConsent,l.de=e.onChange,l._e=e.onModalHide,l.fe=e.onModalShow,l.ue=e.onModalReady;const{mode:g,autoShow:b,lazyHtmlGeneration:y,autoClearCookies:h,revision:w,manageScriptTags:x,hideFromBots:M}=e;g===o&&(a.mode=g),'boolean'==typeof h&&(a.autoClearCookies=h),'boolean'==typeof x&&(a.manageScriptTags=x),'number'==typeof w&&w>=0&&(a.revision=w,r.V=!0),'boolean'==typeof b&&(a.autoShow=b),'boolean'==typeof y&&(a.lazyHtmlGeneration=y),!1===M&&(a.hideFromBots=!1),!0===a.hideFromBots&&u&&(r.G=u.userAgent&&/bot|crawl|spider|slurp|teoma/i.test(u.userAgent)||u.webdriver),C(d)&&(a.cookie={...i,...d}),a.autoClearCookies,r.V,a.manageScriptTags,(e=>{const{P:t,X:o,Y:n,Z:s,B:a}=m.o;for(let c of e){const e=t[c],r=e.services||{},i=C(r)&&S(r)||[];o[c]={},n[c]=[],s[c]=[],e.readOnly&&(a.push(c),n[c]=i),m.ne.ae[c]={};for(let e of i){const t=r[e];t.Se=!1,o[c][e]=t}}})(_),(()=>{if(!m.t.manageScriptTags)return;const e=m.o,t=D(document,'script['+c+']');for(const o of t){let t=H(o,c),n=o.dataset.service||'',s=!1;if(t&&'!'===t.charAt(0)&&(t=t.slice(1),s=!0),'!'===n.charAt(0)&&(n=n.slice(1),s=!0),v(e.O,t)&&(e.oe.push({Me:o,xe:!1,ke:s,Te:t,De:n}),n)){const o=e.X[t];o[n]||(o[n]={Se:!1})}}})(),Se((()=>{const e=m.o.i.language.autoDetect;if(e){const t={browser:navigator.language,document:document.documentElement.lang},o=Ce(t[e]);if(o)return o}return we()})())})(e),t.G)return;(()=>{const e=m.o,t=m.t,n=Ne(),{categories:s,services:a,consentId:c,consentTimestamp:r,lastConsentTimestamp:i,data:l,revision:d}=n,f=y(s);e.p=n,e.M=c;const _=!!c&&h(c);e.C=r,e.C&&(e.C=new Date(r)),e.S=i,e.S&&(e.S=new Date(i)),e.h=void 0!==l?l:null,e.V&&_&&d!==t.revision&&(e.j=!1),e.T=!(_&&e.j&&e.C&&e.S&&f),t.cookie.useLocalStorage&&!e.T&&(e.T=(new Date).getTime()>(n.expirationTime||0),e.T&&De(t.cookie.name)),e.T,(()=>{const e=m.o;for(const t of e.O){const n=e.P[t];if(n.readOnly||n.enabled){e.$.push(t);const n=e.X[t]||{};for(let s in n)e.Z[t].push(s),e.i.mode===o&&e.Y[t].push(s)}}})(),e.T?t.mode===o&&(e.R=[...e.$]):(e.Y={...e.Y,...a},e.Z={...e.Y},J([...e.B,...s]))})();const i=Je();if(!await xe())return!1;if(U(null,r=$e,be,Te),m.o.T&&he(r,Te),m.t.lazyHtmlGeneration||be(r,Te),n.autoShow&&!i&&Pe(!0),i)return ne(),te(s.le);n.mode===o&&ne(t.$)}var r},e.setCookieData=e=>{let t,o=e.value,n=e.mode,s=!1;const a=m.o;if('update'===n){a.h=t=Ge('data');const e=typeof t==typeof o;if(e&&'object'==typeof t){!t&&(t={});for(let e in o)t[e]!==o[e]&&(t[e]=o[e],s=!0)}else!e&&t||t===o||(t=o,s=!0)}else t=o,s=!0;return s&&(a.h=t,a.p.data=t,Ee(!0)),s},e.setLanguage=async(e,t)=>{if(!Ce(e))return!1;const o=m.o;return!(e===we()&&!0!==t||!await xe(e)||(Se(e),o.D&&he($e,Te),o.N&&be($e,Te),Me(),0))},e.show=Pe,e.showPreferences=Re,e.validConsent=Je,e.validCookie=e=>''!==He(e,!0)},'object'==typeof exports&&'undefined'!=typeof module?t(exports):'function'==typeof define&&define.amd?define(['exports'],t):t((e='undefined'!=typeof globalThis?globalThis:e||self).CookieConsent={}); diff --git a/app/core/src/main/resources/static/mobile-sign.html b/app/core/src/main/resources/static/mobile-sign.html new file mode 100644 index 0000000000..9f0204c4a9 --- /dev/null +++ b/app/core/src/main/resources/static/mobile-sign.html @@ -0,0 +1,642 @@ + + + + + + + + Stirling PDF - Draw Signature + + + + + + + + +
+
+ +
+
Stirling PDF
+
Draw Signature
+
+
+ +
+
Connecting…
+ +
+
+ +
+ +
+
+ + +
+
+ + + +
+
+ + +
+
+ + + + + +

Draw your signature above, then send it. It appears in the Sign tool on your computer automatically.

+
+ + + +
Stirling PDF · signatures transfer directly to your desktop
+
+ + + + diff --git a/app/core/src/main/resources/static/moon.svg b/app/core/src/main/resources/static/moon.svg deleted file mode 100644 index 8f35a2beea..0000000000 --- a/app/core/src/main/resources/static/moon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/mstile-144x144.png b/app/core/src/main/resources/static/mstile-144x144.png deleted file mode 100644 index ff28cf1ac6..0000000000 Binary files a/app/core/src/main/resources/static/mstile-144x144.png and /dev/null differ diff --git a/app/core/src/main/resources/static/mstile-150x150.png b/app/core/src/main/resources/static/mstile-150x150.png deleted file mode 100644 index c900c83ae3..0000000000 Binary files a/app/core/src/main/resources/static/mstile-150x150.png and /dev/null differ diff --git a/app/core/src/main/resources/static/mstile-310x150.png b/app/core/src/main/resources/static/mstile-310x150.png deleted file mode 100644 index 43a095f36d..0000000000 Binary files a/app/core/src/main/resources/static/mstile-310x150.png and /dev/null differ diff --git a/app/core/src/main/resources/static/mstile-310x310.png b/app/core/src/main/resources/static/mstile-310x310.png deleted file mode 100644 index fd52bd61d2..0000000000 Binary files a/app/core/src/main/resources/static/mstile-310x310.png and /dev/null differ diff --git a/app/core/src/main/resources/static/mstile-70x70.png b/app/core/src/main/resources/static/mstile-70x70.png deleted file mode 100644 index 7692923f74..0000000000 Binary files a/app/core/src/main/resources/static/mstile-70x70.png and /dev/null differ diff --git a/app/core/src/main/resources/static/rainbow.svg b/app/core/src/main/resources/static/rainbow.svg deleted file mode 100644 index 0ee5e48b8e..0000000000 --- a/app/core/src/main/resources/static/rainbow.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/main/resources/static/safari-pinned-tab.svg b/app/core/src/main/resources/static/safari-pinned-tab.svg deleted file mode 100644 index f0a689a75d..0000000000 --- a/app/core/src/main/resources/static/safari-pinned-tab.svg +++ /dev/null @@ -1 +0,0 @@ -Created by potrace 1.14, written by Peter Selinger 2001-2017 \ No newline at end of file diff --git a/app/core/src/main/resources/static/sun.svg b/app/core/src/main/resources/static/sun.svg deleted file mode 100644 index 28dcfe5680..0000000000 --- a/app/core/src/main/resources/static/sun.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/core/src/test/java/stirling/software/SPDF/config/MultipartConfigurationTest.java b/app/core/src/test/java/stirling/software/SPDF/config/MultipartConfigurationTest.java index 60b17a3534..276afbbe99 100644 --- a/app/core/src/test/java/stirling/software/SPDF/config/MultipartConfigurationTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/config/MultipartConfigurationTest.java @@ -4,8 +4,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import java.lang.reflect.Field; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -24,10 +22,7 @@ class MultipartConfigurationTest { void setUp() throws Exception { // Manually constructed config with a mocked service, so Spring env overrides do not apply. uploadLimitService = mock(UploadLimitService.class); - configuration = new MultipartConfiguration(); - Field field = MultipartConfiguration.class.getDeclaredField("uploadLimitService"); - field.setAccessible(true); - field.set(configuration, uploadLimitService); + configuration = new MultipartConfiguration(uploadLimitService); } @Nested 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/MergeControllerGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerGapTest.java index f5543bcdc7..0a6aa0efca 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerGapTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerGapTest.java @@ -84,12 +84,6 @@ class MergeControllerGapTest { return (MultipartFile[]) m.invoke(null, files, fileOrder); } - private String[] parseClientFileIds(String value) throws Exception { - Method m = MergeController.class.getDeclaredMethod("parseClientFileIds", String.class); - m.setAccessible(true); - return (String[]) m.invoke(mergeController, value); - } - private long getPdfDateTimeSafe(MultipartFile file) throws Exception { Method m = MergeController.class.getDeclaredMethod("getPdfDateTimeSafe", MultipartFile.class); @@ -331,58 +325,6 @@ class MergeControllerGapTest { } } - // ---- parseClientFileIds ------------------------------------------------- - - @Nested - @DisplayName("parseClientFileIds") - class ParseClientFileIds { - - @Test - @DisplayName("null input returns empty array") - void nullReturnsEmpty() throws Exception { - assertEquals(0, parseClientFileIds(null).length); - } - - @Test - @DisplayName("blank input returns empty array") - void blankReturnsEmpty() throws Exception { - assertEquals(0, parseClientFileIds(" ").length); - } - - @Test - @DisplayName("empty JSON array returns empty array") - void emptyArrayReturnsEmpty() throws Exception { - assertEquals(0, parseClientFileIds("[]").length); - assertEquals(0, parseClientFileIds("[ ]").length); - } - - @Test - @DisplayName("non-array text returns empty array") - void nonArrayReturnsEmpty() throws Exception { - assertEquals(0, parseClientFileIds("not-an-array").length); - } - - @Test - @DisplayName("parses quoted, comma-separated ids and strips surrounding quotes") - void parsesQuotedIds() throws Exception { - String[] result = parseClientFileIds("[\"id1\", \"id2\",\"id3\"]"); - assertArrayEquals(new String[] {"id1", "id2", "id3"}, result); - } - - @Test - @DisplayName("parses unquoted ids as-is after trimming") - void parsesUnquotedIds() throws Exception { - String[] result = parseClientFileIds("[a, b , c]"); - assertArrayEquals(new String[] {"a", "b", "c"}, result); - } - - @Test - @DisplayName("single element array yields a one-element result") - void singleElement() throws Exception { - assertArrayEquals(new String[] {"only"}, parseClientFileIds("[\"only\"]")); - } - } - // ---- reorderFilesByProvidedOrder ---------------------------------------- @Nested diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/UIDataControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/UIDataControllerTest.java new file mode 100644 index 0000000000..33e2fbf0df --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/UIDataControllerTest.java @@ -0,0 +1,66 @@ +package stirling.software.SPDF.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.http.ResponseEntity; + +import stirling.software.SPDF.service.SharedSignatureService; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.ObjectMapper; + +class UIDataControllerTest { + + @TempDir Path tempDir; + + @Test + void getPipelineData_usesEachSourceFilenameWhenJsonContentIsIdentical() throws Exception { + Path configDir = tempDir.resolve("defaultWebUIConfigs"); + Files.createDirectories(configDir); + + String configJson = "{\"steps\":[]}"; + Files.writeString(configDir.resolve("first-config.json"), configJson); + Files.writeString(configDir.resolve("second-config.json"), configJson); + + ApplicationProperties applicationProperties = mock(ApplicationProperties.class); + SharedSignatureService signatureService = mock(SharedSignatureService.class); + RuntimePathConfig runtimePathConfig = mock(RuntimePathConfig.class); + + when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(configDir.toString()); + + UIDataController controller = + new UIDataController( + applicationProperties, + signatureService, + null, + new DefaultResourceLoader(), + runtimePathConfig, + new ObjectMapper()); + + ResponseEntity response = controller.getPipelineData(); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + UIDataController.PipelineData body = response.getBody(); + assertThat(body).isNotNull(); + + List> configsWithNames = body.getPipelineConfigsWithNames(); + assertThat(configsWithNames).hasSize(2); + assertThat(configsWithNames) + .extracting(entry -> entry.get("name")) + .containsExactlyInAnyOrder("first-config", "second-config"); + assertThat(configsWithNames) + .extracting(entry -> entry.get("json")) + .containsOnly(configJson); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeControllerTest.java index 90b6a370de..14c49b351a 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeControllerTest.java @@ -204,7 +204,7 @@ class ConvertOfficeControllerTest { inv -> { // unoconvert writes directly to the output path (last arg) List command = inv.getArgument(0); - Path out = Path.of(command.get(command.size() - 1)); + Path out = Path.of(command.getLast()); Files.writeString(out, "%PDF-1.4 produced"); return result; }); @@ -239,7 +239,7 @@ class ConvertOfficeControllerTest { inv -> { // soffice writes .pdf into the --outdir (workDir) List command = inv.getArgument(0); - Path inputPath = Path.of(command.get(command.size() - 1)); + Path inputPath = Path.of(command.getLast()); Path out = inputPath.getParent().resolve("report.pdf"); Files.writeString(out, "%PDF soffice"); return result; @@ -311,7 +311,7 @@ class ConvertOfficeControllerTest { .thenAnswer( inv -> { List command = inv.getArgument(0); - Path inputPath = Path.of(command.get(command.size() - 1)); + Path inputPath = Path.of(command.getLast()); Path out = inputPath.getParent().resolve("report.pdf"); Files.write(out, new byte[0]); return result; @@ -344,7 +344,7 @@ class ConvertOfficeControllerTest { .thenAnswer( inv -> { List command = inv.getArgument(0); - Path inputPath = Path.of(command.get(command.size() - 1)); + Path inputPath = Path.of(command.getLast()); Path out = inputPath.getParent().resolve("page.pdf"); Files.writeString(out, "%PDF html"); return result; @@ -398,7 +398,7 @@ class ConvertOfficeControllerTest { .thenAnswer( inv -> { List command = inv.getArgument(0); - Path inputPath = Path.of(command.get(command.size() - 1)); + Path inputPath = Path.of(command.getLast()); Path out = inputPath.getParent().resolve("report.pdf"); Files.writeString(out, "%PDF produced"); return result; diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java index 31bbc9e1f8..e9d9b9ce1d 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java @@ -197,7 +197,7 @@ class ConvertPDFToPDFAMoreTest { // qpdf normalize/clean writes its (last-arg) output file if (command.contains("--normalize-content=y")) { // qpdf produced file is the last argument - Path out = Path.of(command.get(command.size() - 1)); + Path out = Path.of(command.getLast()); Files.write(out, simplePdfBytes()); } return okResult; diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java index 7a46611443..52104df48e 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java @@ -262,7 +262,7 @@ class ConvertPdfToVideoControllerTest { assertTrue(command.contains("+faststart")); assertFalse(command.contains("libvpx-vp9")); // Output path is always the last argument. - assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1)); + assertEquals(backing.getAbsolutePath(), command.getLast()); } @Test @@ -278,7 +278,7 @@ class ConvertPdfToVideoControllerTest { assertTrue(command.contains("30")); assertFalse(command.contains("libx264")); assertFalse(command.contains("+faststart")); - assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1)); + assertEquals(backing.getAbsolutePath(), command.getLast()); } @Test diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerMoreTest.java index b8f4e51b3f..4393c19e3f 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerMoreTest.java @@ -195,7 +195,7 @@ class CompressControllerMoreTest { // The qpdf output path is the last argument of the command. private static Path qpdfOutputPath(List command) { - return Path.of(command.get(command.size() - 1)); + return Path.of(command.getLast()); } /** Stub gs to write a valid PDF to its output file and report success. */ diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java index bfacb4d503..dc9c9538cd 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java @@ -49,6 +49,33 @@ class MobileScannerControllerTest { when(systemProps.isEnableMobileScanner()).thenReturn(false); } + // --- shared-endpoint gating: scanner and mobile signature share this API --- + + @Test + void createSession_whenOnlyMobileSignatureEnabled_returnsOk() { + // The signature feature must work with the scanner switched off. + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(true); + SessionInfo sessionInfo = new SessionInfo("test-session", 1000L, 601000L, 600000L); + when(mobileScannerService.createSession("test-session")).thenReturn(sessionInfo); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + void createSession_whenBothFeaturesDisabled_returnsForbidden() { + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(false); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + } + // --- createSession tests --- @Test diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java index 1a97cc1a55..db361b4302 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java @@ -162,7 +162,7 @@ class RemoveImagesControllerTest { /** Counts every PDImageXObject reachable through page + nested form resources. */ private int countImagesInSavedOutput() throws IOException { assertFalse(savedTempFiles.isEmpty(), "expected the controller to create a temp file"); - File out = savedTempFiles.get(savedTempFiles.size() - 1); + File out = savedTempFiles.getLast(); try (PDDocument doc = Loader.loadPDF(out)) { int count = 0; for (PDPage page : doc.getPages()) { @@ -245,7 +245,7 @@ class RemoveImagesControllerTest { assertEquals(0, countImagesInSavedOutput()); // page count must be preserved - File out = savedTempFiles.get(savedTempFiles.size() - 1); + File out = savedTempFiles.getLast(); try (PDDocument result = Loader.loadPDF(out)) { assertEquals(3, result.getNumberOfPages()); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerMoreTest.java index 6565a84875..f564dafb11 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerMoreTest.java @@ -98,12 +98,8 @@ class RepairControllerMoreTest { } } - /** - * Writes a valid PDF to the path at the given command index, mimicking a successful tool run. - */ - private static void writeValidPdfTo(List command, int outputPathIndex) - throws Exception { - Path out = Path.of(command.get(outputPathIndex)); + /** Writes a valid PDF to the given output path, mimicking a successful tool run. */ + private static void writeValidPdfTo(Path out) throws Exception { byte[] pdf = buildPdfBytes(1); Files.write(out, pdf); } @@ -133,7 +129,7 @@ class RepairControllerMoreTest { .thenAnswer( inv -> { List cmd = inv.getArgument(0); - writeValidPdfTo(cmd, 2); + writeValidPdfTo(Path.of(cmd.get(2))); return okResult; }); @@ -176,7 +172,7 @@ class RepairControllerMoreTest { .thenAnswer( inv -> { List cmd = inv.getArgument(0); - writeValidPdfTo(cmd, cmd.size() - 1); + writeValidPdfTo(Path.of(cmd.getLast())); return okResult; }); @@ -216,7 +212,7 @@ class RepairControllerMoreTest { .thenAnswer( inv -> { List cmd = inv.getArgument(0); - writeValidPdfTo(cmd, cmd.size() - 1); + writeValidPdfTo(Path.of(cmd.getLast())); return okResult; }); @@ -256,7 +252,7 @@ class RepairControllerMoreTest { .thenAnswer( inv -> { List cmd = inv.getArgument(0); - writeValidPdfTo(cmd, cmd.size() - 1); + writeValidPdfTo(Path.of(cmd.getLast())); return okResult; }); 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/controller/web/UploadLimitServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/UploadLimitServiceTest.java index 219affcd30..173741df87 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/web/UploadLimitServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/UploadLimitServiceTest.java @@ -49,15 +49,7 @@ class UploadLimitServiceTest { systemProps = mock(ApplicationProperties.System.class); when(applicationProperties.getSystem()).thenReturn(systemProps); - uploadLimitService = new UploadLimitService(); - // inject mock - try { - var field = UploadLimitService.class.getDeclaredField("applicationProperties"); - field.setAccessible(true); - field.set(uploadLimitService, applicationProperties); - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } + uploadLimitService = new UploadLimitService(applicationProperties); } @ParameterizedTest(name = "getReadableUploadLimit case #{index}: rawValue={0}, expected={1}") 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/audit/AuditEventType.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java index 433244a971..a1220a9fdd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java @@ -19,6 +19,9 @@ public enum AuditEventType { // File operations - STANDARD level FILE_OPERATION("File operation"), + // Storage encryption at rest - STANDARD level + STORAGE_ENCRYPTION("Storage encryption operation"), + // PDF operations - STANDARD level PDF_PROCESS("PDF processing operation"), 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/controller/api/AuditRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java index 69c9402926..7376795c66 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java @@ -143,19 +143,12 @@ public class AuditRestController { @RequestParam(value = "period", defaultValue = "week") String period) { // Calculate days based on period - int days; - switch (period.toLowerCase()) { - case "day": - days = 1; - break; - case "month": - days = 30; - break; - case "week": - default: - days = 7; - break; - } + int days = + switch (period.toLowerCase()) { + case "day" -> 1; + case "month" -> 30; + default -> 7; + }; // Get events from the specified period Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days)); @@ -269,19 +262,12 @@ public class AuditRestController { @RequestParam(value = "period", defaultValue = "week") String period) { // Calculate days based on period - int days; - switch (period.toLowerCase()) { - case "day": - days = 1; - break; - case "month": - days = 30; - break; - case "week": - default: - days = 7; - break; - } + int days = + switch (period.toLowerCase()) { + case "day" -> 1; + case "month" -> 30; + default -> 7; + }; // Get events from the specified period and previous period Instant now = Instant.now(); @@ -754,7 +740,7 @@ public class AuditRestController { List> files = (List>) eventData.get("files"); if (files != null && !files.isEmpty()) { - Map firstFile = files.get(0); + Map firstFile = files.getFirst(); data.put("documentname", String.valueOf(firstFile.getOrDefault("name", ""))); data.put("author", String.valueOf(firstFile.getOrDefault("pdfAuthor", ""))); data.put("filehash", String.valueOf(firstFile.getOrDefault("fileHash", ""))); 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/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java index 8ac0ddda96..5e507ff285 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -92,7 +92,8 @@ public class CredentialEncryption { writeOwnerOnly(path, Base64.getEncoder().encodeToString(generated.getEncoded())); log.warn( "Generated a new credential encryption key at {}. Back this file up: losing it" - + " makes stored integration secrets unrecoverable.", + + " makes stored integration secrets and pipeline supporting files" + + " unrecoverable.", path); return generated; } catch (Exception e) { @@ -119,7 +120,8 @@ public class CredentialEncryption { } } - public static String encrypt(String plaintext) { + /** Raw {@code IV || ciphertext}, for binary columns that can't afford Base64's 33% overhead. */ + public static byte[] encryptBytes(byte[] plaintext) { if (plaintext == null) { return null; } @@ -128,30 +130,46 @@ public class CredentialEncryption { RANDOM.nextBytes(iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); - byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] ciphertext = cipher.doFinal(plaintext); byte[] combined = new byte[iv.length + ciphertext.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); - return Base64.getEncoder().encodeToString(combined); + return combined; } catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to encrypt credential", e); } } + /** Inverse of {@link #encryptBytes}; throws if the blob was tampered with (GCM tag). */ + public static byte[] decryptBytes(byte[] stored) { + if (stored == null) { + return null; + } + try { + byte[] iv = Arrays.copyOfRange(stored, 0, IV_BYTES); + byte[] ciphertext = Arrays.copyOfRange(stored, IV_BYTES, stored.length); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + return cipher.doFinal(ciphertext); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to decrypt credential", e); + } + } + + public static String encrypt(String plaintext) { + if (plaintext == null) { + return null; + } + return Base64.getEncoder() + .encodeToString(encryptBytes(plaintext.getBytes(StandardCharsets.UTF_8))); + } + public static String decrypt(String stored) { if (stored == null) { return null; } - try { - byte[] combined = Base64.getDecoder().decode(stored); - byte[] iv = Arrays.copyOfRange(combined, 0, IV_BYTES); - byte[] ciphertext = Arrays.copyOfRange(combined, IV_BYTES, combined.length); - Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); - return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); - } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to decrypt credential", e); - } + // Base64's IllegalArgumentException stays uncaught: LegacyDecryptStringConverter needs it. + return new String(decryptBytes(Base64.getDecoder().decode(stored)), StandardCharsets.UTF_8); } private static SecretKey requireKey() { 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/asset/InProcessPolicyAssetStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/InProcessPolicyAssetStore.java new file mode 100644 index 0000000000..35f20aa15a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/InProcessPolicyAssetStore.java @@ -0,0 +1,82 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link PolicyAssetStore} for tests and any future no-database mode. {@link + * JpaPolicyAssetStore} is the runtime bean. + */ +public class InProcessPolicyAssetStore implements PolicyAssetStore { + + private final Map assets = new ConcurrentHashMap<>(); + private final Map contents = new ConcurrentHashMap<>(); + + @Override + public PolicyAsset save(PolicyAsset asset, byte[] content) { + String id = + asset.id() == null || asset.id().isBlank() + ? UUID.randomUUID().toString() + : asset.id(); + PolicyAsset stored = + new PolicyAsset( + id, + asset.fileName(), + asset.contentType(), + content.length, + asset.owner(), + asset.teamId(), + asset.createdAt()); + assets.put(id, stored); + contents.put(id, content); + return stored; + } + + @Override + public Optional get(String id) { + return Optional.ofNullable(assets.get(id)); + } + + @Override + public Optional content(String id) { + return Optional.ofNullable(contents.get(id)); + } + + @Override + public List findByTeam(Long teamId) { + return assets.values().stream() + .filter(asset -> Objects.equals(asset.teamId(), teamId)) + .sorted(newestFirst()) + .toList(); + } + + @Override + public List all() { + return assets.values().stream().sorted(newestFirst()).toList(); + } + + @Override + public List idsCreatedBefore(long cutoff) { + return assets.values().stream() + .filter(asset -> asset.createdAt() < cutoff) + .map(PolicyAsset::id) + .toList(); + } + + @Override + public boolean delete(String id) { + contents.remove(id); + return assets.remove(id) != null; + } + + private static Comparator newestFirst() { + return Comparator.comparingLong(PolicyAsset::createdAt) + .reversed() + .thenComparing(PolicyAsset::id); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java new file mode 100644 index 0000000000..65c6b5ed25 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java @@ -0,0 +1,95 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.crypto.CredentialEncryption; + +/** + * Durable {@link PolicyAssetStore} backed by JPA; the runtime store. Bytes are encrypted at rest + * here rather than by an attribute converter, so the metadata reads (list, validate, clean up) + * never decrypt - only {@link #content} does. + */ +@Service +@RequiredArgsConstructor +public class JpaPolicyAssetStore implements PolicyAssetStore { + + private final PolicyAssetRepository repository; + + @Override + @Transactional + public PolicyAsset save(PolicyAsset asset, byte[] content) { + String id = + asset.id() == null || asset.id().isBlank() + ? UUID.randomUUID().toString() + : asset.id(); + PolicyAssetEntity entity = new PolicyAssetEntity(); + entity.setId(id); + entity.setFileName(asset.fileName()); + entity.setContentType(asset.contentType()); + // Plaintext length: it is the size the UI shows, not the stored ciphertext's. + entity.setFileSize(content.length); + entity.setOwner(asset.owner()); + entity.setTeamId(asset.teamId()); + entity.setCreatedAt(asset.createdAt()); + entity.setData(CredentialEncryption.encryptBytes(content)); + repository.save(entity); + return toAsset(entity); + } + + @Override + public Optional get(String id) { + return repository.findMetaById(id); + } + + @Override + @Transactional(readOnly = true) + public Optional content(String id) { + // The only read of the data column, and so the only decrypt. + return repository + .findById(id) + .map(entity -> CredentialEncryption.decryptBytes(entity.getData())); + } + + @Override + public List findByTeam(Long teamId) { + return repository.findMetaByTeam(teamId); + } + + @Override + public List all() { + return repository.findAllMeta(); + } + + @Override + public List idsCreatedBefore(long cutoff) { + return repository.findIdsCreatedBefore(cutoff); + } + + @Override + @Transactional + public boolean delete(String id) { + if (!repository.existsById(id)) { + return false; + } + repository.deleteById(id); + return true; + } + + private static PolicyAsset toAsset(PolicyAssetEntity entity) { + return new PolicyAsset( + entity.getId(), + entity.getFileName(), + entity.getContentType(), + entity.getFileSize(), + entity.getOwner(), + entity.getTeamId(), + entity.getCreatedAt()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAsset.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAsset.java new file mode 100644 index 0000000000..697319a182 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAsset.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.policy.asset; + +/** + * Metadata for a stored supporting file (e.g. a watermark image, signing certificate, or overlay + * PDF) that pipeline steps reference from their {@code fileParameters}. The bytes live in the + * {@link PolicyAssetStore}; this record is what lists and API responses carry. Team-scoped like + * policies: {@code owner}/{@code teamId} are stamped server-side at upload ({@code null} when login + * is disabled). + */ +public record PolicyAsset( + String id, + String fileName, + String contentType, + long size, + String owner, + Long teamId, + long createdAt) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java new file mode 100644 index 0000000000..582465e874 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java @@ -0,0 +1,124 @@ +package stirling.software.proprietary.policy.asset; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Removes stored assets that no policy references any more, so replaced certificates/images don't + * accumulate. Runs after a policy save (assets its old version referenced but its new one dropped) + * and after a policy delete (everything the deleted policy referenced). An asset still referenced + * by any other policy in the team survives; assets belonging to a different team are never touched, + * whatever a policy claims. + * + *

Those two hooks only ever see ids a policy once referenced, so an upload abandoned before any + * policy bound it is reclaimed by {@link #sweepAbandonedUploads()} instead. Cleanup is best-effort + * throughout: the policy write has already committed, so a failure is logged, not propagated. + */ +@Slf4j +@Service +public class PolicyAssetCleaner { + + // An upload sits unreferenced until the save that binds it, so the window has to outlast a + // builder session comfortably. + private static final Duration ABANDONED_UPLOAD_AGE = Duration.ofDays(1); + + private final PolicyAssetStore assetStore; + private final PolicyStore policyStore; + private final Supplier clock; + + @Autowired + public PolicyAssetCleaner(PolicyAssetStore assetStore, PolicyStore policyStore) { + this(assetStore, policyStore, Instant::now); + } + + // Clock seam so tests can pin "now"; the runtime bean uses the wall clock above. + PolicyAssetCleaner( + PolicyAssetStore assetStore, PolicyStore policyStore, Supplier clock) { + this.assetStore = assetStore; + this.policyStore = policyStore; + this.clock = clock; + } + + /** + * After an update: drop assets the previous version referenced and the new one no longer does. + */ + public void cleanupAfterSave(Policy previous, Policy saved) { + if (previous == null) { + return; + } + Set dropped = new HashSet<>(PolicyAssetRefs.referencedAssetIds(previous.steps())); + dropped.removeAll(PolicyAssetRefs.referencedAssetIds(saved.steps())); + deleteUnreferenced(saved.teamId(), dropped); + } + + /** After a delete: drop everything the deleted policy referenced, if now unreferenced. */ + public void cleanupAfterDelete(Policy deleted) { + deleteUnreferenced(deleted.teamId(), PolicyAssetRefs.referencedAssetIds(deleted.steps())); + } + + /** + * Uploads no policy ever bound: the save/delete hooks never see these ids, so without this they + * would keep their (up to 50 MB) bytes forever. Deliberately not team-scoped - an abandoned + * upload is unreferenced everywhere or nowhere. Runs at startup too, so an instance restarted + * more often than daily still reclaims; the age cutoff is what keeps fresh uploads safe. + */ + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + public void sweepAbandonedUploads() { + long cutoff = clock.get().minus(ABANDONED_UPLOAD_AGE).toEpochMilli(); + try { + for (String id : assetStore.idsCreatedBefore(cutoff)) { + if (!policyStore.anyPolicyReferences(id) && assetStore.delete(id)) { + log.debug("Deleted abandoned policy asset {}", id); + } + } + } catch (RuntimeException e) { + log.warn("Abandoned policy asset sweep failed: {}", e.getMessage(), e); + } + } + + private void deleteUnreferenced(Long teamId, Set candidates) { + if (candidates.isEmpty()) { + return; + } + try { + Set stillReferenced = new HashSet<>(); + for (Policy policy : policyStore.findByTeam(teamId)) { + stillReferenced.addAll(PolicyAssetRefs.referencedAssetIds(policy.steps())); + } + for (String id : candidates) { + if (stillReferenced.contains(id)) { + continue; + } + assetStore + .get(id) + .filter(asset -> Objects.equals(asset.teamId(), teamId)) + .ifPresent( + asset -> { + assetStore.delete(id); + log.debug( + "Deleted unreferenced policy asset {} ({})", + id, + asset.fileName()); + }); + } + } catch (RuntimeException e) { + // The save/delete already committed: failing here must not fail the request or skip + // the caller's trigger re-sync. + log.warn("Policy asset cleanup failed for team {}: {}", teamId, e.getMessage(), e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java new file mode 100644 index 0000000000..14349fd64c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java @@ -0,0 +1,187 @@ +package stirling.software.proprietary.policy.asset; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Stored supporting files for pipelines: the certificate/image/overlay a step needs beyond its + * document stream. Uploaded when a pipeline is built, persisted server-side, and referenced from a + * step's {@code fileParameters} as {@code asset:} - so triggered and scheduled runs have the + * file without anyone re-supplying it. Team-scoped exactly like the policies that reference them. + */ +@RestController +@RequestMapping("/api/v1/policies/assets") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Policies", description = "Run tool pipelines on the backend") +public class PolicyAssetController { + + /** Defensive cap; supporting files (certs, images, overlay PDFs) are far smaller. */ + private static final long MAX_ASSET_BYTES = 50L * 1024 * 1024; + + private final PolicyAssetStore assetStore; + private final PolicyStore policyStore; + private final PolicyAccessGuard policyAccessGuard; + private final PolicyManagementAuthority policyManagementAuthority; + private final ApplicationProperties applicationProperties; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Upload a pipeline supporting file", + description = + "Stores a supporting file (multipart field 'file') for pipeline steps to" + + " reference from their fileParameters as 'asset:', and returns" + + " its metadata including the assigned id.") + public ResponseEntity upload(@RequestPart("file") MultipartFile file) + throws IOException { + requirePolicyEditingAllowed(); + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Uploaded file is empty"); + } + if (file.getSize() > MAX_ASSET_BYTES) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Supporting files may be at most " + (MAX_ASSET_BYTES / (1024 * 1024)) + " MB"); + } + String fileName = Filenames.toSimpleFileName(file.getOriginalFilename()); + if (fileName == null || fileName.isBlank()) { + fileName = "asset"; + } + PolicyAsset meta = + new PolicyAsset( + null, + fileName, + file.getContentType(), + file.getSize(), + policyAccessGuard.ownerForNewPolicy(), + policyAccessGuard.teamForNewPolicy(), + System.currentTimeMillis()); + return ResponseEntity.ok(assetStore.save(meta, file.getBytes())); + } + + @GetMapping + @Operation( + summary = "List stored supporting files", + description = + "Lists the supporting files visible to the caller's team (metadata only), so" + + " the builder can show which file a step's binding points at.") + public List list() { + return policyAccessGuard.visibleFrom(assetStore); + } + + @GetMapping("/{assetId}/content") + @Operation( + summary = "Download a stored supporting file", + description = + "Returns the asset's bytes with its stored content type and filename. Gated" + + " like upload and delete: supporting files include signing" + + " certificates, so reading the bytes back needs the same authority" + + " that put them there, not merely team membership.") + public ResponseEntity content(@PathVariable String assetId) { + requirePolicyEditingAllowed(); + PolicyAsset asset = accessibleAsset(assetId); + byte[] bytes = + assetStore + .content(assetId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "No asset: " + assetId)); + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + try { + if (asset.contentType() != null) { + mediaType = MediaType.parseMediaType(asset.contentType()); + } + } catch (RuntimeException ignored) { + // Stored content type unparsable: serve as a generic binary. + } + return ResponseEntity.ok() + .contentType(mediaType) + .header( + HttpHeaders.CONTENT_DISPOSITION, + // UTF-8 so a non-ASCII filename encodes per RFC 5987 instead of mangling. + ContentDisposition.attachment() + .filename(asset.fileName(), StandardCharsets.UTF_8) + .build() + .toString()) + .body(new ByteArrayResource(bytes)); + } + + @DeleteMapping("/{assetId}") + @Operation( + summary = "Delete a stored supporting file", + description = + "Removes an asset no pipeline references. An asset still referenced by a" + + " pipeline's step returns 409 - remove or replace the binding first." + + " (Assets are also cleaned up automatically when the pipelines" + + " referencing them are saved without them or deleted.)") + public ResponseEntity delete(@PathVariable String assetId) { + requirePolicyEditingAllowed(); + accessibleAsset(assetId); + boolean referenced = + policyAccessGuard.visibleFrom(policyStore).stream() + .anyMatch( + policy -> + PolicyAssetRefs.referencedAssetIds(policy.steps()) + .contains(assetId)); + if (referenced) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Asset is still referenced by a pipeline step"); + } + assetStore.delete(assetId); + return ResponseEntity.noContent().build(); + } + + /** The asset, scoped to the caller's team — another team's asset reads as not-found. */ + private PolicyAsset accessibleAsset(String assetId) { + return assetStore + .get(assetId) + .filter(policyAccessGuard::canAccess) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "No asset: " + assetId)); + } + + /** Same gate as policy edits (see {@code PolicyController#requirePolicyEditingAllowed}). */ + private void requirePolicyEditingAllowed() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + return; + } + if (!policyManagementAuthority.canEditPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Policies may only be created or modified by a team leader"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetEntity.java new file mode 100644 index 0000000000..b535cc1971 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetEntity.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.policy.asset; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * JPA row for a {@link PolicyAsset} plus its bytes. Stored in the database (not on disk) so + * multi-node deployments see the same assets regardless of which node stored or runs a policy. + * {@code owner} and {@code teamId} are plain values, not foreign keys, matching {@code + * PolicyEntity}. + */ +@Entity +@Table(name = "policy_assets") +@NoArgsConstructor +@Getter +@Setter +public class PolicyAssetEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id") + private String id; + + @Column(name = "file_name") + private String fileName; + + @Column(name = "content_type") + private String contentType; + + // Not "size": a reserved word in Oracle, and an HQL keyword the metadata projections would + // read as the collection-size function. + @Column(name = "file_size") + private long fileSize; + + @Column(name = "owner") + private String owner; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "created_at") + private long createdAt; + + // Ciphertext; JpaPolicyAssetStore is the only place these bytes are encrypted or decrypted. + @Lob + @Column(name = "data", nullable = false, columnDefinition = "bytea") + private byte[] data; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRefs.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRefs.java new file mode 100644 index 0000000000..1acf3d7c76 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRefs.java @@ -0,0 +1,63 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import stirling.software.proprietary.policy.model.PipelineStep; + +/** + * Parses stored-asset references out of pipeline steps. A step's {@code fileParameters} value takes + * one of two forms: + * + *

    + *
  • {@code asset:[,...]} - stored assets, loaded from the asset store at run time. + * Several ids appear when one tool field carries multiple files (e.g. attachments). + *
  • anything else - the name of a file supplied with the run itself (the multipart {@code + * assets[i].key} form), which is all a binding could mean before assets could be stored. + *
+ * + *

The prefix is what keeps the two apart: without it a run-supplied key reads as a missing asset + * id, and a stored binding can be satisfied by whatever file the run supplies. The executor looks + * supporting files up by the whole value, so resolution keeps it as the map key and only splits it + * to load each asset. + */ +public final class PolicyAssetRefs { + + /** Marks a {@code fileParameters} value as stored asset ids rather than a run-supplied key. */ + public static final String PREFIX = "asset:"; + + private PolicyAssetRefs() {} + + /** Whether a {@code fileParameters} value names stored assets. */ + public static boolean isAssetRef(String fileParameterValue) { + return fileParameterValue != null && fileParameterValue.startsWith(PREFIX); + } + + /** The individual asset ids inside one {@code fileParameters} value; none if it isn't a ref. */ + public static List assetIds(String fileParameterValue) { + List ids = new ArrayList<>(); + if (!isAssetRef(fileParameterValue)) { + return ids; + } + for (String token : fileParameterValue.substring(PREFIX.length()).split(",")) { + String id = token.trim(); + if (!id.isEmpty()) { + ids.add(id); + } + } + return ids; + } + + /** Every asset id referenced by any step's {@code fileParameters}, in encounter order. */ + public static Set referencedAssetIds(List steps) { + Set ids = new LinkedHashSet<>(); + for (PipelineStep step : steps) { + for (String value : step.fileParameters().values()) { + ids.addAll(assetIds(value)); + } + } + return ids; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRepository.java new file mode 100644 index 0000000000..e423ad01ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRepository.java @@ -0,0 +1,42 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface PolicyAssetRepository extends JpaRepository { + + /** + * Metadata projection. Lists, validation and cleanup need only the fields, and selecting whole + * entities would drag every asset's LOB (up to 50 MB each) along with them. + */ + String META = + "select new stirling.software.proprietary.policy.asset.PolicyAsset(a.id, a.fileName," + + " a.contentType, a.fileSize, a.owner, a.teamId, a.createdAt) from" + + " PolicyAssetEntity a"; + + @Query(META + " where a.id = :id") + Optional findMetaById(@Param("id") String id); + + /** + * Assets belonging to a team, newest first. A {@code null} teamId matches the rows with no team + * (login-disabled data), mirroring {@code PolicyRepository#findByTeam}. + */ + @Query( + META + + " where ((:teamId is null and a.teamId is null) or a.teamId = :teamId) order" + + " by a.createdAt desc, a.id asc") + List findMetaByTeam(@Param("teamId") Long teamId); + + @Query(META + " order by a.createdAt desc, a.id asc") + List findAllMeta(); + + /** Ids of assets uploaded before {@code cutoff}, for the abandoned-upload sweep. */ + @Query("select a.id from PolicyAssetEntity a where a.createdAt < :cutoff") + List findIdsCreatedBefore(@Param("cutoff") long cutoff); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java new file mode 100644 index 0000000000..862d3511a7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java @@ -0,0 +1,112 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyInputs; + +/** + * Loads a stored policy's referenced supporting files into a run's inputs, so a triggered or + * scheduled run has the certificate/image/overlay its steps need without anyone uploading it at run + * time. Assets are matched to the policy's own team (both stamped server-side), so a forged asset + * id in a policy JSON can't pull another team's file - run-time has no principal to check instead. + * + *

Stored assets win: a binding the policy stores is overwritten with the policy's own asset, so + * a member who can run the policy (runs are not leader-gated) can't swap the pinned certificate by + * posting {@code assets[i].key=}. A binding that names no stored asset keeps whatever the run + * supplied, which is what a binding meant before assets could be stored. + * + *

Resolution is all or nothing per binding: a partly resolvable one is dropped so it surfaces as + * the executor's existing missing-supporting-file error, rather than running the step short a file. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyAssetResolver { + + private final PolicyAssetStore assetStore; + + /** Inputs with the policy's stored assets merged in under each step's asset key. */ + public PolicyInputs resolve(Policy policy, PolicyInputs inputs) { + Map> supporting = null; + // Track loaded keys separately: `supporting` starts as a copy of the run's own files, so + // it can't tell "already loaded" from "the run supplied this one" - which is what we + // deliberately overwrite. + Set loaded = new HashSet<>(); + for (PipelineStep step : policy.steps()) { + for (String assetKey : step.fileParameters().values()) { + if (assetKey == null || assetKey.isBlank() || !loaded.add(assetKey)) { + continue; + } + List resources = load(policy, assetKey); + if (resources.isEmpty()) { + // A stored binding that has gone dead must not fall back to whatever the run + // posted under its key: that is the override this class exists to prevent. + if (PolicyAssetRefs.isAssetRef(assetKey) + && inputs.supportingFiles().containsKey(assetKey)) { + if (supporting == null) { + supporting = new LinkedHashMap<>(inputs.supportingFiles()); + } + supporting.remove(assetKey); + } + continue; + } + if (supporting == null) { + supporting = new LinkedHashMap<>(inputs.supportingFiles()); + } + supporting.put(assetKey, resources); + } + } + return supporting == null ? inputs : new PolicyInputs(inputs.primary(), supporting); + } + + /** Every id or none: a part-loaded binding would run the step with fewer files than it asks. */ + private List load(Policy policy, String assetKey) { + List resources = new ArrayList<>(); + for (String id : PolicyAssetRefs.assetIds(assetKey)) { + PolicyAsset asset = assetStore.get(id).orElse(null); + if (asset == null || !Objects.equals(asset.teamId(), policy.teamId())) { + log.warn( + "Policy {} references stored asset {} which is missing or inaccessible;" + + " dropping its binding", + policy.id(), + id); + return List.of(); + } + byte[] content = assetStore.content(id).orElse(null); + if (content == null) { + log.warn( + "Policy {} stored asset {} has no content; dropping its binding", + policy.id(), + id); + return List.of(); + } + resources.add(named(content, asset.fileName())); + } + return resources; + } + + /** The asset bytes as a Resource carrying its original filename (tools read the extension). */ + private static Resource named(byte[] content, String fileName) { + return new ByteArrayResource(content) { + @Override + public String getFilename() { + return fileName; + } + }; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetStore.java new file mode 100644 index 0000000000..f5406e8980 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetStore.java @@ -0,0 +1,32 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.List; +import java.util.Optional; + +/** + * Persistence for pipeline supporting files. Metadata and bytes are stored together but read + * separately: lists and validation only need {@link PolicyAsset}, while a run loads {@link + * #content} for just the assets its steps reference. + */ +public interface PolicyAssetStore { + + /** Persist an asset (a blank id is assigned) and return the stored metadata. */ + PolicyAsset save(PolicyAsset asset, byte[] content); + + Optional get(String id); + + /** The asset's bytes, or empty if the id is unknown. */ + Optional content(String id); + + /** Assets belonging to a team, newest first. {@code null} matches no-team (login-disabled). */ + List findByTeam(Long teamId); + + /** All assets, for team-scoping-off (login-disabled) reads. */ + List all(); + + /** Ids of assets uploaded before {@code cutoff} (epoch millis), for orphan reclamation. */ + List idsCreatedBefore(long cutoff); + + /** Remove an asset. Returns false if the id was unknown. */ + boolean delete(String id); +} 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/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java index 4fa90cc04b..2d7061c966 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java @@ -9,6 +9,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.asset.PolicyAsset; +import stirling.software.proprietary.policy.asset.PolicyAssetStore; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.PolicyStore; @@ -58,6 +60,22 @@ public class PolicyAccessGuard { return store.findByTeam(policyManagementAuthority.currentUserTeamId()); } + /** Whether the stored asset belongs to the current user's team (same rule as policies). */ + public boolean canAccess(PolicyAsset asset) { + if (!enforced()) { + return true; + } + return Objects.equals(asset.teamId(), policyManagementAuthority.currentUserTeamId()); + } + + /** The stored assets visible to the caller, scoped exactly like {@link #visibleFrom}. */ + public List visibleFrom(PolicyAssetStore store) { + if (!enforced()) { + return store.all(); + } + return store.findByTeam(policyManagementAuthority.currentUserTeamId()); + } + private boolean enforced() { return applicationProperties.getSecurity().isEnableLogin(); } 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 a382f96bbc..9b9fca4133 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 @@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.RequestContextHolder; @@ -50,6 +51,8 @@ import stirling.software.common.service.ToolChainValidator; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.audit.AuditContext; +import stirling.software.proprietary.policy.asset.PolicyAssetCleaner; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.engine.PolicyRunHandle; @@ -104,6 +107,8 @@ public class PolicyController { private final PolicyManagementAuthority policyManagementAuthority; private final PolicyTriggerManager policyTriggerManager; private final PolicyOverviewService policyOverviewService; + private final PolicyAssetCleaner assetCleaner; + private final PolicyAssetResolver assetResolver; private final ProcessedLedger processedLedger; private final List policyTriggers; private final ApplicationProperties applicationProperties; @@ -123,12 +128,13 @@ public class PolicyController { + " endpoint and download outputs via /api/v1/general/files/{id}.") public ResponseEntity> run( @RequestPart("json") PipelineDefinition definition, + @RequestParam(value = "policyId", required = false) String policyId, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { stampPolicyAudit(definition); requireRunnable(definition); validateAdHocRun(definition); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = resolveStoredAssets(policyId, toInputs(files)); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); recordEditorDocs(inputs); @@ -144,12 +150,13 @@ public class PolicyController { + " 'cancelled', or 'waiting' event carrying the final run view.") public SseEmitter runStream( @RequestPart("json") PipelineDefinition definition, + @RequestParam(value = "policyId", required = false) String policyId, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { stampPolicyAudit(definition); requireRunnable(definition); validateAdHocRun(definition); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = resolveStoredAssets(policyId, toInputs(files)); SseEmitter emitter = new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs()); @@ -275,7 +282,14 @@ public class PolicyController { } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } + // Snapshot the previous version before saving so supporting files this edit dropped can + // be cleaned up once nothing references them. + Policy previous = + owned.id() == null || owned.id().isBlank() + ? null + : policyStore.get(owned.id()).orElse(null); Policy saved = policyStore.save(owned); + assetCleaner.cleanupAfterSave(previous, saved); // Re-sync trigger registrations now so a new/changed folder-watch policy starts being // watched immediately instead of after the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); @@ -423,21 +437,47 @@ 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()) { - return; - } - if (!policyManagementAuthority.canEditPolicies()) { + if (!policyEditingAllowed()) { throw new ResponseStatusException( HttpStatus.FORBIDDEN, "Policies may only be created or modified by a team leader"); } } + /** + * 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"); + } + } + + /** + * Whether the caller may create/modify policies (a team leader, or any operator when login is + * off). + */ + private boolean policyEditingAllowed() { + return !applicationProperties.getSecurity().isEnableLogin() + || policyManagementAuthority.canEditPolicies(); + } + @GetMapping @Operation( summary = "List policies", @@ -497,10 +537,10 @@ public class PolicyController { public ResponseEntity deletePolicy(@PathVariable String policyId) { requirePolicyEditingAllowed(); // Scope to the caller's team: a policy in another team reads as not-found. - boolean accessible = - policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); - if (accessible && policyStore.delete(policyId)) { + Policy policy = policyStore.get(policyId).filter(policyAccessGuard::canAccess).orElse(null); + if (policy != null && policyStore.delete(policyId)) { processedLedger.clearPolicy(policyId); + assetCleaner.cleanupAfterDelete(policy); // Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open // until the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); @@ -534,8 +574,9 @@ public class PolicyController { description = "Runs the stored policy's pipeline on the supplied files (primary documents" + " under 'fileInput', supporting files under 'assets[i].key' /" - + " 'assets[i].file'). Runs regardless of the policy's enabled flag," - + " which only gates automatic triggering. Returns a run id.") + + " 'assets[i].file' - only for bindings the policy does not already" + + " store). Runs regardless of the policy's enabled flag, which only" + + " gates automatic triggering. Returns a run id.") public ResponseEntity> runStoredPolicy( @PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { @@ -561,8 +602,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) @@ -640,6 +683,25 @@ public class PolicyController { inputs.primary().size()); } + /** + * Resolve a test run's stored {@code asset:} bindings from the saved policy the builder is + * editing, so their bytes need not be re-uploaded. Scoped to that policy (the resolver loads + * only the assets it references, in its own team) and gated to policy editors - the same + * authority that can read asset bytes - so a member can't rebind a policy's stored asset into + * an ad-hoc step to read it back. A blank id (an unsaved pipeline has no stored bindings) or an + * inaccessible policy leaves the run-supplied inputs untouched. + */ + private PolicyInputs resolveStoredAssets(String policyId, PolicyInputs inputs) { + if (policyId == null || policyId.isBlank() || !policyEditingAllowed()) { + return inputs; + } + return policyStore + .get(policyId) + .filter(policyAccessGuard::canAccess) + .map(policy -> assetResolver.resolve(policy, inputs)) + .orElse(inputs); + } + /** * Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the * named supporting-file store, where each asset's {@code key} is the name a step references 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 33ac70d510..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 @@ -34,6 +34,7 @@ import stirling.software.common.util.ExecutorFactory; import stirling.software.common.util.JobContext; import stirling.software.proprietary.failure.FailureKind; import stirling.software.proprietary.failure.PolicyFailureRecorder; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.Policy; @@ -82,6 +83,7 @@ public class PolicyEngine { private final PolicyOutputResolver outputResolver; private final ResourceMonitor resourceMonitor; private final JobQueue jobQueue; + private final PolicyAssetResolver assetResolver; private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor(); @@ -128,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 @@ -154,8 +166,15 @@ 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 + // async hop: worker threads have no principal, so assets bind by the policy's own team. + PolicyInputs resolved = assetResolver.resolve(policy, inputs); // Resolve the referenced output destinations live (like sourceIds), so a stored policy // delivers to each of its saved Source destinations. Unreferenced policies fall back to // their inline output. @@ -163,17 +182,29 @@ public class PolicyEngine { new PipelineDefinition( policy.name(), policy.steps(), outputResolver.resolve(policy)); return submitForPrincipal( - policy.owner(), fileOwner, policy.id(), definition, inputs, fileIdentity, listener); + 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, + 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()); @@ -182,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); @@ -347,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; @@ -357,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/PolicyExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java index 06ac1c8d7c..8a36700460 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java @@ -131,7 +131,7 @@ public class PolicyExecutor { // One call over all inputs. The outputs derive from a single input only when exactly // one entered; otherwise (a genuine merge) there is no single source. ToolResult r = callEndpoint(step, inputFiles, supportingFiles); - Integer origin = inputOrigins.size() == 1 ? inputOrigins.get(0) : null; + Integer origin = inputOrigins.size() == 1 ? inputOrigins.getFirst() : null; for (Resource file : r.files()) { files.add(file); origins.add(origin); 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/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index 7d9a8cf88b..1faa016c42 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -1,6 +1,8 @@ package stirling.software.proprietary.policy.engine; import java.util.List; +import java.util.Map; +import java.util.Objects; import org.springframework.stereotype.Service; @@ -9,6 +11,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.tool.ToolDiagnostic; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.service.ToolChainValidator; +import stirling.software.proprietary.policy.asset.PolicyAssetRefs; +import stirling.software.proprietary.policy.asset.PolicyAssetStore; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -37,6 +41,7 @@ public class PolicyValidator { private final List outputSinks; private final List stepValidators; private final SourceStore sourceStore; + private final PolicyAssetStore assetStore; private final ToolChainValidator toolChainValidator; /** @@ -69,10 +74,46 @@ public class PolicyValidator { inputSourceFor(spec).validate(spec); } validateSteps(policy.steps()); + validateAssetReferences(policy); validateChain(policy.steps()); validateOutput(policy.output()); } + /** + * A step binding that names stored assets ({@code asset:}) must resolve in the policy's own + * team, so a saved pipeline can't fail its later (principal-less) runs on a missing file, and a + * client can't bind another team's asset by id. A binding without that prefix names a file + * supplied with the run instead, and is only checked when the run arrives. + */ + private void validateAssetReferences(Policy policy) { + for (PipelineStep step : policy.steps()) { + for (Map.Entry binding : step.fileParameters().entrySet()) { + if (!PolicyAssetRefs.isAssetRef(binding.getValue())) { + continue; + } + List ids = PolicyAssetRefs.assetIds(binding.getValue()); + if (ids.isEmpty()) { + throw new IllegalArgumentException( + "step " + + step.operation() + + " has an empty file binding for field '" + + binding.getKey() + + "'"); + } + for (String id : ids) { + // One message for absent and other-team: existence must not leak across teams. + assetStore + .get(id) + .filter(asset -> Objects.equals(asset.teamId(), policy.teamId())) + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown stored file: " + id)); + } + } + } + } + /** * Reject a chain whose steps cannot run on each other. Such a policy saves fine today and only * fails part-way through its first run, which for a scheduled one may be much later. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java index 99d8884996..00a072e895 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java @@ -9,7 +9,9 @@ import java.util.Map; * *

{@code fileParameters} maps a tool's named file field (e.g. {@code stampImage}, beyond the * primary {@code fileInput} stream) to an asset key in the run's supporting-file store, keeping - * supporting inputs out of the document stream that flows step to step. + * supporting inputs out of the document stream that flows step to step. The key is either {@code + * asset:} for a stored supporting file or a plain name supplied with the run itself; see {@code + * PolicyAssetRefs}. */ public record PipelineStep( String operation, Map parameters, Map fileParameters) { 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/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java index b0bd210e10..10bf80e278 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -34,6 +34,7 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.ObjectLockMode; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectResponse; import software.amazon.awssdk.services.s3.model.S3Exception; @@ -176,6 +177,7 @@ public class S3OutputSink implements PolicyOutputSink { if (conditionalPuts) { put.ifNoneMatch("*"); } + applyObjectLock(put, config); try { PutObjectResponse response = client.putObject(put.build(), RequestBody.fromFile(staged)); @@ -267,4 +269,24 @@ public class S3OutputSink implements PolicyOutputSink { throw new IllegalStateException("MD5 unavailable", e); } } + + /** + * Write the object under Object Lock retention when the connection asks for it. + * + *

The retain-until date is computed per object from "now", so a policy that runs daily gives + * each document its own full retention window rather than a shared deadline. + * + *

Requires the bucket to have Object Lock enabled; S3 rejects the PUT otherwise, which is + * the correct outcome - silently storing a deletable object while an operator believes it is + * locked would be worse than failing. + */ + private static void applyObjectLock(PutObjectRequest.Builder put, S3Config config) { + if (config.objectLockMode() == null || config.retentionDays() == null) { + return; + } + put.objectLockMode(ObjectLockMode.fromValue(config.objectLockMode())) + .objectLockRetainUntilDate( + java.time.Instant.now() + .plus(config.retentionDays(), java.time.temporal.ChronoUnit.DAYS)); + } } 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/policy/s3/S3Config.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java index c9d3eabd83..faaa8fe76d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java @@ -19,7 +19,9 @@ public record S3Config( String endpoint, String accessKeyId, String secretAccessKey, - boolean snapshot) { + boolean snapshot, + String objectLockMode, + Integer retentionDays) { private static final String BUCKET_OPTION = "bucket"; private static final String REGION_OPTION = "region"; @@ -28,6 +30,12 @@ public record S3Config( private static final String ACCESS_KEY_ID_OPTION = "accessKeyId"; private static final String SECRET_ACCESS_KEY_OPTION = "secretAccessKey"; private static final String MODE_OPTION = "mode"; + private static final String OBJECT_LOCK_MODE_OPTION = "objectLockMode"; + private static final String RETENTION_DAYS_OPTION = "retentionDays"; + + private static final String LOCK_GOVERNANCE = "GOVERNANCE"; + private static final String LOCK_COMPLIANCE = "COMPLIANCE"; + private static final int MAX_RETENTION_DAYS = 36525; private static final String MODE_CONSUME = "consume"; private static final String MODE_SNAPSHOT = "snapshot"; @@ -52,6 +60,41 @@ public record S3Config( if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) { throw new IllegalArgumentException("s3 config 'mode' must be 'consume' or 'snapshot'"); } + // Object Lock: write-once retention, for records that must survive an administrator. + // COMPLIANCE cannot be shortened or deleted by anyone (not even the account root) before + // the retain-until date; GOVERNANCE can be bypassed with a specific IAM permission, so + // only COMPLIANCE is the answer to SEC 17a-4(f) / FINRA. The bucket must already have + // Object Lock enabled - it cannot be turned on per-object - and that in turn requires + // versioning, which can then never be suspended. + String objectLockMode = trimmed(options.get(OBJECT_LOCK_MODE_OPTION)); + if (objectLockMode != null) { + objectLockMode = objectLockMode.toUpperCase(java.util.Locale.ROOT); + if (!LOCK_GOVERNANCE.equals(objectLockMode) + && !LOCK_COMPLIANCE.equals(objectLockMode)) { + throw new IllegalArgumentException( + "s3 config 'objectLockMode' must be 'GOVERNANCE' or 'COMPLIANCE'"); + } + } + Integer retentionDays = null; + Object rawRetention = options.get(RETENTION_DAYS_OPTION); + if (rawRetention != null && !rawRetention.toString().isBlank()) { + try { + retentionDays = Integer.valueOf(rawRetention.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("s3 config 'retentionDays' must be a number"); + } + if (retentionDays < 1 || retentionDays > MAX_RETENTION_DAYS) { + throw new IllegalArgumentException( + "s3 config 'retentionDays' must be between 1 and " + MAX_RETENTION_DAYS); + } + } + // S3 rejects one without the other, so catch it here where the operator can still fix it + // rather than at upload time on a worker thread. + if ((objectLockMode == null) != (retentionDays == null)) { + throw new IllegalArgumentException( + "s3 config 'objectLockMode' and 'retentionDays' must be set together"); + } + return new S3Config( bucket, region == null ? "us-east-1" : region, @@ -59,7 +102,9 @@ public record S3Config( endpoint, accessKeyId, secretAccessKey, - MODE_SNAPSHOT.equals(mode)); + MODE_SNAPSHOT.equals(mode), + objectLockMode, + retentionDays); } private static String validEndpoint(String endpoint) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index de6be8ef5d..70d67bba0f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -8,6 +8,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import stirling.software.proprietary.policy.asset.PolicyAssetRefs; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; @@ -76,6 +77,15 @@ public class InProcessPolicyStore implements PolicyStore { .toList(); } + @Override + public boolean anyPolicyReferences(String assetId) { + return policies.values().stream() + .anyMatch( + policy -> + PolicyAssetRefs.referencedAssetIds(policy.steps()) + .contains(assetId)); + } + @Override public List findBindingsByTriggerType(String triggerType) { List enabled = policies.values().stream().filter(Policy::enabled).toList(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 4f91f9e930..6edaa76c78 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -120,6 +120,11 @@ public class JpaPolicyStore implements PolicyStore { .toList(); } + @Override + public boolean anyPolicyReferences(String assetId) { + return assetId != null && !assetId.isBlank() && repository.anyMentioning(assetId); + } + @Override public List findBindingsByTriggerType(String triggerType) { List enabled = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java index 1c8465c82a..07f31b4a9c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java @@ -35,6 +35,14 @@ public interface PolicyRepository extends JpaRepository { @Query("select p from PolicyEntity p order by coalesce(p.sortOrder, 0) asc, p.id asc") List findAllOrdered(); + /** + * Whether any policy's stored JSON mentions this id. Matched against the raw column rather than + * parsed steps so a row we can't deserialize still protects the assets it references; asset ids + * are UUIDs, so a substring false positive only ever means "keep". + */ + @Query("select count(p) > 0 from PolicyEntity p where p.policyJson like concat('%', :id, '%')") + boolean anyMentioning(@Param("id") String id); + /** * The team's policy rows, locked for the transaction (SELECT … FOR UPDATE). Appending a new * policy reads the max {@code sortOrder} from these under the lock, so two concurrent creates diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index fa2a7e9b87..d86663d596 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -19,6 +19,13 @@ public interface PolicyStore { /** Policies owned by the given team, loaded scoped rather than fetched globally. */ List findByTeam(Long teamId); + /** + * Whether any policy mentions this stored-asset id, across every team. Asked before reclaiming + * an asset, so it must answer from the raw stored form: a row {@link #all()} skips as + * unreadable still holds its certificate hostage, and deleting that would be unrecoverable. + */ + boolean anyPolicyReferences(String assetId); + /** * Enabled inputs with the given trigger type, as {@code (policy, input)} bindings, so a * background trigger fires each input independently and pulls only its own source. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java index eef27703f2..36dcaecfd2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java @@ -2,7 +2,6 @@ package stirling.software.proprietary.security.configuration; import java.time.Duration; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; @@ -19,7 +18,6 @@ public class CacheConfig { private final ApplicationProperties applicationProperties; - @Autowired public CacheConfig(ApplicationProperties applicationProperties) { this.applicationProperties = applicationProperties; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 3bc3807e1e..389ffa535e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -32,6 +32,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.repository", "stirling.software.proprietary.storage.repository", "stirling.software.proprietary.workflow.repository", + "stirling.software.proprietary.policy.asset", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", "stirling.software.proprietary.policy.migration", @@ -46,6 +47,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.model", "stirling.software.proprietary.storage.model", "stirling.software.proprietary.workflow.model", + "stirling.software.proprietary.policy.asset", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", "stirling.software.proprietary.policy.migration", 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/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index 0acc98ee55..b2ce4adb68 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -61,7 +61,7 @@ public class CustomSaml2ResponseAuthenticationConverter @Override public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().get(0); + Assertion assertion = responseToken.getResponse().getAssertions().getFirst(); Map> attributes = extractAttributes(assertion); // Debug log with actual values @@ -117,6 +117,6 @@ public class CustomSaml2ResponseAuthenticationConverter private String getFirstAttributeValue(Map> attributes, String name) { List values = attributes.get(name); - return values != null && !values.isEmpty() ? values.get(0).toString() : null; + return values != null && !values.isEmpty() ? values.getFirst().toString() : null; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java index f18f6bd160..2c074c0a6b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java @@ -232,7 +232,7 @@ public class DatabaseService implements DatabaseServiceInterface { List backupList = this.getBackupList(); backupList.sort(Comparator.comparing(FileInfo::getModificationDate).reversed()); - Path latestExport = Path.of(backupList.get(0).getFilePath()); + Path latestExport = Path.of(backupList.getFirst().getFilePath()); executeDatabaseScript(latestExport); } @@ -376,7 +376,7 @@ public class DatabaseService implements DatabaseServiceInterface { List backupList = this.getBackupList(); List> deletedFiles = new ArrayList<>(); if (!backupList.isEmpty()) { - FileInfo lastBackup = backupList.get(backupList.size() - 1); + FileInfo lastBackup = backupList.getLast(); try { Files.deleteIfExists(Path.of(lastBackup.getFilePath())); deletedFiles.add(Pair.of(lastBackup, true)); @@ -399,7 +399,7 @@ public class DatabaseService implements DatabaseServiceInterface { Comparator.comparing( p -> p.getFileName().substring(7, p.getFileName().length() - 4))); - FileInfo oldestFile = filteredBackupList.get(0); + FileInfo oldestFile = filteredBackupList.getFirst(); Files.deleteIfExists(Path.of(oldestFile.getFilePath())); log.info("Deleted oldest backup: {}", oldestFile.getFileName()); } catch (IOException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index 19adb9a970..e615416e59 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -193,6 +193,6 @@ public class SessionPersistentRegistry implements SessionRegistry { allSessions.sort((s1, s2) -> s2.getLastRequest().compareTo(s1.getLastRequest())); // The first session in the list is the latest session for the given principal name - return Optional.of(allSessions.get(0)); + return Optional.of(allSessions.getFirst()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java index 86f944ca9d..9a76cdd25b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java @@ -299,7 +299,7 @@ public class PortalInfraAuditService { Object files = data.get("files"); if (files instanceof List list && !list.isEmpty() - && list.get(0) instanceof Map f) { + && list.getFirst() instanceof Map f) { Object name = ((Map) f).get("name"); return name != null ? String.valueOf(name) : null; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java index 24814bfc69..1217d81cc8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java @@ -21,10 +21,14 @@ import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.cluster.s3.S3Clients; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.storage.crypto.AuditingStorageEncryptionListener; import stirling.software.proprietary.storage.crypto.EncryptingStorageProvider; import stirling.software.proprietary.storage.crypto.FileEncryptionKeyService; import stirling.software.proprietary.storage.crypto.FileEncryptionMasterKey; +import stirling.software.proprietary.storage.crypto.StorageEncryptionAuditListener; import stirling.software.proprietary.storage.crypto.StorageEncryptionState; import stirling.software.proprietary.storage.provider.DatabaseStorageProvider; import stirling.software.proprietary.storage.provider.LocalStorageProvider; @@ -42,23 +46,33 @@ public class StorageProviderConfig { private final StoredFileBlobRepository storedFileBlobRepository; private final FileEncryptionKeyRepository fileEncryptionKeyRepository; private final LicenseKeyChecker licenseKeyChecker; + private final AuditService auditService; /** - * The encryption state behind the always-installed decorator. Key machinery is created eagerly - * when the write flag is on (licence-gated) or key rows already exist — so a wrong master key - * fails startup, not the first download — and lazily if encrypted content shows up later - * (config drift on one cluster node must fail loudly, never stream ciphertext). Turning the - * flag off or losing the licence only stops encrypting new writes; decryption stays available. + * The encryption state behind the always-installed decorator, shared with the admin API and + * migration job. Key machinery is created eagerly when the write flag is on (licence-gated) or + * key rows already exist — so a wrong master key fails startup, not the first download — and + * lazily if encrypted content shows up later (config drift on one cluster node must fail + * loudly, never stream ciphertext). Turning the flag off or losing the licence only stops + * encrypting new writes; decryption stays available. */ @Bean public StorageEncryptionState storageEncryptionState( @Value("${stirling.security.fileEncryptionKey:}") String configuredFileEncryptionKey, + @Value("${stirling.security.fileEncryptionKeyPrevious:}") + String previousFileEncryptionKey, + @Value("${stirling.security.fileEncryptionKeyVersion:1}") int fileEncryptionKeyVersion, @Value("${cluster.enabled:false}") boolean clusterEnabled, PlatformTransactionManager transactionManager) { boolean writeEnabled = applicationProperties.getStorage().getEncryption().isEnabled(); if (writeEnabled) { licenseKeyChecker.requireProOrEnterprise("storage.encryption"); + warnIfAuditUnavailable(); } + StorageEncryptionAuditListener listener = + new AuditingStorageEncryptionListener( + auditService, + applicationProperties.getStorage().getEncryption().isAuditReads()); // Key creation must commit independently of any caller transaction (see // FileEncryptionKeyService#createActive). TransactionTemplate requiresNew = new TransactionTemplate(transactionManager); @@ -68,8 +82,14 @@ public class StorageProviderConfig { writeEnabled, () -> createKeyService( - configuredFileEncryptionKey, clusterEnabled, requiresNew), - fileEncryptionKeyRepository); + configuredFileEncryptionKey, + previousFileEncryptionKey, + fileEncryptionKeyVersion, + clusterEnabled, + listener, + requiresNew), + fileEncryptionKeyRepository, + listener); // The registry table may not exist when storage is unused, so only probe if it is on. boolean probeForExistingKeys = !writeEnabled && applicationProperties.getStorage().isEnabled(); @@ -82,12 +102,33 @@ public class StorageProviderConfig { return state; } + /** + * Encryption at rest is available on Pro, but {@code AuditService} only records events on an + * Enterprise licence. Without this warning a Pro operator would enable encryption, be told it + * is audited, and silently get no encrypt/decrypt/revocation trail at all. + */ + private void warnIfAuditUnavailable() { + if (licenseKeyChecker.getPremiumLicenseEnabledResult() != License.ENTERPRISE) { + log.warn( + "Storage encryption at rest is enabled, but audit events require an Enterprise" + + " licence: encrypt/decrypt, revocation and plaintext-export events" + + " will NOT be recorded on this licence tier. Encryption itself is" + + " unaffected. See devGuide/STORAGE_ENCRYPTION_AT_REST.md"); + } + } + private FileEncryptionKeyService createKeyService( - String configuredKey, boolean clusterEnabled, TransactionOperations keyCreationTx) { + String configuredKey, + String previousKey, + int keyVersion, + boolean clusterEnabled, + StorageEncryptionAuditListener listener, + TransactionOperations keyCreationTx) { FileEncryptionMasterKey masterKey = - new FileEncryptionMasterKey(configuredKey, clusterEnabled); + new FileEncryptionMasterKey(configuredKey, previousKey, keyVersion, clusterEnabled); FileEncryptionKeyService keyService = - new FileEncryptionKeyService(fileEncryptionKeyRepository, masterKey, keyCreationTx); + new FileEncryptionKeyService( + fileEncryptionKeyRepository, masterKey, listener, keyCreationTx); // Wrong key must fail fast, not silently start a second key hierarchy. keyService.verifyMasterKey(); return keyService; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java index 4eb299cd24..3e5589b31d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java @@ -5,6 +5,7 @@ import java.net.URI; import java.time.Duration; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import org.springframework.http.ContentDisposition; @@ -31,7 +32,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; import stirling.software.proprietary.storage.model.FileShare; import stirling.software.proprietary.storage.model.StoredFile; import stirling.software.proprietary.storage.model.api.CreateShareLinkRequest; @@ -56,6 +59,7 @@ public class FileStorageController { private final FileStorageService fileStorageService; private final StorageProvider storageProvider; + private final AuditService auditService; @PostMapping( value = "/files", @@ -262,6 +266,21 @@ public class FileStorageController { private ResponseEntity buildFileResponse( StoredFile file, boolean inline) { org.springframework.core.io.Resource resource = fileStorageService.loadFile(file); + if (file.getEncryptionKeyId() != null) { + // Compliance marker: a plaintext copy of encrypted-at-rest content left the platform + // (inline=true is an in-app view; false is a saved download). + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of( + "action", + "plaintextExport", + "fileId", + file.getId(), + "inline", + inline, + "keyId", + file.getEncryptionKeyId())); + } String contentType = file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminController.java new file mode 100644 index 0000000000..d0845678ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminController.java @@ -0,0 +1,275 @@ +package stirling.software.proprietary.storage.controller; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.dao.DataAccessException; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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; +import org.springframework.web.server.ResponseStatusException; + +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.audit.AuditEventType; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.storage.crypto.FileEncryptionKeyService; +import stirling.software.proprietary.storage.crypto.StorageEncryptionException; +import stirling.software.proprietary.storage.crypto.StorageEncryptionState; +import stirling.software.proprietary.storage.model.FileEncryptionKey; +import stirling.software.proprietary.storage.model.api.StorageEncryptionStatusResponse; +import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.storage.service.StorageEncryptionMigrationService; + +/** + * Admin surface for storage encryption at rest: status/backup verification, the per-scope kill + * switch, the encrypt-existing migration, and master-key rotation. Deliberately no delete endpoint + * — key material can be disabled but never destroyed through the API. + */ +@RestController +@RequestMapping("/api/v1/admin/storage-encryption") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Admin: Storage Encryption", description = "Encryption-at-rest administration") +public class StorageEncryptionAdminController { + + private final ApplicationProperties applicationProperties; + private final StorageEncryptionState encryptionState; + private final FileEncryptionKeyRepository keyRepository; + private final StoredFileRepository storedFileRepository; + private final StorageEncryptionMigrationService migrationService; + private final AuditService auditService; + + /** + * Reports write state, master-key fingerprint and encrypted/plaintext counts. Refuses rather + * than reading the registry when storage is off, because a deployment that never stores files + * may not have the table at all — the same reason the decorator's boot probe is gated. + */ + @GetMapping(value = "/status", produces = MediaType.APPLICATION_JSON_VALUE) + public StorageEncryptionStatusResponse status() { + if (!applicationProperties.getStorage().isEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled"); + } + try { + return buildStatus(); + } catch (DataAccessException e) { + log.warn("Could not read storage encryption status: {}", e.getMessage()); + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, + "The storage encryption key registry could not be read", + e); + } + } + + private StorageEncryptionStatusResponse buildStatus() { + List keys = + keyRepository.findAll(Sort.by("createdAt")).stream() + .map( + k -> + new StorageEncryptionStatusResponse.KeyInfo( + k.getKeyId(), + k.getScopeType().name(), + k.getScopeId(), + k.getKeyVersion(), + k.getMasterKeyVersion(), + k.getStatus().name(), + k.getCreatedAt(), + k.getStatusChangedAt(), + k.getStatusChangedBy())) + .toList(); + String fingerprint = null; + Integer masterKeyVersion = null; + if (encryptionState.isMaterialised()) { + try { + FileEncryptionKeyService keyService = encryptionState.keyService(); + fingerprint = keyService.masterKey().fingerprint(); + masterKeyVersion = keyService.masterKey().currentVersion(); + } catch (StorageEncryptionException ignored) { + // Materialisation failed; status still reports counts and key rows. + } + } + return new StorageEncryptionStatusResponse( + encryptionState.isWriteEnabled(), + encryptionState.isMaterialised(), + fingerprint, + masterKeyVersion, + storedFileRepository.countByEncryptionKeyIdIsNotNull(), + storedFileRepository.countByEncryptionKeyIdIsNull(), + keys); + } + + /** Kill switch: content under this key fails closed (403) until re-enabled. Reversible. */ + @PostMapping("/keys/{keyId}/disable") + public StorageEncryptionStatusResponse.KeyInfo disableKey(@PathVariable UUID keyId) { + FileEncryptionKey row = setStatus(keyId, FileEncryptionKey.Status.DISABLED); + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of("action", "key.disabled", "keyId", keyId.toString())); + return toKeyInfo(row); + } + + /** + * Reverses the kill switch: only DISABLED keys can be enabled, and they come back ACTIVE unless + * the scope acquired another active key while revoked, in which case they come back RETIRED — + * readable, but not a second key wrapping new writes. The response carries the resulting + * status. + */ + @PostMapping("/keys/{keyId}/enable") + public StorageEncryptionStatusResponse.KeyInfo enableKey(@PathVariable UUID keyId) { + FileEncryptionKey existing = + keyRepository + .findById(keyId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "No such encryption key")); + if (existing.getStatus() != FileEncryptionKey.Status.DISABLED) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Key is not disabled (status: " + existing.getStatus() + ")"); + } + FileEncryptionKeyService keyService = requireKeyService(); + FileEncryptionKey row; + try { + row = keyService.enable(keyId, currentUsername()); + } catch (StorageEncryptionException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage()); + } + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of( + "action", + "key.enabled", + "keyId", + keyId.toString(), + "status", + row.getStatus().name())); + return toKeyInfo(row); + } + + @PostMapping("/migrate") + public MigrationStatusResponse startMigration() { + try { + return MigrationStatusResponse.from(migrationService.start()); + } catch (IllegalStateException e) { + throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage()); + } + } + + @GetMapping(value = "/migrate/status", produces = MediaType.APPLICATION_JSON_VALUE) + public MigrationStatusResponse migrationStatus() { + return migrationService + .status() + .map(MigrationStatusResponse::from) + .orElse(MigrationStatusResponse.IDLE); + } + + /** + * Re-wraps KEK rows below the configured master-key version under the primary master key. Key + * material is never accepted over HTTP — new keys arrive via config/env and a restart; this + * endpoint only performs the re-wrap step of the rotation runbook. + */ + @PostMapping("/master/rotate") + public Map rotateMasterKey() { + FileEncryptionKeyService keyService = requireKeyService(); + int rewrapped; + try { + rewrapped = keyService.rotateMasterKey(); + } catch (StorageEncryptionException e) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Rotation failed: " + e.getMessage(), e); + } + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of( + "action", + "master.rotated", + "rowsRewrapped", + rewrapped, + "masterKeyVersion", + keyService.masterKey().currentVersion())); + return Map.of( + "rewrapped", + rewrapped, + "masterKeyVersion", + keyService.masterKey().currentVersion()); + } + + private FileEncryptionKey setStatus(UUID keyId, FileEncryptionKey.Status status) { + FileEncryptionKeyService keyService = requireKeyService(); + try { + return keyService.setKeyStatus(keyId, status, currentUsername()); + } catch (StorageEncryptionException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage()); + } + } + + private FileEncryptionKeyService requireKeyService() { + try { + return encryptionState.keyService(); + } catch (StorageEncryptionException e) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Storage encryption is not configured: " + e.getMessage(), + e); + } + } + + private static String currentUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null ? auth.getName() : "system"; + } + + private static StorageEncryptionStatusResponse.KeyInfo toKeyInfo(FileEncryptionKey k) { + return new StorageEncryptionStatusResponse.KeyInfo( + k.getKeyId(), + k.getScopeType().name(), + k.getScopeId(), + k.getKeyVersion(), + k.getMasterKeyVersion(), + k.getStatus().name(), + k.getCreatedAt(), + k.getStatusChangedAt(), + k.getStatusChangedBy()); + } + + public record MigrationStatusResponse( + String state, + Long total, + Long processed, + Long skipped, + Long failed, + Instant startedAt, + Instant finishedAt) { + + static final MigrationStatusResponse IDLE = + new MigrationStatusResponse("IDLE", null, null, null, null, null, null); + + static MigrationStatusResponse from(StorageEncryptionMigrationService.MigrationStatus s) { + return new MigrationStatusResponse( + s.state().name(), + s.total(), + s.processed(), + s.skipped(), + s.failed(), + s.startedAt(), + s.finishedAt()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/AuditingStorageEncryptionListener.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/AuditingStorageEncryptionListener.java new file mode 100644 index 0000000000..2b46c9dc62 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/AuditingStorageEncryptionListener.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.storage.crypto; + +import java.util.Map; +import java.util.UUID; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.service.AuditService; + +/** + * Bridges storage-encryption events into the audit trail. Per-read decrypt events can be noisy on + * busy installs, so they honour {@code storage.encryption.auditReads}; denials and key lifecycle + * events are always recorded. + */ +public class AuditingStorageEncryptionListener implements StorageEncryptionAuditListener { + + private final AuditService auditService; + private final boolean auditReads; + + public AuditingStorageEncryptionListener(AuditService auditService, boolean auditReads) { + this.auditService = auditService; + this.auditReads = auditReads; + } + + @Override + public void encrypted(String storageKey, UUID keyId) { + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of("action", "encrypt", "storageKey", storageKey, "keyId", keyId.toString())); + } + + @Override + public void decrypted(String storageKey, UUID keyId) { + if (!auditReads) { + return; + } + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of("action", "decrypt", "storageKey", storageKey, "keyId", keyId.toString())); + } + + @Override + public void decryptDenied(UUID keyId, String reason) { + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of("action", "decrypt.denied", "keyId", keyId.toString(), "reason", reason)); + } + + @Override + public void keyCreated(UUID keyId, String scope, int version) { + auditService.audit( + AuditEventType.STORAGE_ENCRYPTION, + Map.of( + "action", + "key.created", + "keyId", + keyId.toString(), + "scope", + scope, + "keyVersion", + version)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java index 9a22dfd35d..9d1def1935 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java @@ -75,6 +75,15 @@ public class EncryptingStorageProvider implements StorageProvider { this(delegate, StorageEncryptionState.of(writeEnabled, keys), null); } + /** Test convenience with an explicit audit listener. */ + public EncryptingStorageProvider( + StorageProvider delegate, + FileEncryptionKeyService keys, + boolean writeEnabled, + StorageEncryptionAuditListener auditListener) { + this(delegate, StorageEncryptionState.of(writeEnabled, keys, auditListener), null); + } + @Override public StoredObject store(User owner, MultipartFile file) throws IOException { if (!state.isWriteEnabled()) { @@ -119,6 +128,7 @@ public class EncryptingStorageProvider implements StorageProvider { stored.getStorageKey(), kek.keyId(), file.getSize()); + state.auditListener().encrypted(stored.getStorageKey(), kek.keyId()); return stored.toBuilder() .sizeBytes(file.getSize()) .encryptionKeyId(kek.keyId().toString()) @@ -140,9 +150,9 @@ public class EncryptingStorageProvider implements StorageProvider { public Resource load(String storageKey) throws IOException { Resource raw = delegate.load(storageKey); if (raw.isOpen()) { - return wrapOneShot(raw); + return wrapOneShot(storageKey, raw); } - return wrapReopenable(raw); + return wrapReopenable(storageKey, raw); } @Override @@ -261,7 +271,7 @@ public class EncryptingStorageProvider implements StorageProvider { // ---- load helpers -------------------------------------------------------------------- /** Re-openable delegate (local file, DB byte array): sniff via a throwaway stream. */ - private Resource wrapReopenable(Resource raw) throws IOException { + private Resource wrapReopenable(String storageKey, Resource raw) throws IOException { byte[] prefix; try (InputStream in = raw.getInputStream()) { prefix = in.readNBytes(EncryptedFileFormat.HEADER_LENGTH); @@ -271,6 +281,7 @@ public class EncryptingStorageProvider implements StorageProvider { return raw; } byte[] dek = unwrapDek(header); + state.auditListener().decrypted(storageKey, header.keyId()); return new ReopenableDecryptedResource(raw, header, dek); } @@ -280,7 +291,7 @@ public class EncryptingStorageProvider implements StorageProvider { * tampered wrap) must close it — leaking here would starve the S3 connection pool precisely * when the kill switch is being exercised. */ - private Resource wrapOneShot(Resource raw) throws IOException { + private Resource wrapOneShot(String storageKey, Resource raw) throws IOException { InputStream in = raw.getInputStream(); try { byte[] prefix = in.readNBytes(EncryptedFileFormat.HEADER_LENGTH); @@ -306,6 +317,7 @@ public class EncryptingStorageProvider implements StorageProvider { } catch (GeneralSecurityException e) { throw new StorageEncryptionException("Failed to open decrypting stream", e); } + state.auditListener().decrypted(storageKey, header.keyId()); return new OneShotResource(decrypting, header.plaintextLength(), raw.getDescription()); } catch (IOException | RuntimeException e) { try { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyService.java index 716d4f1a7b..c81f03249c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyService.java @@ -4,7 +4,11 @@ import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Base64; +import java.util.List; +import java.util.Optional; import java.util.UUID; import org.springframework.dao.DataIntegrityViolationException; @@ -37,6 +41,7 @@ public class FileEncryptionKeyService { private final FileEncryptionKeyRepository repository; private final FileEncryptionMasterKey masterKey; + private final StorageEncryptionAuditListener auditListener; /** * Runs key-row creation in its own committed transaction (REQUIRES_NEW in production). Callers @@ -54,18 +59,42 @@ public class FileEncryptionKeyService { public FileEncryptionKeyService( FileEncryptionKeyRepository repository, FileEncryptionMasterKey masterKey) { - this(repository, masterKey, TransactionOperations.withoutTransaction()); + this( + repository, + masterKey, + StorageEncryptionAuditListener.NOOP, + TransactionOperations.withoutTransaction()); + } + + public FileEncryptionKeyService( + FileEncryptionKeyRepository repository, + FileEncryptionMasterKey masterKey, + StorageEncryptionAuditListener auditListener) { + this(repository, masterKey, auditListener, TransactionOperations.withoutTransaction()); } public FileEncryptionKeyService( FileEncryptionKeyRepository repository, FileEncryptionMasterKey masterKey, TransactionOperations keyCreationTx) { + this(repository, masterKey, StorageEncryptionAuditListener.NOOP, keyCreationTx); + } + + public FileEncryptionKeyService( + FileEncryptionKeyRepository repository, + FileEncryptionMasterKey masterKey, + StorageEncryptionAuditListener auditListener, + TransactionOperations keyCreationTx) { this.repository = repository; this.masterKey = masterKey; + this.auditListener = auditListener; this.keyCreationTx = keyCreationTx; } + public FileEncryptionMasterKey masterKey() { + return masterKey; + } + public record ScopeKek(UUID keyId, byte[] key) {} /** The ACTIVE KEK for the owner's scope, created on first use. */ @@ -100,14 +129,17 @@ public class FileEncryptionKeyService { repository .findById(keyId) .orElseThrow( - () -> - new StorageEncryptionException( - "No encryption key " - + keyId - + " — the key registry does not match the" - + " stored data (restored from an older" - + " database backup?)")); + () -> { + auditListener.decryptDenied(keyId, "key not found"); + return new StorageEncryptionException( + "No encryption key " + + keyId + + " — the key registry does not match the" + + " stored data (restored from an older" + + " database backup?)"); + }); if (row.getStatus() == FileEncryptionKey.Status.DISABLED) { + auditListener.decryptDenied(keyId, "key disabled"); throw new StorageKeyRevokedException( "Encryption key " + keyId + " is disabled; access to this content is revoked"); } @@ -115,42 +147,164 @@ public class FileEncryptionKeyService { } /** - * Startup self-check: proves the resolved master key can unwrap an existing row, so a wrong key - * fails fast instead of silently writing new files under a second key hierarchy. + * Flips a key's status (the kill switch) and invalidates the local caches so the change is + * immediate on this node; other cluster nodes converge within the cache TTL. + */ + public FileEncryptionKey setKeyStatus(UUID keyId, FileEncryptionKey.Status status, String actor) + throws StorageEncryptionException { + FileEncryptionKey row = + repository + .findById(keyId) + .orElseThrow( + () -> new StorageEncryptionException("No encryption key " + keyId)); + row.setStatus(status); + row.setStatusChangedAt(LocalDateTime.now()); + row.setStatusChangedBy(actor); + FileEncryptionKey saved = repository.save(row); + invalidate(keyId); + return saved; + } + + /** + * Reverses the kill switch. Returns the key to ACTIVE only while its scope has no other ACTIVE + * key, otherwise to RETIRED — which unwraps existing content just the same, and keeps exactly + * one key wrapping new writes per scope. + * + *

The second case is reached whenever the scope uploaded anything while revoked: those + * writes minted a fresh ACTIVE key, since revoking a key blocks reads of existing content + * rather than stopping the scope from storing new files. + */ + public FileEncryptionKey enable(UUID keyId, String actor) throws StorageEncryptionException { + FileEncryptionKey row = + repository + .findById(keyId) + .orElseThrow( + () -> new StorageEncryptionException("No encryption key " + keyId)); + boolean scopeHasAnotherActiveKey = + activeForScope(row.getScopeType(), row.getScopeId()) + .filter(other -> !other.getKeyId().equals(keyId)) + .isPresent(); + FileEncryptionKey.Status target = + scopeHasAnotherActiveKey + ? FileEncryptionKey.Status.RETIRED + : FileEncryptionKey.Status.ACTIVE; + if (scopeHasAnotherActiveKey) { + log.info( + "Enabling key {} as RETIRED: {}:{} already has an active key wrapping new" + + " writes. Existing content under {} is readable again.", + keyId, + row.getScopeType(), + row.getScopeId(), + keyId); + } + return setKeyStatus(keyId, target, actor); + } + + /** + * Drops cached material for a key so status changes take effect without waiting out the TTL. + */ + public void invalidate(UUID keyId) { + unwrapCache.invalidate(keyId); + activeScopeCache.asMap().values().removeIf(keyId::equals); + } + + /** + * Re-wraps every KEK row below the configured master-key version under the primary master key. + * Cheap by design: touches only this small table, never file contents. Returns the number of + * rows re-wrapped. + */ + public int rotateMasterKey() throws StorageEncryptionException { + int rewrapped = 0; + for (FileEncryptionKey row : + repository.findByMasterKeyVersionLessThan(masterKey.currentVersion())) { + byte[] kek = unwrapRow(row); + row.setWrappedKey( + Base64.getEncoder() + .encodeToString(masterKey.wrap(kek, aadFor(row.getKeyId())))); + row.setMasterKeyVersion(masterKey.currentVersion()); + repository.save(row); + invalidate(row.getKeyId()); + rewrapped++; + } + return rewrapped; + } + + /** + * Startup self-check: proves the configured master key can unwrap every KEK row, so a + * wrong or half-rotated key fails fast instead of silently writing new files under a second key + * hierarchy — or leaving some scopes' files unreadable while the rest of the app looks healthy. + * Checking all rows rather than a sample matters because rotation can stop part-way; the table + * holds one row per scope per rotation, so this stays cheap. */ public void verifyMasterKey() { - repository - .findFirstByStatus(FileEncryptionKey.Status.ACTIVE) - .or(() -> repository.findFirstByStatus(FileEncryptionKey.Status.RETIRED)) - .ifPresent( - row -> { - try { - unwrapRow(row); - } catch (StorageEncryptionException e) { - throw new IllegalStateException( - "The configured file encryption key (fingerprint " - + masterKey.fingerprint() - + ") cannot unwrap existing key " - + row.getKeyId() - + ". Refusing to start with a mismatched key —" - + " restore the original" - + " STIRLING_FILE_ENCRYPTION_KEY /" - + " file-encryption.key.", - e); - } - }); + long pending = repository.countByMasterKeyVersionLessThan(masterKey.currentVersion()); + if (pending > 0) { + log.warn( + "{} encryption key row(s) are still wrapped by the previous master key. Run" + + " POST /api/v1/admin/storage-encryption/master/rotate, then remove" + + " stirling.security.fileEncryptionKeyPrevious.", + pending); + } + long stranded = repository.countByMasterKeyVersionGreaterThan(masterKey.currentVersion()); + if (stranded > 0) { + log.warn( + "{} encryption key row(s) are wrapped by a master-key version newer than the" + + " configured stirling.security.fileEncryptionKeyVersion={}. Rotation" + + " only re-wraps rows below the configured version, so these rows can" + + " never be re-wrapped while it stays this low.", + stranded, + masterKey.currentVersion()); + } + List unreadable = new ArrayList<>(); + StorageEncryptionException firstFailure = null; + // DISABLED rows are included: revocation is meant to be reversible, and a row that cannot + // be unwrapped would not come back on enable. + for (FileEncryptionKey row : repository.findAll()) { + try { + unwrapRow(row); + } catch (StorageEncryptionException e) { + unreadable.add(row); + if (firstFailure == null) { + firstFailure = e; + } + } + } + if (!unreadable.isEmpty()) { + throw new IllegalStateException( + "The configured file encryption key (fingerprint " + + masterKey.fingerprint() + + ") cannot unwrap " + + unreadable.size() + + " of " + + repository.count() + + " encryption key row(s), starting with " + + unreadable.get(0).getKeyId() + + " (" + + unreadable.get(0).getScopeType() + + ":" + + unreadable.get(0).getScopeId() + + ", master key version " + + unreadable.get(0).getMasterKeyVersion() + + "). Files under those keys would be unreadable. Refusing to start —" + + " restore the original key as" + + " stirling.security.fileEncryptionKeyPrevious (or as" + + " STIRLING_FILE_ENCRYPTION_KEY) and re-run the rotation.", + firstFailure); + } } private FileEncryptionKey findOrCreateActive( FileEncryptionKey.ScopeType scopeType, long scopeId) throws StorageEncryptionException { - return repository - .findFirstByScopeTypeAndScopeIdAndStatus( - scopeType, scopeId, FileEncryptionKey.Status.ACTIVE) - .orElseGet(() -> createActive(scopeType, scopeId)); + return activeForScope(scopeType, scopeId).orElseGet(() -> createActive(scopeType, scopeId)); } - // Package-private so the @DataJpaTest can drive the duplicate-insert recovery - // deterministically. + private Optional activeForScope( + FileEncryptionKey.ScopeType scopeType, long scopeId) { + return repository.findFirstByScopeTypeAndScopeIdAndStatusOrderByKeyVersionDesc( + scopeType, scopeId, FileEncryptionKey.Status.ACTIVE); + } + + // Package-private so the @DataJpaTest can drive duplicate-insert recovery deterministically. FileEncryptionKey createActive(FileEncryptionKey.ScopeType scopeType, long scopeId) { byte[] kek = new byte[EncryptedFileFormat.DEK_LENGTH_BYTES]; RANDOM.nextBytes(kek); @@ -168,7 +322,7 @@ public class FileEncryptionKeyService { row.setKeyVersion(version); row.setWrappedKey( Base64.getEncoder().encodeToString(masterKey.wrap(kek, aadFor(row.getKeyId())))); - row.setMasterKeyVersion(FileEncryptionMasterKey.CURRENT_VERSION); + row.setMasterKeyVersion(masterKey.currentVersion()); row.setStatus(FileEncryptionKey.Status.ACTIVE); try { // saveAndFlush inside a fresh transaction so a unique-constraint violation surfaces @@ -180,13 +334,11 @@ public class FileEncryptionKeyService { scopeType, scopeId); unwrapCache.put(saved.getKeyId(), kek); + auditListener.keyCreated(saved.getKeyId(), scopeType + ":" + scopeId, version); return saved; } catch (DataIntegrityViolationException raced) { // Another node created the scope key concurrently; use theirs. - return repository - .findFirstByScopeTypeAndScopeIdAndStatus( - scopeType, scopeId, FileEncryptionKey.Status.ACTIVE) - .orElseThrow(() -> raced); + return activeForScope(scopeType, scopeId).orElseThrow(() -> raced); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionMasterKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionMasterKey.java index 025b281b79..5b78b00135 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionMasterKey.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionMasterKey.java @@ -43,16 +43,55 @@ public class FileEncryptionMasterKey { private static final String KEY_FILE = "file-encryption.key"; private static final SecureRandom RANDOM = new SecureRandom(); - /** Bumped when master rotation ships (P2); recorded on every wrapped KEK row. */ + /** Default master-key version when rotation has never been configured. */ public static final int CURRENT_VERSION = 1; private final SecretKey key; + private final SecretKey previousKey; + private final int currentVersion; public FileEncryptionMasterKey(String configuredKey, boolean clusterEnabled) { + this(configuredKey, null, CURRENT_VERSION, clusterEnabled); + } + + /** + * @param previousKeyBase64 optional outgoing master key kept only during rotation: {@link + * #unwrap} falls back to it so existing KEK rows stay readable until {@code rotate} + * re-wraps them under the primary key. + * @param currentVersion admin-bumped version stamped on newly wrapped KEK rows ({@code + * stirling.security.fileEncryptionKeyVersion}); rotation re-wraps rows below it. + */ + public FileEncryptionMasterKey( + String configuredKey, + String previousKeyBase64, + int currentVersion, + boolean clusterEnabled) { this.key = resolveKey(configuredKey, clusterEnabled); + this.previousKey = + previousKeyBase64 == null || previousKeyBase64.isBlank() + ? null + : decodeKey( + previousKeyBase64, "stirling.security.fileEncryptionKeyPrevious"); + if (currentVersion < 1) { + log.warn( + "stirling.security.fileEncryptionKeyVersion={} is not a valid version; using 1", + currentVersion); + } + this.currentVersion = Math.max(1, currentVersion); log.info( - "Storage encryption master key initialised (AES-256-GCM, fingerprint {})", - fingerprint()); + "Storage encryption master key initialised (AES-256-GCM, fingerprint {}, version" + + " {}{})", + fingerprint(), + this.currentVersion, + previousKey != null ? ", previous key configured for rotation" : ""); + } + + public int currentVersion() { + return currentVersion; + } + + public boolean hasPreviousKey() { + return previousKey != null; } private static SecretKey resolveKey(String configuredKey, boolean clusterEnabled) { @@ -152,10 +191,29 @@ public class FileEncryptionMasterKey { } public byte[] unwrap(byte[] wrapped, byte[] associatedData) throws GeneralSecurityException { + try { + return unwrapWith(key, wrapped, associatedData); + } catch (GeneralSecurityException primaryFailure) { + if (previousKey == null) { + throw primaryFailure; + } + try { + return unwrapWith(previousKey, wrapped, associatedData); + } catch (GeneralSecurityException previousFailure) { + // Report the primary key's failure, or a corrupt row gets diagnosed through the + // outgoing key's error message. + primaryFailure.addSuppressed(previousFailure); + throw primaryFailure; + } + } + } + + private static byte[] unwrapWith(SecretKey unwrapKey, byte[] wrapped, byte[] associatedData) + throws GeneralSecurityException { byte[] iv = Arrays.copyOfRange(wrapped, 0, IV_BYTES); byte[] ciphertext = Arrays.copyOfRange(wrapped, IV_BYTES, wrapped.length); Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + cipher.init(Cipher.DECRYPT_MODE, unwrapKey, new GCMParameterSpec(GCM_TAG_BITS, iv)); cipher.updateAAD(associatedData); return cipher.doFinal(ciphertext); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/ResourceUpload.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/ResourceUpload.java new file mode 100644 index 0000000000..fa48d3bb39 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/ResourceUpload.java @@ -0,0 +1,74 @@ +package stirling.software.proprietary.storage.crypto; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; + +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +/** + * Presents a loaded {@link Resource} as a {@link MultipartFile} so existing blobs can be re-stored + * through {@code StorageProvider.store(...)} — the encrypt-existing migration re-uploads plaintext + * blobs through the encrypting decorator this way. The size must be accurate: the decorator records + * it as the plaintext length in the blob header. + */ +public class ResourceUpload implements MultipartFile { + + private final Resource resource; + private final String filename; + private final String contentType; + private final long sizeBytes; + + public ResourceUpload(Resource resource, String filename, String contentType, long sizeBytes) { + this.resource = resource; + this.filename = filename; + this.contentType = contentType; + this.sizeBytes = sizeBytes; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return filename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return sizeBytes == 0; + } + + @Override + public long getSize() { + return sizeBytes; + } + + @Override + public byte[] getBytes() throws IOException { + try (InputStream in = resource.getInputStream()) { + return in.readAllBytes(); + } + } + + @Override + public InputStream getInputStream() throws IOException { + return resource.getInputStream(); + } + + @Override + public void transferTo(File dest) throws IOException { + try (InputStream in = resource.getInputStream()) { + Files.copy(in, dest.toPath()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionAuditListener.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionAuditListener.java new file mode 100644 index 0000000000..978bf3af0d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionAuditListener.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.storage.crypto; + +import java.util.UUID; + +/** + * Security-event hook for the storage-encryption layer. The crypto classes stay plain (non-Spring) + * objects, so audit emission is injected through this interface; production wires {@link + * AuditingStorageEncryptionListener}, tests default to {@link #NOOP}. + */ +public interface StorageEncryptionAuditListener { + + StorageEncryptionAuditListener NOOP = new StorageEncryptionAuditListener() {}; + + default void encrypted(String storageKey, UUID keyId) {} + + default void decrypted(String storageKey, UUID keyId) {} + + default void decryptDenied(UUID keyId, String reason) {} + + default void keyCreated(UUID keyId, String scope, int version) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionErrors.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionErrors.java new file mode 100644 index 0000000000..f04e5a5dcc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionErrors.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.storage.crypto; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * Shared HTTP translation for encryption failures, so every path that serves stored bytes answers a + * revoked key the same way. {@link StorageKeyRevokedException} extends {@code IOException}, so a + * caller that only catches {@code IOException} reports a deliberate, reversible policy state as a + * server fault. + */ +public final class StorageEncryptionErrors { + + private StorageEncryptionErrors() {} + + public static ResponseStatusException revoked(StorageKeyRevokedException cause) { + return new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Access to this file has been revoked (its encryption key is disabled)", + cause); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionState.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionState.java index 8fe465406b..0d2bd112fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionState.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionState.java @@ -10,12 +10,13 @@ import stirling.software.proprietary.storage.repository.FileEncryptionKeyReposit /** * Holds the storage-encryption machinery for the always-installed {@link EncryptingStorageProvider} - * decorator. + * decorator, shared with the admin API and migration job so kill-switch cache invalidation hits the + * caches the decorator reads. * *

The decorator is unconditional so a node whose config lags the cluster (flag off, rolling * deploy, config drift) can never stream raw ciphertext: it always sniffs the header and decrypts - * or fails loudly. The expensive parts — resolving the master key (which may generate a key file) - * and the key service — are created lazily: eagerly at startup only when the write flag is on or + * or fails loudly. The expensive parts - resolving the master key (which may generate a key file) + * and the key service - are created lazily: eagerly at startup only when the write flag is on or * key rows already exist (preserving the fail-fast master-key verification), otherwise on first * encounter with an encrypted blob. */ @@ -27,6 +28,7 @@ public class StorageEncryptionState { private final boolean writeEnabled; private final Supplier keyServiceFactory; private final FileEncryptionKeyRepository keyRepository; + private final StorageEncryptionAuditListener auditListener; private volatile FileEncryptionKeyService keyService; private volatile boolean keysExistEverChecked; @@ -36,17 +38,27 @@ public class StorageEncryptionState { public StorageEncryptionState( boolean writeEnabled, Supplier keyServiceFactory, - FileEncryptionKeyRepository keyRepository) { + FileEncryptionKeyRepository keyRepository, + StorageEncryptionAuditListener auditListener) { this.writeEnabled = writeEnabled; this.keyServiceFactory = keyServiceFactory; this.keyRepository = keyRepository; + this.auditListener = auditListener; } /** Test convenience: a pre-materialised state around an existing service. */ public static StorageEncryptionState of( boolean writeEnabled, FileEncryptionKeyService keyService) { + return of(writeEnabled, keyService, StorageEncryptionAuditListener.NOOP); + } + + /** Test convenience: a pre-materialised state with an explicit audit listener. */ + public static StorageEncryptionState of( + boolean writeEnabled, + FileEncryptionKeyService keyService, + StorageEncryptionAuditListener auditListener) { StorageEncryptionState state = - new StorageEncryptionState(writeEnabled, () -> keyService, null); + new StorageEncryptionState(writeEnabled, () -> keyService, null, auditListener); state.keyService = keyService; return state; } @@ -56,9 +68,18 @@ public class StorageEncryptionState { return writeEnabled; } + /** True once the key machinery has been materialised (eagerly at boot or on first use). */ + public boolean isMaterialised() { + return keyService != null; + } + + public StorageEncryptionAuditListener auditListener() { + return auditListener; + } + /** * The key service, created on first use. A failure here (no key material, wrong key) is a loud, - * actionable error — never silently-served ciphertext. + * actionable error - never silently-served ciphertext. */ public FileEncryptionKeyService keyService() throws StorageEncryptionException { FileEncryptionKeyService current = keyService; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StorageEncryptionStatusResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StorageEncryptionStatusResponse.java new file mode 100644 index 0000000000..0699a1aecd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StorageEncryptionStatusResponse.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Admin view of storage encryption at rest. The master-key fingerprint (SHA-256 prefix, never key + * material) lets admins verify their key backup matches the live key. + */ +public record StorageEncryptionStatusResponse( + boolean writeEnabled, + boolean active, + String masterKeyFingerprint, + Integer masterKeyVersion, + long encryptedFiles, + long plaintextFiles, + List keys) { + + public record KeyInfo( + UUID keyId, + String scopeType, + long scopeId, + int keyVersion, + int masterKeyVersion, + String status, + LocalDateTime createdAt, + LocalDateTime statusChangedAt, + String statusChangedBy) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileEncryptionKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileEncryptionKeyRepository.java index b6563f8c26..90450ee4ad 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileEncryptionKeyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileEncryptionKeyRepository.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -11,11 +12,29 @@ import stirling.software.proprietary.storage.model.FileEncryptionKey; @Repository public interface FileEncryptionKeyRepository extends JpaRepository { - Optional findFirstByScopeTypeAndScopeIdAndStatus( + /** + * Ordered so that if a scope ever holds more than one row in the given status, every node picks + * the same one instead of following database row order. + */ + Optional findFirstByScopeTypeAndScopeIdAndStatusOrderByKeyVersionDesc( FileEncryptionKey.ScopeType scopeType, long scopeId, FileEncryptionKey.Status status); Optional findFirstByScopeTypeAndScopeIdOrderByKeyVersionDesc( FileEncryptionKey.ScopeType scopeType, long scopeId); Optional findFirstByStatus(FileEncryptionKey.Status status); + + /** + * Rows still wrapped by an older master key. Counting and fetching are separate so the startup + * check can ask the database for a number instead of materialising every row. + */ + long countByMasterKeyVersionLessThan(int masterKeyVersion); + + List findByMasterKeyVersionLessThan(int masterKeyVersion); + + /** + * Rows wrapped by a version the configuration has since gone below. Rotation only re-wraps rows + * under the configured version, so these can never be re-wrapped. + */ + long countByMasterKeyVersionGreaterThan(int masterKeyVersion); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java index 47545ab58a..b7ff77c987 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.storage.repository; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -73,4 +74,53 @@ public interface StoredFileRepository extends JpaRepository { + "WHERE sf.workflowSession IN " + "(SELECT ws FROM WorkflowSession ws WHERE ws.owner = :user)") void clearWorkflowSessionReferencesByOwner(@Param("user") User user); + + // ---- storage encryption at rest ---------------------------------------------------- + + long countByEncryptionKeyIdIsNull(); + + long countByEncryptionKeyIdIsNotNull(); + + /** + * Next batch of plaintext files for the encrypt-existing migration. Cursor-based ({@code id > + * lastId}) so per-file failures don't wedge the loop, and owner is fetched eagerly because the + * job re-stores blobs under the owner's scope key outside a web transaction. + */ + @Query( + "SELECT f FROM StoredFile f JOIN FETCH f.owner " + + "WHERE f.encryptionKeyId IS NULL AND f.id > :lastId ORDER BY f.id ASC") + List findMigratableAfter(@Param("lastId") long lastId, Pageable pageable); + + /** + * Compare-and-swap updates for the migration: each blob's storage key only flips if it still + * holds the value the job read, so a user replacing the file mid-migration wins and the job + * discards its own copy. The main-blob swap also stamps the key id, which is what removes the + * row from the migration's selection. + */ + @Modifying + @Transactional + @Query( + "UPDATE StoredFile f SET f.storageKey = :newKey, f.encryptionKeyId = :keyId " + + "WHERE f.id = :id AND f.storageKey = :oldKey") + int swapMainBlob( + @Param("id") Long id, + @Param("oldKey") String oldKey, + @Param("newKey") String newKey, + @Param("keyId") String keyId); + + @Modifying + @Transactional + @Query( + "UPDATE StoredFile f SET f.historyStorageKey = :newKey " + + "WHERE f.id = :id AND f.historyStorageKey = :oldKey") + int swapHistoryBlob( + @Param("id") Long id, @Param("oldKey") String oldKey, @Param("newKey") String newKey); + + @Modifying + @Transactional + @Query( + "UPDATE StoredFile f SET f.auditLogStorageKey = :newKey " + + "WHERE f.id = :id AND f.auditLogStorageKey = :oldKey") + int swapAuditLogBlob( + @Param("id") Long id, @Param("oldKey") String oldKey, @Param("newKey") String newKey); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java index 9764f392b1..7b7d9b68a6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java @@ -32,6 +32,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.storage.crypto.StorageEncryptionErrors; import stirling.software.proprietary.storage.crypto.StorageKeyRevokedException; import stirling.software.proprietary.storage.model.FileShare; import stirling.software.proprietary.storage.model.FileShareAccess; @@ -496,16 +497,11 @@ public class FileStorageService { try { return storageProvider.load(file.getStorageKey()); } catch (StorageKeyRevokedException e) { - // Deliberate, reversible policy state (encryption key disabled), not a server fault — - // surface as forbidden so the client sees "revoked", not "internal error". log.warn( "Access to stored file {} denied: {}", file != null ? file.getId() : null, e.getMessage()); - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Access to this file has been revoked (its encryption key is disabled)", - e); + throw StorageEncryptionErrors.revoked(e); } catch (IOException e) { log.error( "Failed to load stored file {} (key: {})", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageEncryptionMigrationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageEncryptionMigrationService.java new file mode 100644 index 0000000000..6df4d1aaea --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageEncryptionMigrationService.java @@ -0,0 +1,316 @@ +package stirling.software.proprietary.storage.service; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.core.io.Resource; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.storage.crypto.ResourceUpload; +import stirling.software.proprietary.storage.crypto.StorageEncryptionState; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.provider.StoredObject; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +/** + * Encrypts the pre-existing plaintext backlog after storage encryption is enabled (new writes are + * encrypted from the moment the flag is on; this job converts what was stored before). + * + *

Crash-safe per file: store the encrypted copy under a NEW storage key, compare-and-swap the + * row, and only then delete the old blob. A CAS miss means a user replaced the file mid-migration — + * the job discards its own copy and moves on. The worst crash outcome is an orphaned new blob, + * never a lost file, and re-runs are idempotent because selection is {@code encryptionKeyId IS + * NULL} (only stamped by the final main-blob swap). + */ +@Service +@Slf4j +public class StorageEncryptionMigrationService { + + private static final int PAGE_SIZE = 25; + private static final long PAUSE_BETWEEN_PAGES_MS = 200; + + private final StoredFileRepository storedFileRepository; + private final StorageProvider storageProvider; + private final StorageEncryptionState encryptionState; + private final AuditService auditService; + + private final AtomicReference currentRun = new AtomicReference<>(); + + public StorageEncryptionMigrationService( + StoredFileRepository storedFileRepository, + StorageProvider storageProvider, + StorageEncryptionState encryptionState, + AuditService auditService) { + this.storedFileRepository = storedFileRepository; + this.storageProvider = storageProvider; + this.encryptionState = encryptionState; + this.auditService = auditService; + } + + public enum State { + RUNNING, + COMPLETED, + FAILED + } + + public record MigrationStatus( + State state, + long total, + long processed, + long skipped, + long failed, + Instant startedAt, + Instant finishedAt) {} + + /** Starts the migration; throws {@link IllegalStateException} if one is already running. */ + public MigrationStatus start() { + if (!encryptionState.isWriteEnabled()) { + throw new IllegalStateException( + "storage.encryption.enabled must be on before migrating existing files"); + } + // Captured here because the run itself executes on a virtual thread with no security + // context: without this, re-encrypting every stored file is attributed to "system". + String principal = auditService.captureCurrentPrincipal(); + Run run = new Run(storedFileRepository.countByEncryptionKeyIdIsNull(), principal); + Run previous = currentRun.get(); + if (previous != null && previous.state == State.RUNNING) { + throw new IllegalStateException("A migration is already running"); + } + if (!currentRun.compareAndSet(previous, run)) { + throw new IllegalStateException("A migration is already running"); + } + auditService.audit( + principal, + AuditEventType.STORAGE_ENCRYPTION, + Map.of("action", "migration.started", "plaintextFiles", run.total)); + Thread.ofVirtual().name("storage-encryption-migration").start(() -> execute(run)); + return run.snapshot(); + } + + /** Latest run's progress, or empty if none has started this uptime. */ + public Optional status() { + Run run = currentRun.get(); + return Optional.ofNullable(run).map(Run::snapshot); + } + + private void execute(Run run) { + log.info("Storage encryption migration started ({} plaintext files)", run.total); + State terminal; + try { + terminal = migratePages(run); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Storage encryption migration interrupted"); + terminal = State.FAILED; + } catch (Exception e) { + log.error("Storage encryption migration aborted", e); + terminal = State.FAILED; + } + run.finish(terminal); + MigrationStatus done = run.snapshot(); + log.info( + "Storage encryption migration finished: {} encrypted, {} skipped, {} failed", + done.processed(), + done.skipped(), + done.failed()); + auditService.audit( + run.principal, + AuditEventType.STORAGE_ENCRYPTION, + Map.of( + "action", "migration.completed", + "state", done.state().name(), + "encrypted", done.processed(), + "skipped", done.skipped(), + "failed", done.failed())); + } + + /** Walks the plaintext backlog a page at a time; returns the state the run ends in. */ + private State migratePages(Run run) throws InterruptedException { + long lastId = 0; + while (true) { + List page = + storedFileRepository.findMigratableAfter(lastId, PageRequest.of(0, PAGE_SIZE)); + if (page.isEmpty()) { + return State.COMPLETED; + } + for (StoredFile file : page) { + if (!encryptionState.isWriteEnabled()) { + // Run-level condition, not a per-file failure: carrying on would copy every + // remaining file as plaintext and delete it again. + log.warn( + "Encryption write path turned off mid-run; stopping with {} of {} files" + + " encrypted. Re-enable storage.encryption.enabled and start" + + " the migration again.", + run.processed.get(), + run.total); + return State.FAILED; + } + lastId = file.getId(); + try { + migrateFile(file, run); + } catch (Exception e) { + run.failed.incrementAndGet(); + log.error( + "Failed to encrypt stored file {} (key {})", + file.getId(), + file.getStorageKey(), + e); + } + } + Thread.sleep(PAUSE_BETWEEN_PAGES_MS); + } + } + + /** + * The three blobs are swapped independently, so a compare-and-swap miss on a secondary one + * abandons the whole file for this run: the row keeps {@code encryptionKeyId} null and the next + * run picks it up from the top. + */ + private void migrateFile(StoredFile file, Run run) throws IOException { + User owner = file.getOwner(); + + // Secondary blobs first; the main-blob swap stamps encryptionKeyId and thereby removes + // the row from the migration's selection, so it must come last. + if (file.getHistoryStorageKey() != null) { + String oldKey = file.getHistoryStorageKey(); + String newKey = + reencrypt( + owner, + oldKey, + file.getHistoryFilename(), + file.getHistoryContentType(), + file.getHistorySizeBytes()) + .getStorageKey(); + if (storedFileRepository.swapHistoryBlob(file.getId(), oldKey, newKey) == 1) { + deleteQuietly(oldKey); + } else { + deleteQuietly(newKey); + run.skipped.incrementAndGet(); + return; + } + } + if (file.getAuditLogStorageKey() != null) { + String oldKey = file.getAuditLogStorageKey(); + String newKey = + reencrypt( + owner, + oldKey, + file.getAuditLogFilename(), + file.getAuditLogContentType(), + file.getAuditLogSizeBytes()) + .getStorageKey(); + if (storedFileRepository.swapAuditLogBlob(file.getId(), oldKey, newKey) == 1) { + deleteQuietly(oldKey); + } else { + deleteQuietly(newKey); + run.skipped.incrementAndGet(); + return; + } + } + + String oldKey = file.getStorageKey(); + StoredObject encrypted = + reencrypt( + owner, + oldKey, + file.getOriginalFilename(), + file.getContentType(), + file.getSizeBytes()); + if (encrypted.getEncryptionKeyId() == null) { + // The flag flipped between this page's check and the store; the loop stops on the next + // file, so this only ever discards one plaintext copy. + deleteQuietly(encrypted.getStorageKey()); + throw new IllegalStateException("Encryption write path is no longer active"); + } + if (storedFileRepository.swapMainBlob( + file.getId(), + oldKey, + encrypted.getStorageKey(), + encrypted.getEncryptionKeyId()) + == 1) { + deleteQuietly(oldKey); + run.processed.incrementAndGet(); + } else { + deleteQuietly(encrypted.getStorageKey()); + run.skipped.incrementAndGet(); + } + } + + private StoredObject reencrypt( + User owner, String storageKey, String filename, String contentType, Long sizeBytes) + throws IOException { + Resource plaintext = storageProvider.load(storageKey); + long size = -1; + try { + size = plaintext.contentLength(); + } catch (IOException | RuntimeException e) { + // Some resources refuse contentLength() once partially read. + size = -1; + } + if (size < 0) { + // A resource may also simply report -1; the row's recorded plaintext size is + // authoritative, so prefer it over failing the file. + size = sizeBytes != null ? sizeBytes : -1; + } + if (size < 0) { + throw new IOException("Cannot determine plaintext size for " + storageKey); + } + return storageProvider.store( + owner, new ResourceUpload(plaintext, filename, contentType, size)); + } + + private void deleteQuietly(String storageKey) { + try { + storageProvider.delete(storageKey); + } catch (IOException e) { + log.warn("Could not delete blob {} after migration step; orphaned", storageKey, e); + } + } + + private static final class Run { + private final long total; + + /** The admin who started the run, so the completion event is attributed to them. */ + private final String principal; + + private final Instant startedAt = Instant.now(); + private final AtomicLong processed = new AtomicLong(); + private final AtomicLong skipped = new AtomicLong(); + private final AtomicLong failed = new AtomicLong(); + private volatile State state = State.RUNNING; + private volatile Instant finishedAt; + + private Run(long total, String principal) { + this.total = total; + this.principal = principal; + } + + private void finish(State terminal) { + this.state = terminal; + this.finishedAt = Instant.now(); + } + + private MigrationStatus snapshot() { + return new MigrationStatus( + state, + total, + processed.get(), + skipped.get(), + failed.get(), + startedAt, + finishedAt); + } + } +} 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/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java index 57ad1c97e7..dd3fcb9cc3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java @@ -119,20 +119,18 @@ public class UnifiedAccessControlService { public ShareAccessRole getEffectiveRole(WorkflowParticipant participant) { ParticipantStatus status = participant.getStatus(); - switch (status) { - case SIGNED: - case DECLINED: - // After action completed, downgrade to read-only - return ShareAccessRole.VIEWER; - case PENDING: - case NOTIFIED: - case VIEWED: - // Active participants retain their assigned role - return participant.getAccessRole(); - default: + return switch (status) { + case SIGNED, DECLINED -> + // After action completed, downgrade to read-only + ShareAccessRole.VIEWER; + case PENDING, NOTIFIED, VIEWED -> + // Active participants retain their assigned role + participant.getAccessRole(); + default -> { log.warn("Unknown participant status: {}", status); - return ShareAccessRole.VIEWER; - } + yield ShareAccessRole.VIEWER; + } + }; } /** Checks if a user can access a specific file */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 41aed7d9c2..4c60c60df2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -37,6 +37,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.crypto.StorageEncryptionErrors; +import stirling.software.proprietary.storage.crypto.StorageKeyRevokedException; import stirling.software.proprietary.storage.model.FilePurpose; import stirling.software.proprietary.storage.model.ShareAccessRole; import stirling.software.proprietary.storage.model.StoredFile; @@ -444,9 +446,21 @@ public class WorkflowSessionService { HttpStatus.NOT_FOUND, "No processed file available for this session"); } - String storageKey = session.getProcessedFile().getStorageKey(); - org.springframework.core.io.Resource resource = storageProvider.load(storageKey); - return resource.getContentAsByteArray(); + return readBlob(session.getProcessedFile().getStorageKey()); + } + + /** + * Reads a stored blob, translating a revoked encryption key into 403 the same way {@code + * FileStorageService} does. Without this the callers' {@code IOException} handling would report + * an administrator's deliberate revocation as a server error. + */ + private byte[] readBlob(String storageKey) throws IOException { + try { + return storageProvider.load(storageKey).getContentAsByteArray(); + } catch (StorageKeyRevokedException e) { + log.warn("Access to workflow blob {} denied: {}", storageKey, e.getMessage()); + throw StorageEncryptionErrors.revoked(e); + } } /** Retrieves the original file data for a workflow session. */ @@ -458,9 +472,7 @@ public class WorkflowSessionService { HttpStatus.NOT_FOUND, "Original file no longer available (session may be finalized)"); } - String storageKey = session.getOriginalFile().getStorageKey(); - org.springframework.core.io.Resource resource = storageProvider.load(storageKey); - return resource.getContentAsByteArray(); + return readBlob(session.getOriginalFile().getStorageKey()); } /** Deletes a workflow session and associated files. */ @@ -658,9 +670,7 @@ public class WorkflowSessionService { } try { - org.springframework.core.io.Resource resource = - storageProvider.load(fileToServe.getStorageKey()); - return resource.getContentAsByteArray(); + return readBlob(fileToServe.getStorageKey()); } catch (IOException e) { log.error("Failed to retrieve document for session {}", sessionId, e); throw new ResponseStatusException( 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/integration/crypto/CredentialEncryptionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java index e6e5d356bf..9f424e6367 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java @@ -61,4 +61,47 @@ class CredentialEncryptionTest { assertThatThrownBy(() -> CredentialEncryption.decrypt(tampered)) .isInstanceOf(IllegalStateException.class); } + + @Test + void encryptStaysBase64SoStringColumnsKeepTheirWireFormat() { + assertThat(Base64.getDecoder().decode(CredentialEncryption.encrypt("secret"))).isNotEmpty(); + } + + @Test + void byteRoundTripRecoversPlaintext() { + // Not valid UTF-8: the byte path must not go via a charset. + byte[] plaintext = new byte[] {0, -1, 0x7F, -128}; + + byte[] encrypted = CredentialEncryption.encryptBytes(plaintext); + + assertThat(encrypted).isNotEqualTo(plaintext); + assertThat(CredentialEncryption.decryptBytes(encrypted)).isEqualTo(plaintext); + } + + @Test + void sameBytesProduceDifferentCiphertext() { + byte[] plaintext = "repeated-secret".getBytes(); + + byte[] first = CredentialEncryption.encryptBytes(plaintext); + byte[] second = CredentialEncryption.encryptBytes(plaintext); + + assertThat(first).isNotEqualTo(second); + assertThat(CredentialEncryption.decryptBytes(first)).isEqualTo(plaintext); + assertThat(CredentialEncryption.decryptBytes(second)).isEqualTo(plaintext); + } + + @Test + void tamperedBlobIsRejected() { + byte[] encrypted = CredentialEncryption.encryptBytes("top-secret".getBytes()); + encrypted[encrypted.length - 1] ^= 0x01; + + assertThatThrownBy(() -> CredentialEncryption.decryptBytes(encrypted)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void nullBytesPassThrough() { + assertThat(CredentialEncryption.encryptBytes(null)).isNull(); + assertThat(CredentialEncryption.decryptBytes(null)).isNull(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java new file mode 100644 index 0000000000..4677166aeb --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java @@ -0,0 +1,127 @@ +package stirling.software.proprietary.policy.asset; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import stirling.software.proprietary.integration.crypto.CredentialEncryption; + +/** + * {@link JpaPolicyAssetStore} on a real (H2) database: proves the entity (including its LOB column) + * creates via ddl-auto, that bytes round-trip while sitting encrypted in the column, and that the + * metadata projections select every field in the right order. + */ +@DataJpaTest +@Import(CredentialEncryption.class) +@TestPropertySource( + properties = + "stirling.security.credentialEncryptionKey=" + + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") +class JpaPolicyAssetStoreDbTest { + + @Autowired private PolicyAssetRepository repository; + + @Test + void savesAndReadsBackMetadataAndContent() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + byte[] content = new byte[] {1, 2, 3, 4}; + + PolicyAsset saved = + store.save( + new PolicyAsset(null, "logo.png", "image/png", 0, "owner", 7L, 42L), + content); + + assertFalse(saved.id().isBlank()); + assertEquals(content.length, saved.size()); + PolicyAsset read = store.get(saved.id()).orElseThrow(); + // Every field: a transposed projection argument is invisible to the compiler. + assertEquals(saved, read); + assertEquals("logo.png", read.fileName()); + assertEquals("image/png", read.contentType()); + assertEquals("owner", read.owner()); + assertEquals(content.length, read.size()); + assertEquals(7L, read.teamId()); + assertEquals(42L, read.createdAt()); + assertArrayEquals(content, store.content(saved.id()).orElseThrow()); + } + + @Test + void storesTheBytesEncrypted() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + byte[] content = "-----BEGIN PRIVATE KEY-----".getBytes(); + + PolicyAsset saved = + store.save(new PolicyAsset(null, "cert.p12", null, 0, null, null, 1L), content); + + byte[] atRest = repository.findById(saved.id()).orElseThrow().getData(); + assertFalse(Arrays.equals(content, atRest)); + assertArrayEquals(content, CredentialEncryption.decryptBytes(atRest)); + // Plaintext length, not the ciphertext's: it is the size the UI shows. + assertEquals(content.length, saved.size()); + } + + @Test + void findByTeamScopesRowsAndMatchesNullTeam() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset teamAsset = + store.save(new PolicyAsset(null, "a.pdf", null, 0, null, 7L, 1L), new byte[] {1}); + PolicyAsset noTeamAsset = + store.save(new PolicyAsset(null, "b.pdf", null, 0, null, null, 2L), new byte[] {2}); + + List team = store.findByTeam(7L); + List noTeam = store.findByTeam(null); + + assertEquals(List.of(teamAsset), team); + assertEquals(List.of(noTeamAsset.id()), noTeam.stream().map(PolicyAsset::id).toList()); + } + + @Test + void allReturnsEveryAssetNewestFirst() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset oldest = + store.save(new PolicyAsset(null, "a.pdf", null, 0, null, 7L, 1L), new byte[] {1}); + PolicyAsset newest = + store.save(new PolicyAsset(null, "b.pdf", null, 0, null, 99L, 3L), new byte[] {2}); + + assertEquals( + List.of(newest.id(), oldest.id()), + store.all().stream().map(PolicyAsset::id).toList()); + } + + @Test + void idsCreatedBeforeSelectsOnlyOlderRows() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset old = + store.save(new PolicyAsset(null, "old.p12", null, 0, null, 7L, 1L), new byte[] {1}); + store.save(new PolicyAsset(null, "new.p12", null, 0, null, 7L, 1000L), new byte[] {2}); + + assertEquals(List.of(old.id()), store.idsCreatedBefore(500L)); + } + + @Test + void deleteRemovesTheRow() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset saved = + store.save(new PolicyAsset(null, "x.p12", null, 0, null, null, 1L), new byte[] {1}); + + assertTrue(store.delete(saved.id())); + assertFalse(store.get(saved.id()).isPresent()); + assertFalse(store.delete(saved.id())); + } + + @SpringBootConfiguration + @AutoConfigurationPackage + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetCleanerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetCleanerTest.java new file mode 100644 index 0000000000..7d7fbb5d97 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetCleanerTest.java @@ -0,0 +1,195 @@ +package stirling.software.proprietary.policy.asset; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +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.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link PolicyAssetCleaner}: assets a policy stops referencing are deleted once no other + * policy in the team references them, never across teams, and uploads no policy ever bound are + * reclaimed by the sweep once they are old enough. + */ +class PolicyAssetCleanerTest { + + private static final Instant NOW = Instant.ofEpochMilli(10_000_000_000L); + + private final InProcessPolicyAssetStore assetStore = new InProcessPolicyAssetStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private final PolicyAssetCleaner cleaner = + new PolicyAssetCleaner(assetStore, policyStore, () -> NOW); + + @Test + void deletesAssetsDroppedByASave() { + PolicyAsset dropped = asset("old.png", 7L); + PolicyAsset kept = asset("kept.png", 7L); + Policy previous = + savedPolicy( + "p1", + 7L, + step("watermarkImage", dropped.id()), + step("stampImage", kept.id())); + Policy saved = savedPolicy("p1", 7L, step("stampImage", kept.id())); + + cleaner.cleanupAfterSave(previous, saved); + + assertFalse(assetStore.get(dropped.id()).isPresent()); + assertTrue(assetStore.get(kept.id()).isPresent()); + } + + @Test + void keepsAnAssetAnotherPolicyStillReferences() { + PolicyAsset shared = asset("shared.p12", 7L); + savedPolicy("other", 7L, step("p12File", shared.id())); + Policy deleted = policy("gone", 7L, true, step("p12File", shared.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(shared.id()).isPresent()); + } + + @Test + void keepsAnAssetOnlyAPausedPolicyReferences() { + // Pausing is a re-save with enabled=false, so reference counting must span every policy in + // the team, not just the enabled ones. + PolicyAsset shared = asset("paused.p12", 7L); + policyStore.save(policy("paused", 7L, false, step("p12File", shared.id()))); + Policy deleted = policy("gone", 7L, true, step("p12File", shared.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(shared.id()).isPresent()); + } + + @Test + void deletesAssetsAfterTheirLastReferencingPolicyIsDeleted() { + PolicyAsset orphaned = asset("orphan.png", 7L); + Policy deleted = policy("gone", 7L, true, step("watermarkImage", orphaned.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertFalse(assetStore.get(orphaned.id()).isPresent()); + } + + @Test + void neverDeletesAnotherTeamsAsset() { + PolicyAsset foreign = asset("foreign.png", 99L); + Policy deleted = policy("gone", 7L, true, step("watermarkImage", foreign.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(foreign.id()).isPresent()); + } + + @Test + void ignoresARunSuppliedFileKey() { + // No asset: prefix, so the binding names a run-supplied file and nothing is a candidate. + PolicyAsset unrelated = asset("unrelated.png", 7L); + Policy deleted = + policy( + "gone", + 7L, + true, + new PipelineStep("/api/v1/x", Map.of(), Map.of("watermarkImage", "logo"))); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(unrelated.id()).isPresent()); + } + + @Test + void swallowsAStoreFailureSoTheCallersOwnWorkIsNotLost() { + // The policy write has already committed by the time cleanup runs. + PolicyAsset orphaned = asset("orphan.png", 7L); + PolicyStore failing = mock(PolicyStore.class); + when(failing.findByTeam(any())).thenThrow(new RuntimeException("db down")); + PolicyAssetCleaner guarded = new PolicyAssetCleaner(assetStore, failing, () -> NOW); + Policy deleted = policy("gone", 7L, true, step("watermarkImage", orphaned.id())); + + assertDoesNotThrow(() -> guarded.cleanupAfterDelete(deleted)); + assertTrue(assetStore.get(orphaned.id()).isPresent()); + } + + @Test + void sweepDeletesAnUploadNoPolicyEverReferenced() { + PolicyAsset abandoned = agedAsset("abandoned.p12", NOW.toEpochMilli() - dayMillis(2), 7L); + + cleaner.sweepAbandonedUploads(); + + assertFalse(assetStore.get(abandoned.id()).isPresent()); + } + + @Test + void sweepKeepsARecentUpload() { + // The grace window is what stops the sweep racing a builder session that is mid-save. + PolicyAsset justUploaded = agedAsset("fresh.p12", NOW.toEpochMilli() - 60_000L, 7L); + + cleaner.sweepAbandonedUploads(); + + assertTrue(assetStore.get(justUploaded.id()).isPresent()); + } + + @Test + void sweepKeepsAnOldAssetAPolicyStillReferences() { + PolicyAsset bound = agedAsset("bound.p12", NOW.toEpochMilli() - dayMillis(30), 7L); + savedPolicy("p1", 7L, step("p12File", bound.id())); + + cleaner.sweepAbandonedUploads(); + + assertTrue(assetStore.get(bound.id()).isPresent()); + } + + @Test + void sweepReclaimsAcrossTeams() { + // An abandoned upload is unreferenced everywhere or nowhere, so the sweep is not scoped. + PolicyAsset teamSeven = agedAsset("seven.png", NOW.toEpochMilli() - dayMillis(2), 7L); + PolicyAsset teamNinetyNine = agedAsset("99.png", NOW.toEpochMilli() - dayMillis(2), 99L); + + cleaner.sweepAbandonedUploads(); + + assertFalse(assetStore.get(teamSeven.id()).isPresent()); + assertFalse(assetStore.get(teamNinetyNine.id()).isPresent()); + } + + private static long dayMillis(int days) { + return days * 24L * 60L * 60L * 1000L; + } + + private PolicyAsset asset(String name, Long teamId) { + return assetStore.save( + new PolicyAsset(null, name, null, 0, "owner", teamId, 1L), new byte[] {1}); + } + + private PolicyAsset agedAsset(String name, long createdAt, Long teamId) { + return assetStore.save( + new PolicyAsset(null, name, null, 0, "owner", teamId, createdAt), new byte[] {1}); + } + + private static PipelineStep step(String field, String assetId) { + return new PipelineStep( + "/api/v1/x", Map.of(), Map.of(field, PolicyAssetRefs.PREFIX + assetId)); + } + + private static Policy policy(String id, Long teamId, boolean enabled, PipelineStep... steps) { + return new Policy( + id, id, "owner", enabled, List.of(), List.of(steps), OutputSpec.inline(), teamId); + } + + private Policy savedPolicy(String id, Long teamId, PipelineStep... steps) { + return policyStore.save(policy(id, teamId, true, steps)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetControllerTest.java new file mode 100644 index 0000000000..f2e3ea147b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetControllerTest.java @@ -0,0 +1,243 @@ +package stirling.software.proprietary.policy.asset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PolicyAssetController") +class PolicyAssetControllerTest { + + @Mock private PolicyAccessGuard policyAccessGuard; + @Mock private PolicyManagementAuthority policyManagementAuthority; + + private final PolicyAssetStore assetStore = new InProcessPolicyAssetStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private ApplicationProperties applicationProperties; + private PolicyAssetController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + controller = + new PolicyAssetController( + assetStore, + policyStore, + policyAccessGuard, + policyManagementAuthority, + applicationProperties); + } + + @Nested + @DisplayName("upload") + class Upload { + + @Test + void stampsTheCallersOwnerAndTeamAndTheActualByteLength() throws Exception { + // Owner/team come from the guard, never the request: a client cannot forge either. + applicationProperties.getSecurity().setEnableLogin(false); + when(policyAccessGuard.ownerForNewPolicy()).thenReturn("lead@example.com"); + when(policyAccessGuard.teamForNewPolicy()).thenReturn(7L); + + PolicyAsset saved = controller.upload(file("logo.png", "image/png", "abcd")).getBody(); + + assertThat(saved).isNotNull(); + assertThat(saved.id()).isNotBlank(); + assertThat(saved.owner()).isEqualTo("lead@example.com"); + assertThat(saved.teamId()).isEqualTo(7L); + assertThat(saved.size()).isEqualTo(4); + } + + @Test + void stripsAnyPathFromTheSuppliedFilename() throws Exception { + applicationProperties.getSecurity().setEnableLogin(false); + + PolicyAsset saved = + controller.upload(file("../../etc/passwd", "text/plain", "x")).getBody(); + + assertThat(saved).isNotNull(); + assertThat(saved.fileName()).isEqualTo("passwd"); + } + + @Test + void rejectsAnEmptyUpload() { + applicationProperties.getSecurity().setEnableLogin(false); + + assertThatThrownBy(() -> controller.upload(file("empty.png", "image/png", ""))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void rejectsANonLeaderWhenLoginIsEnabled() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.upload(file("c.p12", null, "x"))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + } + + @Nested + @DisplayName("content") + class Content { + + @Test + void needsTheSameAuthorityAsUploading() { + // Supporting files include signing certificates: reading the bytes back is gated on + // who may manage policies, not merely on team membership. + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + PolicyAsset asset = store("cert.p12", 7L, "secret"); + + assertThatThrownBy(() -> controller.content(asset.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void readsAnotherTeamsAssetAsNotFound() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(true); + PolicyAsset asset = store("cert.p12", 9L, "secret"); + when(policyAccessGuard.canAccess(asset)).thenReturn(false); + + assertThatThrownBy(() -> controller.content(asset.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void encodesANonAsciiFilenameInTheContentDispositionHeader() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = store("cafƩ.png", null, "bytes"); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + + String disposition = + controller + .content(asset.id()) + .getHeaders() + .getFirst(HttpHeaders.CONTENT_DISPOSITION); + + // RFC 5987 form, not the raw non-ASCII byte that would mangle in the header. + assertThat(disposition).contains("filename*=UTF-8''caf%C3%A9.png"); + } + + @Test + void fallsBackToOctetStreamForAnUnparsableStoredContentType() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = + assetStore.save( + new PolicyAsset(null, "x.bin", "not/a/media/type", 0, null, null, 1L), + "bytes".getBytes()); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + + assertThat(controller.content(asset.id()).getHeaders().getContentType()) + .hasToString("application/octet-stream"); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + void refusesWhileAPipelineStepStillReferencesTheAsset() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = store("stamp.png", null, "bytes"); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + policyStore.save(policyBinding(asset.id())); + when(policyAccessGuard.visibleFrom(policyStore)) + .thenReturn(policyStore.findByTeam(null)); + + assertThatThrownBy(() -> controller.delete(asset.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT); + assertThat(assetStore.get(asset.id())).isPresent(); + } + + @Test + void removesAnAssetNoStepReferences() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = store("old.png", null, "bytes"); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + when(policyAccessGuard.visibleFrom(policyStore)).thenReturn(List.of()); + + assertThat(controller.delete(asset.id()).getStatusCode()) + .isEqualTo(HttpStatus.NO_CONTENT); + assertThat(assetStore.get(asset.id())).isEmpty(); + } + + @Test + void seesOneIdInsideAMultiFileBindingAsStillReferenced() { + // A field carrying several files stores its ids comma-joined; each one still counts. + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset first = store("a.pdf", null, "a"); + PolicyAsset second = store("b.pdf", null, "b"); + when(policyAccessGuard.canAccess(second)).thenReturn(true); + policyStore.save(policyBinding(first.id() + "," + second.id())); + when(policyAccessGuard.visibleFrom(policyStore)) + .thenReturn(policyStore.findByTeam(null)); + + assertThatThrownBy(() -> controller.delete(second.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT); + } + } + + private PolicyAsset store(String fileName, Long teamId, String content) { + return assetStore.save( + new PolicyAsset(null, fileName, null, 0, null, teamId, 1L), content.getBytes()); + } + + private static Policy policyBinding(String assetIds) { + return new Policy( + "p1", + "p", + "owner", + true, + List.of(PipelineInput.manual("s1")), + List.of( + new PipelineStep( + "/api/v1/security/cert-sign", + Map.of(), + Map.of("certFile", PolicyAssetRefs.PREFIX + assetIds))), + OutputSpec.inline()); + } + + private static MockMultipartFile file(String name, String contentType, String content) { + return new MockMultipartFile("file", name, contentType, content.getBytes()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetResolverTest.java new file mode 100644 index 0000000000..b71536ffa6 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetResolverTest.java @@ -0,0 +1,243 @@ +package stirling.software.proprietary.policy.asset; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +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; + +/** + * Tests for {@link PolicyAssetResolver}: stored assets referenced by a policy's steps load into the + * run's supporting files, keyed by the step's binding value, team-checked, and winning over + * anything the run supplied under the same key. + */ +class PolicyAssetResolverTest { + + private final InProcessPolicyAssetStore store = new InProcessPolicyAssetStore(); + private final PolicyAssetResolver resolver = new PolicyAssetResolver(store); + + @Test + void loadsReferencedAssetsUnderTheStepBindingKey() throws IOException { + PolicyAsset image = save("logo.png", 7L, new byte[] {1, 2}); + String key = ref(image.id()); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + List bound = resolved.supportingFiles().get(key); + assertEquals(1, bound.size()); + assertEquals("logo.png", bound.get(0).getFilename()); + assertArrayEquals(new byte[] {1, 2}, bound.get(0).getContentAsByteArray()); + } + + @Test + void aCommaSeparatedBindingLoadsEveryAssetUnderTheFullKey() { + PolicyAsset first = save("a.pdf", null, new byte[] {1}); + PolicyAsset second = save("b.pdf", null, new byte[] {2}); + String key = ref(first.id() + "," + second.id()); + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/general/overlay-pdfs", + Map.of(), + Map.of("overlayFiles", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + assertEquals(2, resolved.supportingFiles().get(key).size()); + } + + @Test + void storedAssetsWinOverRunSuppliedOnes() throws IOException { + // Runs are open to the whole team, so a member must not be able to swap a leader-pinned + // certificate by posting their own file under the binding's key. + PolicyAsset stored = save("stored.png", null, new byte[] {9}); + String key = ref(stored.id()); + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", key))); + Resource supplied = new ByteArrayResource(new byte[] {5}); + PolicyInputs inputs = new PolicyInputs(List.of(), Map.of(key, List.of(supplied))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertArrayEquals( + new byte[] {9}, resolved.supportingFiles().get(key).get(0).getContentAsByteArray()); + } + + @Test + void aLaterStepsStoredBindingStillWinsOverARunSuppliedFile() { + // The first resolved key seeds the merged map from the run's own files; the second must + // still overwrite its run-supplied entry rather than read as already resolved. + PolicyAsset first = save("first.png", null, new byte[] {1}); + PolicyAsset second = save("second.p12", null, new byte[] {2}); + String firstKey = ref(first.id()); + String secondKey = ref(second.id()); + Policy policy = + new Policy( + "p1", + "p", + "owner", + true, + List.of(), + List.of( + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", firstKey)), + new PipelineStep( + "/api/v1/security/cert-sign", + Map.of(), + Map.of("p12File", secondKey))), + OutputSpec.inline(), + null); + Resource supplied = new ByteArrayResource(new byte[] {5}); + PolicyInputs inputs = new PolicyInputs(List.of(), Map.of(secondKey, List.of(supplied))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertEquals("second.p12", resolved.supportingFiles().get(secondKey).get(0).getFilename()); + } + + @Test + void aRunSuppliedKeyIsLeftAlone() { + // Bindings without the asset: prefix name a file uploaded with the run, as they did before + // supporting files could be stored. + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", "company-logo"))); + Resource supplied = new ByteArrayResource(new byte[] {5}); + PolicyInputs inputs = + new PolicyInputs(List.of(), Map.of("company-logo", List.of(supplied))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertSame(inputs, resolved); + assertSame(supplied, resolved.supportingFiles().get("company-logo").get(0)); + } + + @Test + void anotherTeamsAssetDoesNotResolve() { + PolicyAsset foreign = save("secret.p12", 99L, new byte[] {1}); + String key = ref(foreign.id()); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/security/cert-sign", Map.of(), Map.of("p12File", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void aBindingWithOneUnresolvableIdResolvesToNothing() { + // Half a binding would run the step short a file; drop it so the executor fails the run. + PolicyAsset present = save("a.pdf", null, new byte[] {1}); + String key = ref(present.id() + ",missing-id"); + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/general/overlay-pdfs", + Map.of(), + Map.of("overlayFiles", key))); + PolicyInputs inputs = PolicyInputs.of(List.of()); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertSame(inputs, resolved); + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void aDeadStoredBindingDoesNotFallBackToTheRunSuppliedFile() { + // Otherwise losing the pinned asset would hand the binding straight back to the member + // running the policy - the very override stored assets are meant to prevent. + String key = ref("missing-id"); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/security/cert-sign", Map.of(), Map.of("p12File", key))); + PolicyInputs inputs = + new PolicyInputs( + List.of(), Map.of(key, List.of(new ByteArrayResource(new byte[] {5})))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + // Key absent, so the executor raises its missing-supporting-file error and the run fails. + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void aBindingWhoseSecondIdIsAnotherTeamsResolvesToNothing() { + PolicyAsset own = save("own.pdf", 7L, new byte[] {1}); + PolicyAsset foreign = save("foreign.pdf", 99L, new byte[] {2}); + String key = ref(own.id() + "," + foreign.id()); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/general/overlay-pdfs", + Map.of(), + Map.of("overlayFiles", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void stepsWithoutBindingsLeaveInputsUntouched() { + Policy policy = policy(null, new PipelineStep("/api/v1/misc/compress-pdf", Map.of())); + PolicyInputs inputs = PolicyInputs.of(List.of()); + + assertSame(inputs, resolver.resolve(policy, inputs)); + assertTrue(inputs.supportingFiles().isEmpty()); + } + + private static String ref(String ids) { + return PolicyAssetRefs.PREFIX + ids; + } + + private PolicyAsset save(String name, Long teamId, byte[] content) { + return store.save( + new PolicyAsset(null, name, "application/octet-stream", 0, "owner", teamId, 1L), + content); + } + + private static Policy policy(Long teamId, PipelineStep step) { + return new Policy( + "p1", "p", "owner", true, List.of(), List.of(step), OutputSpec.inline(), teamId); + } +} 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 6cafde14c8..36f5cc221b 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; @@ -80,6 +81,10 @@ class PolicyControllerTest { private stirling.software.proprietary.policy.overview.PolicyOverviewService policyOverviewService; + @Mock private stirling.software.proprietary.policy.asset.PolicyAssetCleaner assetCleaner; + + @Mock private stirling.software.proprietary.policy.asset.PolicyAssetResolver assetResolver; + @Mock private ProcessedLedger processedLedger; @Mock private TempFileManager tempFileManager; @@ -111,6 +116,8 @@ class PolicyControllerTest { policyManagementAuthority, policyTriggerManager, policyOverviewService, + assetCleaner, + assetResolver, processedLedger, policyTriggers, applicationProperties, @@ -213,7 +220,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)); } @@ -228,7 +235,7 @@ class PolicyControllerTest { .thenReturn(handle("run-1")); ResponseEntity> response = - controller.run(definitionWithStep(), new PolicyRunFiles()); + controller.run(definitionWithStep(), null, new PolicyRunFiles()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getBody().getJobId()).isEqualTo("run-1"); @@ -241,7 +248,7 @@ class PolicyControllerTest { .thenReturn(handle("run-1")); when(sourceAccessGuard.currentTeamId()).thenReturn(3L); - controller.run(definitionWithStep(), new PolicyRunFiles()); + controller.run(definitionWithStep(), null, new PolicyRunFiles()); verify(docCounter).record(EditorSource.counterKey(3L), 0L); } @@ -251,7 +258,7 @@ class PolicyControllerTest { void rejectsEmptyPipeline() { PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); - assertThatThrownBy(() -> controller.run(empty, new PolicyRunFiles())) + assertThatThrownBy(() -> controller.run(empty, null, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class) .satisfies( e -> @@ -273,7 +280,7 @@ class PolicyControllerTest { .when(policyValidator) .validateOutput(any()); - assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles())) + assertThatThrownBy(() -> controller.run(definition, null, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class) .satisfies( e -> @@ -281,6 +288,38 @@ class PolicyControllerTest { .isEqualTo(HttpStatus.BAD_REQUEST)); verify(policyRunner, never()).runAdHoc(any(), any(), any()); } + + @Test + @DisplayName("resolves stored assets from the supplied policy when the caller may edit it") + void resolvesStoredAssetsForEditor() throws Exception { + applicationProperties.getSecurity().setEnableLogin(false); // editing allowed + Policy p = policy("pol-1", 1L); + when(policyStore.get("pol-1")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(assetResolver.resolve(eq(p), any())).thenAnswer(inv -> inv.getArgument(1)); + when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-1")); + + controller.run(definitionWithStep(), "pol-1", new PolicyRunFiles()); + + verify(assetResolver).resolve(eq(p), any()); + } + + @Test + @DisplayName("does not resolve a policy's stored assets for a caller who cannot edit it") + void skipsStoredAssetsForNonEditor() throws Exception { + // Gating asset resolution to editors keeps a member from rebinding a policy's stored + // asset into an ad-hoc step to read it back. + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-1")); + + controller.run(definitionWithStep(), "pol-1", new PolicyRunFiles()); + + verify(assetResolver, never()).resolve(any(), any()); + verify(policyStore, never()).get(any()); + } } @Nested @@ -292,7 +331,8 @@ class PolicyControllerTest { void returnsEmitter() throws Exception { when(policyRunner.runAdHoc(any(), any(), any())).thenReturn(handle("run-2")); - SseEmitter emitter = controller.runStream(definitionWithStep(), new PolicyRunFiles()); + SseEmitter emitter = + controller.runStream(definitionWithStep(), null, new PolicyRunFiles()); assertThat(emitter).isNotNull(); } @@ -302,7 +342,7 @@ class PolicyControllerTest { void rejectsEmpty() { PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); - assertThatThrownBy(() -> controller.runStream(empty, new PolicyRunFiles())) + assertThatThrownBy(() -> controller.runStream(empty, null, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class); } } @@ -314,7 +354,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"); @@ -343,9 +383,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 @@ -473,6 +515,22 @@ class PolicyControllerTest { assertThat(response.getBody().owner()).isEqualTo("origOwner"); assertThat(response.getBody().teamId()).isEqualTo(3L); } + + @Test + @DisplayName("hands the pre-save version to the asset cleaner") + void cleansUpAssetsTheEditDropped() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy existing = + new Policy("p2", "name", "owner", true, List.of(), List.of(), null, 3L); + when(policyStore.get("p2")).thenReturn(Optional.of(existing)); + when(policyAccessGuard.canAccess(existing)).thenReturn(true); + when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0)); + + controller.savePolicy( + new Policy("p2", "name", "owner", true, List.of(), List.of(), null, 3L)); + + verify(assetCleaner).cleanupAfterSave(eq(existing), any()); + } } @Nested @@ -557,6 +615,7 @@ class PolicyControllerTest { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); verify(processedLedger).clearPolicy("a"); + verify(assetCleaner).cleanupAfterDelete(p); verify(policyTriggerManager).notifyPoliciesChanged(); } @@ -716,5 +775,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 479dccd3ca..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; @@ -55,6 +56,8 @@ import stirling.software.common.service.ToolMetadataService; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileRegistry; import stirling.software.proprietary.failure.PolicyFailureRecorder; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.PipelineStep; @@ -124,7 +127,8 @@ class PolicyEngineTest { List.of(sink, recordingSink), outputResolver, resourceMonitor, - jobQueue); + jobQueue, + new PolicyAssetResolver(new InProcessPolicyAssetStore())); // Identity scoping: the run id is the generated UUID unchanged. Lenient because the // resume/cancel tests do not submit a run. @@ -222,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 @@ -235,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( @@ -503,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/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 074adc2b36..2928dd3b65 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -23,6 +23,10 @@ import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIOSource; import stirling.software.common.model.tool.ToolIOSpec; import stirling.software.common.service.ToolChainValidator; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAsset; +import stirling.software.proprietary.policy.asset.PolicyAssetRefs; +import stirling.software.proprietary.policy.asset.PolicyAssetStore; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -46,6 +50,7 @@ class PolicyValidatorTest { @Mock private PipelineStepValidator stepValidator; private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyAssetStore assetStore = new InProcessPolicyAssetStore(); private PolicyValidator validator; @BeforeEach @@ -57,6 +62,7 @@ class PolicyValidatorTest { List.of(outputSink), List.of(stepValidator), sourceStore, + assetStore, new ToolChainValidator(path -> java.util.Optional.empty())); } @@ -120,6 +126,90 @@ class PolicyValidatorTest { () -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1)))); } + @Test + void acceptsAStepBindingThatReferencesATeamAsset() { + when(inputSource.supports(any())).thenReturn(true); + when(outputSink.supports(any())).thenReturn(true); + PolicyAsset asset = + assetStore.save( + new PolicyAsset(null, "logo.png", null, 0, "owner", null, 1L), + new byte[] {1}); + + validator.validate(withFileBinding(PolicyAssetRefs.PREFIX + asset.id(), null)); + } + + @Test + void acceptsARunSuppliedFileKey() { + // No asset: prefix, so the binding names a file uploaded with the run - it existed before + // stored assets did, and pausing such a policy must not start failing. + when(inputSource.supports(any())).thenReturn(true); + when(outputSink.supports(any())).thenReturn(true); + + validator.validate(withFileBinding("company-logo", null)); + } + + @Test + void rejectsAStepBindingToAnUnknownAsset() { + when(inputSource.supports(any())).thenReturn(true); + + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> + validator.validate( + withFileBinding( + PolicyAssetRefs.PREFIX + "missing-asset", null))); + assertTrue(ex.getMessage().contains("unknown stored file")); + } + + @Test + void rejectsAStepBindingToAnotherTeamsAsset() { + when(inputSource.supports(any())).thenReturn(true); + PolicyAsset foreign = + assetStore.save( + new PolicyAsset(null, "secret.p12", null, 0, "owner", 99L, 1L), + new byte[] {1}); + + // Policy has no team; the asset belongs to team 99 - must read as unknown, not leak. + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> + validator.validate( + withFileBinding( + PolicyAssetRefs.PREFIX + foreign.id(), null))); + assertTrue(ex.getMessage().contains("unknown stored file")); + } + + @Test + void rejectsAnAssetBindingWithNoIds() { + when(inputSource.supports(any())).thenReturn(true); + + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> validator.validate(withFileBinding(PolicyAssetRefs.PREFIX, null))); + assertTrue(ex.getMessage().contains("empty file binding")); + } + + /** A manual-only policy whose single step binds a file field to the given asset key. */ + private Policy withFileBinding(String assetKey, Long teamId) { + PipelineStep step = + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", assetKey)); + return new Policy( + "p1", + "p", + "owner", + true, + List.of(PipelineInput.manual(folderSourceId())), + List.of(step), + OutputSpec.inline(), + teamId); + } + @Test void rejectsAnUnknownTriggerType() { when(trigger.type()).thenReturn("schedule"); @@ -184,6 +274,7 @@ class PolicyValidatorTest { List.of(outputSink), List.of(stepValidator), sourceStore, + assetStore, new ToolChainValidator(toolIO)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkObjectLockMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkObjectLockMinioTest.java new file mode 100644 index 0000000000..89d2fe845d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkObjectLockMinioTest.java @@ -0,0 +1,183 @@ +package stirling.software.proprietary.policy.output; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.ResultFile; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.ObjectLockMode; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Proves the Object Lock (WORM) retention path against a real S3 API. + * + *

The claim being tested is a compliance one - SEC 17a-4(f) and FINRA require records on + * non-rewritable, non-erasable storage - so asserting that we merely send the retention + * headers would be worthless. What matters is that the store then genuinely refuses to delete the + * object, which is what {@link #anObjectWrittenUnderComplianceRetentionCannotBeDeleted} checks. + */ +@Testcontainers(disabledWithoutDocker = true) +class S3OutputSinkObjectLockMinioTest { + + private static final String POLICY = "p1"; + private static final String ACCESS_KEY = "minioadmin"; + private static final String SECRET_KEY = "minioadmin"; + + @Container + static MinIOContainer minio = + new MinIOContainer("minio/minio:latest") + .withUserName(ACCESS_KEY) + .withPassword(SECRET_KEY); + + private static S3Client adminClient; + private static int bucketCounter; + + private String bucket; + private S3OutputSink sink; + + @BeforeEach + void setUp() { + if (adminClient == null) { + adminClient = + S3Client.builder() + .endpointOverride(URI.create(minio.getS3URL())) + .httpClient(UrlConnectionHttpClient.create()) + .region(Region.US_EAST_1) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .serviceConfiguration( + S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .build(); + } + bucket = "worm-archive-" + ++bucketCounter; + // Object Lock can only be enabled at bucket creation here, and implies versioning. + adminClient.createBucket( + CreateBucketRequest.builder() + .bucket(bucket) + .objectLockEnabledForBucket(true) + .build()); + + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowPrivateS3Endpoints(true); + sink = + new S3OutputSink( + new S3ConnectionPool(properties), + S3TestConnections.legacyResolver(), + new InProcessProcessedLedger()); + } + + @Test + void anObjectWrittenUnderComplianceRetentionCannotBeDeleted() throws IOException { + List results = + sink.deliver( + new OutputDelivery("run-1", POLICY), + List.of(output("statement.pdf", "regulated record")), + lockedOutputSpec("COMPLIANCE", 7)); + + assertThat(results).hasSize(1); + + HeadObjectResponse head = + adminClient.headObject( + HeadObjectRequest.builder().bucket(bucket).key("statement.pdf").build()); + assertThat(head.objectLockMode()).isEqualTo(ObjectLockMode.COMPLIANCE); + // Retention is computed per object from "now", so a daily policy gives each document its + // own full window rather than a shared deadline. + assertThat(head.objectLockRetainUntilDate()) + .isBetween( + Instant.now().plus(6, ChronoUnit.DAYS), + Instant.now().plus(8, ChronoUnit.DAYS)); + + // The point of the feature: the store itself refuses, not us. + assertThatThrownBy( + () -> + adminClient.deleteObject( + DeleteObjectRequest.builder() + .bucket(bucket) + .key("statement.pdf") + .versionId(head.versionId()) + .build())) + .isInstanceOf(S3Exception.class); + + // And it is still readable - locked, not quarantined. + assertThat( + adminClient + .getObject( + GetObjectRequest.builder() + .bucket(bucket) + .key("statement.pdf") + .build()) + .response() + .contentLength()) + .isEqualTo("regulated record".length()); + } + + @Test + void withoutRetentionConfiguredObjectsAreWrittenUnlockedAsBefore() throws IOException { + sink.deliver( + new OutputDelivery("run-2", POLICY), + List.of(output("scratch.pdf", "ordinary output")), + lockedOutputSpec(null, null)); + + HeadObjectResponse head = + adminClient.headObject( + HeadObjectRequest.builder().bucket(bucket).key("scratch.pdf").build()); + // No accidental retention: an Object-Lock-enabled bucket must not silently lock everything. + assertThat(head.objectLockMode()).isNull(); + assertThat(head.objectLockRetainUntilDate()).isNull(); + } + + private OutputSpec lockedOutputSpec(String lockMode, Integer retentionDays) { + Map options = new java.util.LinkedHashMap<>(); + options.put("bucket", bucket); + options.put("prefix", ""); + options.put("endpoint", minio.getS3URL()); + options.put("accessKeyId", ACCESS_KEY); + options.put("secretAccessKey", SECRET_KEY); + if (lockMode != null) { + options.put("objectLockMode", lockMode); + options.put("retentionDays", String.valueOf(retentionDays)); + } + return new OutputSpec("s3", options); + } + + private static org.springframework.core.io.Resource output(String name, String content) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) { + @Override + public String getFilename() { + return name; + } + }; + } +} 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/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreDbTest.java new file mode 100644 index 0000000000..e690c72827 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreDbTest.java @@ -0,0 +1,65 @@ +package stirling.software.proprietary.policy.store; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +import tools.jackson.databind.json.JsonMapper; + +/** + * {@link JpaPolicyStore#anyPolicyReferences} on a real (H2) database. It guards a scheduled DELETE + * over stored certificates, so it has to be proven against the raw column rather than the parsed + * policies {@code all()} would hand back. + */ +@DataJpaTest +class JpaPolicyStoreDbTest { + + @Autowired private PolicyRepository repository; + + private JpaPolicyStore store() { + return new JpaPolicyStore(repository, JsonMapper.builder().build()); + } + + @Test + void seesAnAssetIdMentionedByAStoredPolicy() { + save("p1", "{\"steps\":[{\"fileParameters\":{\"p12File\":\"asset:abc-123\"}}]}"); + + assertTrue(store().anyPolicyReferences("abc-123")); + assertFalse(store().anyPolicyReferences("def-456")); + } + + @Test + void seesAnAssetIdInsideAPolicyRowItCannotParse() { + // The whole point of matching raw JSON: a row all() skips as unreadable still holds its + // certificate, and reclaiming that would be unrecoverable. + save("broken", "{ this is not valid json, asset:abc-123"); + + assertTrue(store().all().isEmpty()); + assertTrue(store().anyPolicyReferences("abc-123")); + } + + @Test + void ignoresBlankIds() { + save("p1", "{\"steps\":[]}"); + + assertFalse(store().anyPolicyReferences("")); + assertFalse(store().anyPolicyReferences(null)); + } + + private void save(String id, String policyJson) { + PolicyEntity entity = new PolicyEntity(); + entity.setId(id); + entity.setName(id); + entity.setPolicyJson(policyJson); + repository.save(entity); + } + + @SpringBootConfiguration + @AutoConfigurationPackage + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java index a6fd31e9b2..4098d6ab19 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java @@ -24,8 +24,10 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; import stirling.software.proprietary.storage.crypto.EncryptingStorageProvider; import stirling.software.proprietary.storage.crypto.InMemoryKeyRepo; +import stirling.software.proprietary.storage.crypto.StorageEncryptionAuditListener; import stirling.software.proprietary.storage.crypto.StorageEncryptionState; import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository; @@ -44,12 +46,16 @@ class StorageProviderConfigTest { private final InMemoryKeyRepo keyRepo = new InMemoryKeyRepo(); private final PlatformTransactionManager txManager = mock(PlatformTransactionManager.class); + private StorageEncryptionState newState(StorageProviderConfig cfg) { + return cfg.storageEncryptionState(MASTER, "", 1, false, txManager); + } + // ---- decorator installation matrix ------------------------------------------------- @Test void decorator_alwaysInstalled_evenWhenEncryptionOffAndNoKeys() { StorageProviderConfig cfg = newConfig("local", License.NORMAL, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); StorageProvider provider = cfg.storageProvider(state, Optional.empty()); assertThat(provider).isInstanceOf(EncryptingStorageProvider.class); @@ -61,7 +67,7 @@ class StorageProviderConfigTest { @Test void decorator_writeEnabled_requiresLicenceAndSuppressesDirectDownloads() { StorageProviderConfig cfg = newConfig("local", License.SERVER, true); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); StorageProvider provider = cfg.storageProvider(state, Optional.empty()); assertThat(provider).isInstanceOf(EncryptingStorageProvider.class); @@ -73,7 +79,7 @@ class StorageProviderConfigTest { void decorator_flagOffButKeysExist_decryptOnlyModeStillMaterialises() throws Exception { // Drifted node: keys created elsewhere; storage on, as it must be to serve files. StorageProviderConfig seedCfg = newConfig("local", License.SERVER, true, true); - StorageEncryptionState seedState = seedCfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState seedState = newState(seedCfg); Team team = new Team(); team.setId(1L); User owner = new User(); @@ -81,7 +87,7 @@ class StorageProviderConfigTest { seedState.keyService().activeKekForOwner(owner); StorageProviderConfig cfg = newConfig("local", License.NORMAL, false, true); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); assertThat(cfg.storageProvider(state, Optional.empty())) .isInstanceOf(EncryptingStorageProvider.class); @@ -89,13 +95,13 @@ class StorageProviderConfigTest { // Encrypted content may exist -> presigned URLs must be suppressed on this node too. assertThat(state.suppressDirectDownloads()).isTrue(); // Eager init ran (keys existed at boot), so decryption works without a licence. - assertThat(state.keyService()).isNotNull(); + assertThat(state.isMaterialised()).isTrue(); } @Test void encryption_enabled_normalLicense_failsStartup() { StorageProviderConfig cfg = newConfig("local", License.NORMAL, true); - assertThatThrownBy(() -> cfg.storageEncryptionState(MASTER, false, txManager)) + assertThatThrownBy(() -> newState(cfg)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("storage.encryption requires a Pro or Enterprise license"); } @@ -104,7 +110,7 @@ class StorageProviderConfigTest { void encryption_enabled_wrongLengthKey_failsStartup() { StorageProviderConfig cfg = newConfig("local", License.SERVER, true); String shortKey = Base64.getEncoder().encodeToString("only16bytes-yes!".getBytes()); - assertThatThrownBy(() -> cfg.storageEncryptionState(shortKey, false, txManager)) + assertThatThrownBy(() -> cfg.storageEncryptionState(shortKey, "", 1, false, txManager)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("32 bytes"); } @@ -114,16 +120,18 @@ class StorageProviderConfigTest { @Test void storageDisabled_neverQueriesTheKeyRegistry() { StorageProviderConfig cfg = newConfig("local", License.NORMAL, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); assertThat(state.isWriteEnabled()).isFalse(); + // No probe means no eager init, so no master key is resolved on a node that never stores. + assertThat(state.isMaterialised()).isFalse(); verify(keyRepo.mock, never()).count(); } @Test void storageDisabled_decoratorStillInstalledSoCiphertextIsNeverServedRaw() { StorageProviderConfig cfg = newConfig("local", License.NORMAL, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); assertThat(cfg.storageProvider(state, Optional.empty())) .isInstanceOf(EncryptingStorageProvider.class); @@ -134,7 +142,9 @@ class StorageProviderConfigTest { FileEncryptionKeyRepository broken = mock(FileEncryptionKeyRepository.class); when(broken.count()) .thenThrow(new InvalidDataAccessResourceUsageException("no such table")); - StorageEncryptionState state = new StorageEncryptionState(false, () -> null, broken); + StorageEncryptionState state = + new StorageEncryptionState( + false, () -> null, broken, StorageEncryptionAuditListener.NOOP); assertThat(state.encryptedContentMayExist()).isFalse(); assertThat(state.suppressDirectDownloads()).isTrue(); @@ -143,7 +153,7 @@ class StorageProviderConfigTest { @Test void storageEnabledWithoutEncryption_probesRegistry() { StorageProviderConfig cfg = newConfig("local", License.NORMAL, false, true); - cfg.storageEncryptionState(MASTER, false, txManager); + newState(cfg); verify(keyRepo.mock, atLeastOnce()).count(); } @@ -152,7 +162,7 @@ class StorageProviderConfigTest { @Test void provider_s3_normalLicense_throwsBeforeBuildingClient() { StorageProviderConfig cfg = newConfig("s3", License.NORMAL, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); // License check must throw BEFORE S3Clients.build tries to validate endpoint / bucket. assertThatThrownBy(() -> cfg.storageProvider(state, Optional.empty())) @@ -163,7 +173,7 @@ class StorageProviderConfigTest { @Test void provider_database_normalLicense_throws() { StorageProviderConfig cfg = newConfig("database", License.NORMAL, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); assertThatThrownBy(() -> cfg.storageProvider(state, Optional.empty())) .isInstanceOf(IllegalStateException.class) @@ -174,7 +184,7 @@ class StorageProviderConfigTest { @Test void provider_database_serverLicense_builds() { StorageProviderConfig cfg = newConfig("database", License.SERVER, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); assertThatCode(() -> cfg.storageProvider(state, Optional.empty())) .doesNotThrowAnyException(); } @@ -182,7 +192,7 @@ class StorageProviderConfigTest { @Test void provider_s3_serverLicense_passesLicenseCheck_thenFailsOnEmptyConfig() { StorageProviderConfig cfg = newConfig("s3", License.SERVER, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); // Valid license, but no bucket/endpoint configured - so we expect a CONFIG error, // not a license error. @@ -194,7 +204,7 @@ class StorageProviderConfigTest { @Test void provider_unknown_throwsUnsupportedProvider_notLicense() { StorageProviderConfig cfg = newConfig("magic", License.NORMAL, false); - StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager); + StorageEncryptionState state = newState(cfg); assertThatThrownBy(() -> cfg.storageProvider(state, Optional.empty())) .isInstanceOf(IllegalStateException.class) @@ -215,6 +225,7 @@ class StorageProviderConfigTest { props.getStorage().getEncryption().setEnabled(encryptionEnabled); StoredFileBlobRepository repo = mock(StoredFileBlobRepository.class); LicenseKeyChecker checker = mock(LicenseKeyChecker.class); + AuditService audit = mock(AuditService.class); when(checker.getPremiumLicenseEnabledResult()).thenReturn(license); if (license == License.SERVER || license == License.ENTERPRISE) { doNothing().when(checker).requireProOrEnterprise(anyString()); @@ -229,6 +240,6 @@ class StorageProviderConfigTest { .when(checker) .requireProOrEnterprise(anyString()); } - return new StorageProviderConfig(props, repo, keyRepo.mock, checker); + return new StorageProviderConfig(props, repo, keyRepo.mock, checker, audit); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerMoreTest.java index 1575dfbff5..f423ecba22 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerMoreTest.java @@ -31,6 +31,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; import stirling.software.proprietary.storage.model.FileShare; import stirling.software.proprietary.storage.model.ShareAccessRole; import stirling.software.proprietary.storage.model.StoredFile; @@ -48,12 +49,13 @@ class FileStorageControllerMoreTest { @Mock private FileStorageService fileStorageService; @Mock private StorageProvider storageProvider; + @Mock private AuditService auditService; private FileStorageController controller; @BeforeEach void setUp() { - controller = new FileStorageController(fileStorageService, storageProvider); + controller = new FileStorageController(fileStorageService, storageProvider, auditService); } private User user() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerTest.java index 79033abc2d..df6cad881d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerTest.java @@ -1,11 +1,13 @@ package stirling.software.proprietary.storage.controller; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -14,20 +16,26 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import java.net.URI; import java.time.Duration; +import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; import stirling.software.proprietary.storage.model.StoredFile; import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.service.FileStorageService; @@ -40,13 +48,14 @@ class FileStorageControllerTest { @Mock private FileStorageService fileStorageService; @Mock private StorageProvider storageProvider; + @Mock private AuditService auditService; private MockMvc mockMvc; @BeforeEach void setUp() { FileStorageController controller = - new FileStorageController(fileStorageService, storageProvider); + new FileStorageController(fileStorageService, storageProvider, auditService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } @@ -113,6 +122,75 @@ class FileStorageControllerTest { eq("11/abc-doc.pdf"), any(Duration.class), eq(true), eq("doc.pdf")); } + @Test + void downloadFile_encryptedContent_auditsPlaintextExportAsAttachment() throws Exception { + StoredFile file = newStoredFile(); + file.setEncryptionKeyId("cafe1234-0000-0000-0000-000000000001"); + streamedDownload(file); + + mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L)) + .andExpect(status().isOk()); + + // The marker is the compliance evidence that a decrypted copy left the platform, so it + // must carry the key it came from and whether it was viewed in-app or saved. + assertThat(exportEvents()) + .singleElement() + .satisfies( + event -> { + assertThat(event).containsEntry("action", "plaintextExport"); + assertThat(event).containsEntry("fileId", 77L); + assertThat(event).containsEntry("inline", false); + assertThat(event) + .containsEntry("keyId", "cafe1234-0000-0000-0000-000000000001"); + }); + } + + @Test + void downloadFile_inlineView_marksTheExportInline() throws Exception { + StoredFile file = newStoredFile(); + file.setEncryptionKeyId("cafe1234-0000-0000-0000-000000000001"); + streamedDownload(file); + + mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L).param("inline", "true")) + .andExpect(status().isOk()); + + // An in-app view and a saved copy are both exports, but a reviewer needs to tell them + // apart. + assertThat(exportEvents()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("inline", true)); + } + + @Test + void downloadFile_plaintextContent_recordsNoExportEvent() throws Exception { + StoredFile file = newStoredFile(); // encryptionKeyId stays null + streamedDownload(file); + + mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L)) + .andExpect(status().isOk()); + + // Nothing was encrypted at rest, so there is no decryption to attest to. + verifyNoInteractions(auditService); + } + + /** Stubs an app-streamed (non-presigned) download of {@code file}. */ + private void streamedDownload(StoredFile file) throws Exception { + when(fileStorageService.requireAuthenticatedUser()).thenReturn(file.getOwner()); + when(fileStorageService.getAccessibleFile(file.getOwner(), 77L)).thenReturn(file); + when(storageProvider.signedDownloadUrl( + anyString(), any(Duration.class), anyBoolean(), anyString())) + .thenReturn(Optional.empty()); + when(fileStorageService.loadFile(file)) + .thenReturn(new ByteArrayResource("decrypted bytes".getBytes(UTF_8))); + } + + private List> exportEvents() { + @SuppressWarnings("unchecked") + ArgumentCaptor> data = ArgumentCaptor.forClass(Map.class); + verify(auditService).audit(eq(AuditEventType.STORAGE_ENCRYPTION), data.capture()); + return data.getAllValues(); + } + private static StoredFile newStoredFile() { User user = new User(); user.setId(11L); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminControllerHttpTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminControllerHttpTest.java new file mode 100644 index 0000000000..27b5a893c7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminControllerHttpTest.java @@ -0,0 +1,209 @@ +package stirling.software.proprietary.storage.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.storage.crypto.FileEncryptionKeyService; +import stirling.software.proprietary.storage.crypto.FileEncryptionMasterKey; +import stirling.software.proprietary.storage.crypto.InMemoryKeyRepo; +import stirling.software.proprietary.storage.crypto.StorageEncryptionAuditListener; +import stirling.software.proprietary.storage.crypto.StorageEncryptionState; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.storage.service.StorageEncryptionMigrationService; + +/** + * Drives the admin API over HTTP rather than as Java calls: path mappings, status codes and — the + * part unit tests can't see — that the response records actually serialise. A UUID/Instant/enum + * that Jackson cannot render would pass every direct-invocation test and fail the first request. + * + *

Note {@code standaloneSetup} deliberately does not install the security filter chain, so this + * cannot prove {@code @PreAuthorize} enforcement; {@link #controller_isAdminOnly()} pins the + * annotation instead (method security is enabled globally in {@code SecurityConfiguration}). + */ +class StorageEncryptionAdminControllerHttpTest { + + private static final String MASTER = + Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes()); + + private InMemoryKeyRepo keyRepo; + private FileEncryptionKeyService keyService; + private StorageEncryptionMigrationService migrationService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + keyRepo = new InMemoryKeyRepo(); + keyService = + new FileEncryptionKeyService( + keyRepo.mock, new FileEncryptionMasterKey(MASTER, false)); + StoredFileRepository storedFileRepository = mock(StoredFileRepository.class); + when(storedFileRepository.countByEncryptionKeyIdIsNotNull()).thenReturn(7L); + when(storedFileRepository.countByEncryptionKeyIdIsNull()).thenReturn(3L); + migrationService = mock(StorageEncryptionMigrationService.class); + + ApplicationProperties props = new ApplicationProperties(); + props.getStorage().setEnabled(true); + StorageEncryptionAdminController controller = + new StorageEncryptionAdminController( + props, + StorageEncryptionState.of( + true, keyService, StorageEncryptionAuditListener.NOOP), + keyRepo.mock, + storedFileRepository, + migrationService, + mock(AuditService.class)); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + private UUID seedKey() throws Exception { + Team team = new Team(); + team.setId(1L); + User owner = new User(); + owner.setTeam(team); + return keyService.activeKekForOwner(owner).keyId(); + } + + @Test + void status_serialisesCountsFingerprintAndKeyRows() throws Exception { + UUID keyId = seedKey(); + + mockMvc.perform(get("/api/v1/admin/storage-encryption/status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.writeEnabled").value(true)) + .andExpect(jsonPath("$.active").value(true)) + .andExpect(jsonPath("$.masterKeyFingerprint").isString()) + .andExpect(jsonPath("$.masterKeyVersion").value(1)) + .andExpect(jsonPath("$.encryptedFiles").value(7)) + .andExpect(jsonPath("$.plaintextFiles").value(3)) + .andExpect(jsonPath("$.keys[0].keyId").value(keyId.toString())) + .andExpect(jsonPath("$.keys[0].scopeType").value("TEAM")) + .andExpect(jsonPath("$.keys[0].scopeId").value(1)) + .andExpect(jsonPath("$.keys[0].status").value("ACTIVE")); + } + + @Test + void disableThenEnable_serialiseKeyInfoAndReportStatusTransitions() throws Exception { + UUID keyId = seedKey(); + + mockMvc.perform(post("/api/v1/admin/storage-encryption/keys/" + keyId + "/disable")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.keyId").value(keyId.toString())) + .andExpect(jsonPath("$.status").value("DISABLED")) + .andExpect(jsonPath("$.statusChangedAt").isNotEmpty()); + + mockMvc.perform(post("/api/v1/admin/storage-encryption/keys/" + keyId + "/enable")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + @Test + void enable_onActiveKey_returns409() throws Exception { + UUID keyId = seedKey(); + + mockMvc.perform(post("/api/v1/admin/storage-encryption/keys/" + keyId + "/enable")) + .andExpect(status().isConflict()); + } + + @Test + void disable_unknownKey_returns404() throws Exception { + mockMvc.perform( + post( + "/api/v1/admin/storage-encryption/keys/" + + UUID.randomUUID() + + "/disable")) + .andExpect(status().isNotFound()); + } + + @Test + void migrateStatus_neverStarted_serialisesIdle() throws Exception { + when(migrationService.status()).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/v1/admin/storage-encryption/migrate/status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("IDLE")); + } + + @Test + void migrate_serialisesRunningSnapshotIncludingInstants() throws Exception { + when(migrationService.start()) + .thenReturn( + new StorageEncryptionMigrationService.MigrationStatus( + StorageEncryptionMigrationService.State.RUNNING, + 10, + 2, + 1, + 0, + Instant.parse("2026-01-01T00:00:00Z"), + null)); + + mockMvc.perform(post("/api/v1/admin/storage-encryption/migrate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("RUNNING")) + .andExpect(jsonPath("$.total").value(10)) + .andExpect(jsonPath("$.processed").value(2)) + .andExpect(jsonPath("$.skipped").value(1)) + .andExpect(jsonPath("$.startedAt").isNotEmpty()) + .andExpect(jsonPath("$.finishedAt").doesNotExist()); + } + + @Test + void migrate_alreadyRunning_returns409() throws Exception { + when(migrationService.start()).thenThrow(new IllegalStateException("already running")); + + mockMvc.perform(post("/api/v1/admin/storage-encryption/migrate")) + .andExpect(status().isConflict()); + } + + @Test + void rotate_serialisesRewrapCount() throws Exception { + UUID keyId = seedKey(); + keyRepo.rows.get(keyId).setMasterKeyVersion(0); + + mockMvc.perform(post("/api/v1/admin/storage-encryption/master/rotate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rewrapped").value(1)) + .andExpect(jsonPath("$.masterKeyVersion").value(1)); + } + + @Test + void controller_isAdminOnly() { + PreAuthorize preAuthorize = + StorageEncryptionAdminController.class.getAnnotation(PreAuthorize.class); + assertThat(preAuthorize).as("admin API must be role-gated").isNotNull(); + assertThat(preAuthorize.value()).isEqualTo("hasRole('ADMIN')"); + } + + @Test + void noDeleteEndpointExists() { + // The "key material can be disabled but never destroyed" guarantee is structural: assert no + // handler method maps a DELETE. + boolean anyDelete = + java.util.Arrays.stream(StorageEncryptionAdminController.class.getMethods()) + .anyMatch( + m -> + m.isAnnotationPresent( + org.springframework.web.bind.annotation + .DeleteMapping.class)); + assertThat(anyDelete).as("no endpoint may delete key material").isFalse(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminControllerTest.java new file mode 100644 index 0000000000..b976985d45 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/StorageEncryptionAdminControllerTest.java @@ -0,0 +1,228 @@ +package stirling.software.proprietary.storage.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.InvalidDataAccessResourceUsageException; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.storage.crypto.FileEncryptionKeyService; +import stirling.software.proprietary.storage.crypto.FileEncryptionMasterKey; +import stirling.software.proprietary.storage.crypto.InMemoryKeyRepo; +import stirling.software.proprietary.storage.crypto.StorageEncryptionAuditListener; +import stirling.software.proprietary.storage.crypto.StorageEncryptionState; +import stirling.software.proprietary.storage.model.api.StorageEncryptionStatusResponse; +import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.storage.service.StorageEncryptionMigrationService; + +class StorageEncryptionAdminControllerTest { + + private static final String MASTER = + Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes()); + + private InMemoryKeyRepo keyRepo; + private FileEncryptionKeyService keyService; + private StoredFileRepository storedFileRepository; + private StorageEncryptionMigrationService migrationService; + private StorageEncryptionAdminController controller; + + @BeforeEach + void setUp() { + keyRepo = new InMemoryKeyRepo(); + keyService = + new FileEncryptionKeyService( + keyRepo.mock, new FileEncryptionMasterKey(MASTER, false)); + storedFileRepository = mock(StoredFileRepository.class); + when(storedFileRepository.countByEncryptionKeyIdIsNotNull()).thenReturn(4L); + when(storedFileRepository.countByEncryptionKeyIdIsNull()).thenReturn(2L); + migrationService = mock(StorageEncryptionMigrationService.class); + controller = + new StorageEncryptionAdminController( + storageEnabled(true), + StorageEncryptionState.of( + true, keyService, StorageEncryptionAuditListener.NOOP), + keyRepo.mock, + storedFileRepository, + migrationService, + mock(AuditService.class)); + } + + private static ApplicationProperties storageEnabled(boolean enabled) { + ApplicationProperties props = new ApplicationProperties(); + props.getStorage().setEnabled(enabled); + return props; + } + + private UUID createKey() throws Exception { + Team team = new Team(); + team.setId(1L); + User owner = new User(); + owner.setTeam(team); + return keyService.activeKekForOwner(owner).keyId(); + } + + @Test + void status_reportsCountsFingerprintAndKeys() throws Exception { + UUID keyId = createKey(); + StorageEncryptionStatusResponse status = controller.status(); + + assertThat(status.writeEnabled()).isTrue(); + assertThat(status.active()).isTrue(); + assertThat(status.masterKeyFingerprint()).hasSize(16); + assertThat(status.encryptedFiles()).isEqualTo(4); + assertThat(status.plaintextFiles()).isEqualTo(2); + assertThat(status.keys()).hasSize(1); + assertThat(status.keys().get(0).keyId()).isEqualTo(keyId); + assertThat(status.keys().get(0).status()).isEqualTo("ACTIVE"); + } + + @Test + void disableThenEnable_roundTripsAndFailsClosedInBetween() throws Exception { + UUID keyId = createKey(); + + StorageEncryptionStatusResponse.KeyInfo disabled = controller.disableKey(keyId); + assertThat(disabled.status()).isEqualTo("DISABLED"); + assertThatThrownBy(() -> keyService.kekForDecrypt(keyId)).hasMessageContaining("disabled"); + + StorageEncryptionStatusResponse.KeyInfo enabled = controller.enableKey(keyId); + assertThat(enabled.status()).isEqualTo("ACTIVE"); + assertThat(keyService.kekForDecrypt(keyId)).isNotNull(); + } + + @Test + void enable_keyNotDisabled_conflicts() throws Exception { + UUID keyId = createKey(); + assertThatThrownBy(() -> controller.enableKey(keyId)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void disable_unknownKey_notFound() { + assertThatThrownBy(() -> controller.disableKey(UUID.randomUUID())) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + void migrate_alreadyRunning_conflicts() { + when(migrationService.start()).thenThrow(new IllegalStateException("already running")); + assertThatThrownBy(() -> controller.startMigration()) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void migrationStatus_neverStarted_reportsIdle() { + when(migrationService.status()).thenReturn(Optional.empty()); + assertThat(controller.migrationStatus().state()).isEqualTo("IDLE"); + } + + @Test + void rotate_whenInactive_conflicts() { + StorageEncryptionAdminController inactive = + new StorageEncryptionAdminController( + storageEnabled(true), + new StorageEncryptionState( + false, + () -> { + throw new IllegalStateException("no key material configured"); + }, + keyRepo.mock, + StorageEncryptionAuditListener.NOOP), + keyRepo.mock, + storedFileRepository, + migrationService, + mock(AuditService.class)); + assertThatThrownBy(inactive::rotateMasterKey) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void status_storageDisabled_refusesWithoutTouchingTheRegistry() { + StorageEncryptionAdminController storageOff = + new StorageEncryptionAdminController( + storageEnabled(false), + StorageEncryptionState.of( + false, keyService, StorageEncryptionAuditListener.NOOP), + keyRepo.mock, + storedFileRepository, + migrationService, + mock(AuditService.class)); + + assertThatThrownBy(storageOff::status) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + // The registry table need not exist on a deployment that never stores files. + verifyNoInteractions(storedFileRepository); + verify(keyRepo.mock, never()).findAll(any(Sort.class)); + } + + @Test + void status_unreadableRegistry_reports503RatherThanRaw500() { + FileEncryptionKeyRepository broken = mock(FileEncryptionKeyRepository.class); + when(broken.findAll(any(Sort.class))) + .thenThrow(new InvalidDataAccessResourceUsageException("no such table")); + StorageEncryptionAdminController brokenRegistry = + new StorageEncryptionAdminController( + storageEnabled(true), + StorageEncryptionState.of( + true, keyService, StorageEncryptionAuditListener.NOOP), + broken, + storedFileRepository, + migrationService, + mock(AuditService.class)); + + assertThatThrownBy(brokenRegistry::status) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)); + } + + @Test + void rotate_rewrapsPendingRows() throws Exception { + UUID keyId = createKey(); + keyRepo.rows.get(keyId).setMasterKeyVersion(0); // pretend wrapped by an older master + + var result = controller.rotateMasterKey(); + assertThat(result.get("rewrapped")).isEqualTo(1); + assertThat(keyRepo.rows.get(keyId).getMasterKeyVersion()).isEqualTo(1); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/AuditingStorageEncryptionListenerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/AuditingStorageEncryptionListenerTest.java new file mode 100644 index 0000000000..0c5fa53bc7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/AuditingStorageEncryptionListenerTest.java @@ -0,0 +1,99 @@ +package stirling.software.proprietary.storage.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.service.AuditService; + +/** + * Covers the one knob a compliance reviewer actually turns: {@code storage.encryption.auditReads} + * must silence per-read decrypt events without silencing denials or key lifecycle events. + */ +class AuditingStorageEncryptionListenerTest { + + private static final UUID KEY_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + private final AuditService auditService = mock(AuditService.class); + + private List> auditedEvents() { + @SuppressWarnings("unchecked") + ArgumentCaptor> data = ArgumentCaptor.forClass(Map.class); + verify(auditService, atLeastOnce()) + .audit(eq(AuditEventType.STORAGE_ENCRYPTION), data.capture()); + return data.getAllValues(); + } + + @Test + void auditReadsOn_recordsDecryptWithKeyAndStorageKey() { + new AuditingStorageEncryptionListener(auditService, true).decrypted("team/blob", KEY_ID); + + assertThat(auditedEvents()) + .singleElement() + .satisfies( + event -> { + assertThat(event).containsEntry("action", "decrypt"); + assertThat(event).containsEntry("storageKey", "team/blob"); + assertThat(event).containsEntry("keyId", KEY_ID.toString()); + }); + } + + @Test + void auditReadsOff_dropsDecryptButKeepsEverythingElse() { + AuditingStorageEncryptionListener listener = + new AuditingStorageEncryptionListener(auditService, false); + + listener.decrypted("team/blob", KEY_ID); + verify(auditService, never()).audit(eq(AuditEventType.STORAGE_ENCRYPTION), anyMap()); + + listener.encrypted("team/blob", KEY_ID); + listener.decryptDenied(KEY_ID, "key disabled"); + listener.keyCreated(KEY_ID, "TEAM:7", 2); + + // Denials and lifecycle events are the compliance-relevant ones; they are never optional. + assertThat(auditedEvents()) + .extracting(event -> event.get("action")) + .containsExactly("encrypt", "decrypt.denied", "key.created"); + } + + @Test + void decryptDenied_carriesTheReason() { + new AuditingStorageEncryptionListener(auditService, false) + .decryptDenied(KEY_ID, "key not found"); + + assertThat(auditedEvents()) + .singleElement() + .satisfies( + event -> { + assertThat(event).containsEntry("action", "decrypt.denied"); + assertThat(event).containsEntry("reason", "key not found"); + assertThat(event).containsEntry("keyId", KEY_ID.toString()); + }); + } + + @Test + void keyCreated_carriesScopeAndVersion() { + new AuditingStorageEncryptionListener(auditService, true).keyCreated(KEY_ID, "TEAM:7", 2); + + assertThat(auditedEvents()) + .singleElement() + .satisfies( + event -> { + assertThat(event).containsEntry("action", "key.created"); + assertThat(event).containsEntry("scope", "TEAM:7"); + assertThat(event).containsEntry("keyVersion", 2); + }); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProviderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProviderTest.java index f66d03ae42..ce592f8bb0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProviderTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProviderTest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.Base64; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -239,6 +240,54 @@ class EncryptingStorageProviderTest { } } + @Test + void auditListener_receivesEncryptDecryptAndDeniedEvents() throws IOException { + List events = new java.util.ArrayList<>(); + StorageEncryptionAuditListener recording = + new StorageEncryptionAuditListener() { + @Override + public void encrypted(String storageKey, UUID keyId) { + events.add("encrypt:" + storageKey); + } + + @Override + public void decrypted(String storageKey, UUID keyId) { + events.add("decrypt:" + storageKey); + } + + @Override + public void decryptDenied(UUID keyId, String reason) { + events.add("denied:" + reason); + } + }; + FileEncryptionKeyService keyService = + new FileEncryptionKeyService( + repo.mock, new FileEncryptionMasterKey(MASTER, false), recording); + EncryptingStorageProvider audited = + new EncryptingStorageProvider(inner, keyService, true, recording); + + StoredObject stored = audited.store(owner, upload()); + try (InputStream in = audited.load(stored.getStorageKey()).getInputStream()) { + in.readAllBytes(); + } + // Legacy plaintext must NOT produce a decrypt event. + StoredObject legacy = inner.store(owner, upload()); + audited.load(legacy.getStorageKey()); + + assertThat(events) + .containsExactly( + "encrypt:" + stored.getStorageKey(), "decrypt:" + stored.getStorageKey()); + + repo.rows + .get(UUID.fromString(stored.getEncryptionKeyId())) + .setStatus(FileEncryptionKey.Status.DISABLED); + keyService.invalidate(UUID.fromString(stored.getEncryptionKeyId())); + assertThatThrownBy(() -> audited.load(stored.getStorageKey())) + .isInstanceOf(StorageKeyRevokedException.class); + assertThat(events).hasSize(3); + assertThat(events.get(2)).isEqualTo("denied:key disabled"); + } + @Test void load_tinyLegacyBlob_shorterThanHeader_passesThrough() throws IOException { byte[] tiny = "hi".getBytes(StandardCharsets.UTF_8); @@ -347,7 +396,11 @@ class EncryptingStorageProviderTest { void signedDownloadUrl_delegatesWhenNoEncryptedContentPossible() throws IOException { // Vanilla install: flag off, no key rows anywhere -> keep the backend's fast path. StorageEncryptionState vanilla = - new StorageEncryptionState(false, () -> newKeyService(), repo.mock); + new StorageEncryptionState( + false, + () -> newKeyService(), + repo.mock, + StorageEncryptionAuditListener.NOOP); StorageProvider withUrls = new StorageProvider() { @Override @@ -383,7 +436,11 @@ class EncryptingStorageProviderTest { StoredObject encrypted = provider.store(owner, upload()); assertThat(encrypted.getEncryptionKeyId()).isNotNull(); StorageEncryptionState drifted = - new StorageEncryptionState(false, () -> newKeyService(), repo.mock); + new StorageEncryptionState( + false, + () -> newKeyService(), + repo.mock, + StorageEncryptionAuditListener.NOOP); EncryptingStorageProvider driftedNode = new EncryptingStorageProvider(withUrls, drifted); assertThat(driftedNode.signedDownloadUrl("k", Duration.ofMinutes(5), false, "a.pdf")) .isEmpty(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceTest.java index 10c0a1431d..a4dd9720d3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceTest.java @@ -6,7 +6,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; +import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -143,6 +145,56 @@ class FileEncryptionKeyServiceTest { .hasMessageContaining("cannot unwrap"); } + @Test + void verifyMasterKey_halfRotatedRegistryWithPreviousKeyRemoved_refusesStartup() + throws Exception { + // Team 1 gets re-wrapped under the new master key; team 2 does not, because the rotation + // stopped part-way (or was never run). + FileEncryptionKeyService.ScopeKek rewrapped = service.activeKekForOwner(teamUser(1)); + FileEncryptionKeyService.ScopeKek leftBehind = service.activeKekForOwner(teamUser(2)); + FileEncryptionMasterKey rotating = + new FileEncryptionMasterKey(MASTER_B, MASTER_A, 2, false); + FileEncryptionKey row = repo.rows.get(rewrapped.keyId()); + row.setWrappedKey( + Base64.getEncoder() + .encodeToString( + rotating.wrap( + rewrapped.key(), + rewrapped + .keyId() + .toString() + .getBytes(StandardCharsets.US_ASCII)))); + row.setMasterKeyVersion(2); + + // The operator now removes the previous key, believing rotation finished. Team 2's files + // are only recoverable while MASTER_A still exists, so startup must not look healthy. + FileEncryptionKeyService afterRemoval = + new FileEncryptionKeyService( + repo.mock, new FileEncryptionMasterKey(MASTER_B, null, 2, false)); + + assertThatThrownBy(afterRemoval::verifyMasterKey) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot unwrap 1 of 2") + .hasMessageContaining(leftBehind.keyId().toString()) + .hasMessageContaining("Refusing to start"); + } + + @Test + void verifyMasterKey_disabledRowThatCannotUnwrap_refusesStartup() throws Exception { + // Revocation is advertised as reversible, so an unreadable DISABLED row is just as much a + // loss of access as an unreadable ACTIVE one - it would not come back on enable. + FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1)); + service.setKeyStatus(created.keyId(), FileEncryptionKey.Status.DISABLED, "admin"); + + FileEncryptionKeyService wrongKey = + new FileEncryptionKeyService( + repo.mock, new FileEncryptionMasterKey(MASTER_B, false)); + + assertThatThrownBy(wrongKey::verifyMasterKey) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot unwrap 1 of 1"); + } + @Test void createActive_concurrentInsertRace_fallsBackToWinnersRow() throws Exception { // First save call hits the unique constraint; the service must re-read the winner's row. @@ -186,6 +238,111 @@ class FileEncryptionKeyServiceTest { assertThat(resolved.key()).isEqualTo(winnerKek); } + @Test + void setKeyStatus_disable_takesEffectImmediatelyOnSameService() throws Exception { + FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1)); + // The unwrap cache is warm from creation; disabling must invalidate it, not wait for TTL. + service.setKeyStatus(created.keyId(), FileEncryptionKey.Status.DISABLED, "admin"); + assertThatThrownBy(() -> service.kekForDecrypt(created.keyId())) + .isInstanceOf(StorageKeyRevokedException.class); + assertThat(repo.rows.get(created.keyId()).getStatusChangedBy()).isEqualTo("admin"); + + service.setKeyStatus(created.keyId(), FileEncryptionKey.Status.ACTIVE, "admin"); + assertThat(service.kekForDecrypt(created.keyId())).isEqualTo(created.key()); + } + + @Test + void unwrap_fallsBackToPreviousMasterKeyDuringRotation() throws Exception { + FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1)); + + // New primary key B, old key A kept as previous, version bumped to 2. + FileEncryptionMasterKey rotated = new FileEncryptionMasterKey(MASTER_B, MASTER_A, 2, false); + FileEncryptionKeyService rotatedService = new FileEncryptionKeyService(repo.mock, rotated); + assertThat(rotatedService.kekForDecrypt(created.keyId())).isEqualTo(created.key()); + rotatedService.verifyMasterKey(); // must pass via the previous-key fallback + } + + @Test + void rotateMasterKey_rewrapsRowsBelowCurrentVersion() throws Exception { + FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1)); + String wrappedBefore = repo.rows.get(created.keyId()).getWrappedKey(); + + FileEncryptionMasterKey rotated = new FileEncryptionMasterKey(MASTER_B, MASTER_A, 2, false); + FileEncryptionKeyService rotatedService = new FileEncryptionKeyService(repo.mock, rotated); + assertThat(rotatedService.rotateMasterKey()).isEqualTo(1); + + FileEncryptionKey row = repo.rows.get(created.keyId()); + assertThat(row.getMasterKeyVersion()).isEqualTo(2); + assertThat(row.getWrappedKey()).isNotEqualTo(wrappedBefore); + + // Same key material now unwraps WITHOUT the previous key configured. + FileEncryptionKeyService afterCleanup = + new FileEncryptionKeyService( + repo.mock, new FileEncryptionMasterKey(MASTER_B, null, 2, false)); + assertThat(afterCleanup.kekForDecrypt(created.keyId())).isEqualTo(created.key()); + + // Second rotation call is a no-op. + assertThat(rotatedService.rotateMasterKey()).isZero(); + } + + @Test + void enable_afterScopeMintedAnotherKey_comesBackRetiredNotSecondActive() throws Exception { + FileEncryptionKeyService.ScopeKek revoked = service.activeKekForOwner(teamUser(1)); + service.setKeyStatus(revoked.keyId(), FileEncryptionKey.Status.DISABLED, "admin"); + + // Revoking does not stop the team uploading: this mints a second key for the scope. + FileEncryptionKeyService.ScopeKek minted = service.activeKekForOwner(teamUser(1)); + assertThat(minted.keyId()).isNotEqualTo(revoked.keyId()); + + FileEncryptionKey reEnabled = service.enable(revoked.keyId(), "admin"); + + assertThat(reEnabled.getStatus()).isEqualTo(FileEncryptionKey.Status.RETIRED); + assertThat(activeKeysForTeam(1)).containsExactly(minted.keyId()); + // RETIRED still unwraps, so the revoked content is readable again. + assertThat(service.kekForDecrypt(revoked.keyId())).isEqualTo(revoked.key()); + } + + @Test + void enable_scopeHasNoOtherActiveKey_comesBackActive() throws Exception { + FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1)); + service.setKeyStatus(created.keyId(), FileEncryptionKey.Status.DISABLED, "admin"); + + FileEncryptionKey reEnabled = service.enable(created.keyId(), "admin"); + + assertThat(reEnabled.getStatus()).isEqualTo(FileEncryptionKey.Status.ACTIVE); + assertThat(activeKeysForTeam(1)).containsExactly(created.keyId()); + assertThat(service.kekForDecrypt(created.keyId())).isEqualTo(created.key()); + } + + @Test + void activeKekForOwner_twoActiveRowsInScope_picksTheHighestVersionOnEveryNode() + throws Exception { + // A registry left in the pre-fix shape (an older build re-enabled a key straight to + // ACTIVE). + FileEncryptionKeyService.ScopeKek older = service.activeKekForOwner(teamUser(1)); + service.setKeyStatus(older.keyId(), FileEncryptionKey.Status.DISABLED, "admin"); + FileEncryptionKeyService.ScopeKek newer = service.activeKekForOwner(teamUser(1)); + service.setKeyStatus(older.keyId(), FileEncryptionKey.Status.ACTIVE, "old-build"); + assertThat(activeKeysForTeam(1)).hasSize(2); + + // Two independent nodes (fresh caches) must agree, rather than follow DB row order. + for (int node = 0; node < 2; node++) { + FileEncryptionKeyService fresh = + new FileEncryptionKeyService( + repo.mock, new FileEncryptionMasterKey(MASTER_A, false)); + assertThat(fresh.activeKekForOwner(teamUser(1)).keyId()).isEqualTo(newer.keyId()); + } + } + + private List activeKeysForTeam(long teamId) { + return repo.rows.values().stream() + .filter(r -> r.getScopeType() == FileEncryptionKey.ScopeType.TEAM) + .filter(r -> r.getScopeId() == teamId) + .filter(r -> r.getStatus() == FileEncryptionKey.Status.ACTIVE) + .map(FileEncryptionKey::getKeyId) + .toList(); + } + @Test void createActive_raceWithoutWinner_rethrows() { doThrow(new DataIntegrityViolationException("duplicate key")) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/InMemoryKeyRepo.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/InMemoryKeyRepo.java index cc3828333a..3d5e7ede3d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/InMemoryKeyRepo.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/InMemoryKeyRepo.java @@ -1,16 +1,20 @@ package stirling.software.proprietary.storage.crypto; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Comparator; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.data.domain.Sort; + import stirling.software.proprietary.storage.model.FileEncryptionKey; import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository; @@ -38,14 +42,17 @@ public final class InMemoryKeyRepo { }); when(mock.findById(any(UUID.class))) .thenAnswer(inv -> Optional.ofNullable(rows.get(inv.getArgument(0)))); - when(mock.findFirstByScopeTypeAndScopeIdAndStatus(any(), anyLong(), any())) + when(mock.findFirstByScopeTypeAndScopeIdAndStatusOrderByKeyVersionDesc( + any(), anyLong(), any())) .thenAnswer( inv -> rows.values().stream() .filter(r -> r.getScopeType() == inv.getArgument(0)) .filter(r -> r.getScopeId() == inv.getArgument(1)) .filter(r -> r.getStatus() == inv.getArgument(2)) - .findFirst()); + .max( + Comparator.comparingInt( + FileEncryptionKey::getKeyVersion))); when(mock.findFirstByScopeTypeAndScopeIdOrderByKeyVersionDesc(any(), anyLong())) .thenAnswer( inv -> @@ -62,5 +69,34 @@ public final class InMemoryKeyRepo { .filter(r -> r.getStatus() == inv.getArgument(0)) .findFirst()); when(mock.count()).thenAnswer(inv -> (long) rows.size()); + when(mock.findAll()).thenAnswer(inv -> List.copyOf(rows.values())); + when(mock.countByMasterKeyVersionLessThan(anyInt())) + .thenAnswer( + inv -> + rows.values().stream() + .filter( + r -> + r.getMasterKeyVersion() + < inv.getArgument(0)) + .count()); + when(mock.findByMasterKeyVersionLessThan(anyInt())) + .thenAnswer( + inv -> + rows.values().stream() + .filter( + r -> + r.getMasterKeyVersion() + < inv.getArgument(0)) + .toList()); + when(mock.countByMasterKeyVersionGreaterThan(anyInt())) + .thenAnswer( + inv -> + rows.values().stream() + .filter( + r -> + r.getMasterKeyVersion() + > inv.getArgument(0)) + .count()); + when(mock.findAll(any(Sort.class))).thenAnswer(inv -> List.copyOf(rows.values())); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/repository/StoredFileMigrationQueriesDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/repository/StoredFileMigrationQueriesDbTest.java new file mode 100644 index 0000000000..430b0e1522 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/repository/StoredFileMigrationQueriesDbTest.java @@ -0,0 +1,148 @@ +package stirling.software.proprietary.storage.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.DirtiesContext; + +import jakarta.persistence.EntityManager; + +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.StoredFile; + +/** + * The encrypt-existing migration's queries against a real database. The service-level tests mock + * {@link StoredFileRepository} wholesale, so without this the JOIN FETCH + Pageable selection and + * the three compare-and-swap updates would never actually execute — a wrong CAS predicate would + * pass every mocked test and then silently skip every file in production. + */ +@DataJpaTest +@DirtiesContext +class StoredFileMigrationQueriesDbTest { + + @Autowired private StoredFileRepository repository; + @Autowired private EntityManager entityManager; + + private User owner; + + @BeforeEach + void setUp() { + Team team = new Team(); + team.setName("team-" + UUID.randomUUID()); + entityManager.persist(team); + + owner = new User(); + owner.setUsername("owner-" + UUID.randomUUID()); + owner.setPassword("x"); + owner.setTeam(team); + entityManager.persist(owner); + entityManager.flush(); + } + + private StoredFile persistFile(String storageKey, String encryptionKeyId) { + StoredFile file = new StoredFile(); + file.setOwner(owner); + file.setOriginalFilename("doc.pdf"); + file.setContentType("application/pdf"); + file.setSizeBytes(123); + file.setStorageKey(storageKey); + file.setEncryptionKeyId(encryptionKeyId); + entityManager.persist(file); + entityManager.flush(); + return file; + } + + @Test + void findMigratableAfter_selectsOnlyPlaintextRows_inIdOrder_withOwnerFetched() { + StoredFile plain1 = persistFile("k-plain-1", null); + StoredFile plain2 = persistFile("k-plain-2", null); + persistFile("k-encrypted", UUID.randomUUID().toString()); + entityManager.clear(); + + List page = repository.findMigratableAfter(0L, PageRequest.of(0, 10)); + + assertThat(page) + .extracting(StoredFile::getId) + .containsExactly(plain1.getId(), plain2.getId()); + // JOIN FETCH must populate owner (+ its EAGER team) for use outside this session. + assertThat(page.get(0).getOwner().getUsername()).isEqualTo(owner.getUsername()); + assertThat(page.get(0).getOwner().getTeam()).isNotNull(); + } + + @Test + void findMigratableAfter_cursorAndPageSizeAreHonoured() { + StoredFile first = persistFile("k-1", null); + StoredFile second = persistFile("k-2", null); + entityManager.clear(); + + assertThat(repository.findMigratableAfter(0L, PageRequest.of(0, 1))) + .extracting(StoredFile::getId) + .containsExactly(first.getId()); + assertThat(repository.findMigratableAfter(first.getId(), PageRequest.of(0, 10))) + .extracting(StoredFile::getId) + .containsExactly(second.getId()); + assertThat(repository.findMigratableAfter(second.getId(), PageRequest.of(0, 10))).isEmpty(); + } + + @Test + void swapMainBlob_appliesOnlyWhenStorageKeyStillMatches() { + StoredFile file = persistFile("k-old", null); + String keyId = UUID.randomUUID().toString(); + entityManager.clear(); + + // Stale expectation (someone else replaced the file) -> no row updated. + assertThat(repository.swapMainBlob(file.getId(), "k-stale", "k-new", keyId)).isZero(); + + assertThat(repository.swapMainBlob(file.getId(), "k-old", "k-new", keyId)).isEqualTo(1); + entityManager.clear(); + + StoredFile reloaded = repository.findById(file.getId()).orElseThrow(); + assertThat(reloaded.getStorageKey()).isEqualTo("k-new"); + assertThat(reloaded.getEncryptionKeyId()).isEqualTo(keyId); + // Stamping the key id must remove the row from the migration's selection. + assertThat(repository.findMigratableAfter(0L, PageRequest.of(0, 10))).isEmpty(); + assertThat(repository.countByEncryptionKeyIdIsNotNull()).isEqualTo(1); + assertThat(repository.countByEncryptionKeyIdIsNull()).isZero(); + } + + @Test + void swapSecondaryBlobs_applyIndependentlyAndLeaveKeyIdAlone() { + StoredFile file = persistFile("k-main", null); + file.setHistoryStorageKey("k-hist-old"); + file.setAuditLogStorageKey("k-audit-old"); + entityManager.merge(file); + entityManager.flush(); + entityManager.clear(); + + assertThat(repository.swapHistoryBlob(file.getId(), "k-wrong", "k-hist-new")).isZero(); + assertThat(repository.swapHistoryBlob(file.getId(), "k-hist-old", "k-hist-new")) + .isEqualTo(1); + assertThat(repository.swapAuditLogBlob(file.getId(), "k-audit-old", "k-audit-new")) + .isEqualTo(1); + entityManager.clear(); + + StoredFile reloaded = repository.findById(file.getId()).orElseThrow(); + assertThat(reloaded.getHistoryStorageKey()).isEqualTo("k-hist-new"); + assertThat(reloaded.getAuditLogStorageKey()).isEqualTo("k-audit-new"); + // Secondary swaps must not stamp encryptionKeyId, so the row stays selectable until the + // main blob is done. + assertThat(reloaded.getEncryptionKeyId()).isNull(); + assertThat(repository.findMigratableAfter(0L, PageRequest.of(0, 10))) + .extracting(StoredFile::getId) + .containsExactly(file.getId()); + } + + @SpringBootConfiguration + @AutoConfigurationPackage(basePackages = "stirling.software.proprietary") + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/StorageEncryptionMigrationServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/StorageEncryptionMigrationServiceTest.java new file mode 100644 index 0000000000..e71f8ef714 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/StorageEncryptionMigrationServiceTest.java @@ -0,0 +1,467 @@ +package stirling.software.proprietary.storage.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.InputStreamResource; +import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.storage.crypto.EncryptingStorageProvider; +import stirling.software.proprietary.storage.crypto.FileEncryptionKeyService; +import stirling.software.proprietary.storage.crypto.FileEncryptionMasterKey; +import stirling.software.proprietary.storage.crypto.InMemoryKeyRepo; +import stirling.software.proprietary.storage.crypto.StorageEncryptionAuditListener; +import stirling.software.proprietary.storage.crypto.StorageEncryptionState; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.LocalStorageProvider; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.provider.StoredObject; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +class StorageEncryptionMigrationServiceTest { + + private static final byte[] CONTENT = + "legacy plaintext content that must end up encrypted".getBytes(StandardCharsets.UTF_8); + private static final String MASTER = + Base64.getEncoder() + .encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + + @TempDir Path tempDir; + + private final Map rows = new ConcurrentHashMap<>(); + private LocalStorageProvider inner; + private StorageProvider provider; + private StoredFileRepository fileRepo; + private StorageEncryptionMigrationService service; + private FileEncryptionKeyService keyService; + private User owner; + + @BeforeEach + void setUp() { + inner = new LocalStorageProvider(tempDir); + keyService = + new FileEncryptionKeyService( + new InMemoryKeyRepo().mock, new FileEncryptionMasterKey(MASTER, false)); + provider = + new EncryptingStorageProvider( + inner, keyService, true, StorageEncryptionAuditListener.NOOP); + + Team team = new Team(); + team.setId(3L); + owner = new User(); + owner.setId(1L); + owner.setTeam(team); + + fileRepo = mock(StoredFileRepository.class); + when(fileRepo.countByEncryptionKeyIdIsNull()) + .thenAnswer( + inv -> + rows.values().stream() + .filter(f -> f.getEncryptionKeyId() == null) + .count()); + when(fileRepo.findMigratableAfter(anyLong(), any(Pageable.class))) + .thenAnswer( + inv -> { + long lastId = inv.getArgument(0); + Pageable p = inv.getArgument(1); + return rows.values().stream() + .filter(f -> f.getEncryptionKeyId() == null) + .filter(f -> f.getId() > lastId) + .sorted(Comparator.comparing(StoredFile::getId)) + .limit(p.getPageSize()) + .toList(); + }); + when(fileRepo.swapMainBlob(anyLong(), anyString(), anyString(), anyString())) + .thenAnswer( + inv -> { + StoredFile f = rows.get(inv.getArgument(0)); + if (f == null || !f.getStorageKey().equals(inv.getArgument(1))) { + return 0; + } + f.setStorageKey(inv.getArgument(2)); + f.setEncryptionKeyId(inv.getArgument(3)); + return 1; + }); + when(fileRepo.swapHistoryBlob(anyLong(), anyString(), anyString())) + .thenAnswer( + inv -> { + StoredFile f = rows.get(inv.getArgument(0)); + if (f == null || !inv.getArgument(1).equals(f.getHistoryStorageKey())) { + return 0; + } + f.setHistoryStorageKey(inv.getArgument(2)); + return 1; + }); + + service = + new StorageEncryptionMigrationService( + fileRepo, + provider, + StorageEncryptionState.of( + true, keyService, StorageEncryptionAuditListener.NOOP), + mock(AuditService.class)); + } + + private StoredFile plaintextFile(long id, boolean withHistory) throws Exception { + StoredObject main = + inner.store( + owner, + new MockMultipartFile( + "file", "doc" + id + ".pdf", "application/pdf", CONTENT)); + StoredFile f = new StoredFile(); + f.setId(id); + f.setOwner(owner); + f.setOriginalFilename("doc" + id + ".pdf"); + f.setContentType("application/pdf"); + f.setSizeBytes(CONTENT.length); + f.setStorageKey(main.getStorageKey()); + if (withHistory) { + StoredObject hist = + inner.store( + owner, + new MockMultipartFile( + "file", "hist" + id + ".zip", "application/zip", CONTENT)); + f.setHistoryStorageKey(hist.getStorageKey()); + f.setHistoryFilename("hist" + id + ".zip"); + f.setHistoryContentType("application/zip"); + f.setHistorySizeBytes((long) CONTENT.length); + } + rows.put(id, f); + return f; + } + + private StorageEncryptionMigrationService.MigrationStatus awaitCompletion() throws Exception { + for (int i = 0; i < 300; i++) { + Optional s = service.status(); + if (s.isPresent() + && s.get().state() != StorageEncryptionMigrationService.State.RUNNING) { + return s.get(); + } + Thread.sleep(100); + } + throw new AssertionError("migration did not finish in time"); + } + + @Test + void migrate_encryptsBacklogAndPreservesContent() throws Exception { + plaintextFile(1, false); + plaintextFile(2, true); + + service.start(); + StorageEncryptionMigrationService.MigrationStatus done = awaitCompletion(); + + assertThat(done.state()).isEqualTo(StorageEncryptionMigrationService.State.COMPLETED); + assertThat(done.processed()).isEqualTo(2); + assertThat(done.failed()).isZero(); + + for (StoredFile f : rows.values()) { + assertThat(f.getEncryptionKeyId()).isNotNull(); + byte[] onDisk = Files.readAllBytes(tempDir.resolve(f.getStorageKey())); + assertThat(new String(onDisk, 0, 8, StandardCharsets.US_ASCII)).isEqualTo("SPDFEAR1"); + try (var in = provider.load(f.getStorageKey()).getInputStream()) { + assertThat(in.readAllBytes()).isEqualTo(CONTENT); + } + if (f.getHistoryStorageKey() != null) { + byte[] hist = Files.readAllBytes(tempDir.resolve(f.getHistoryStorageKey())); + assertThat(new String(hist, 0, 8, StandardCharsets.US_ASCII)).isEqualTo("SPDFEAR1"); + } + } + // Exactly one blob per storage key remains (old plaintext blobs were deleted). + long blobCount; + try (var stream = Files.walk(tempDir)) { + blobCount = stream.filter(Files::isRegularFile).count(); + } + assertThat(blobCount).isEqualTo(3); + } + + @Test + void migrate_casMiss_discardsOwnCopyAndSkips() throws Exception { + plaintextFile(1, false); + // Simulate a user replacing the file mid-migration: main-blob CAS always misses. + when(fileRepo.swapMainBlob(anyLong(), anyString(), anyString(), anyString())).thenReturn(0); + + service.start(); + StorageEncryptionMigrationService.MigrationStatus done = awaitCompletion(); + + assertThat(done.skipped()).isEqualTo(1); + assertThat(done.processed()).isZero(); + StoredFile f = rows.get(1L); + assertThat(f.getEncryptionKeyId()).isNull(); + // The user's blob is untouched and still loadable; the migration's copy is gone. + byte[] onDisk = Files.readAllBytes(tempDir.resolve(f.getStorageKey())); + assertThat(onDisk).isEqualTo(CONTENT); + long blobCount; + try (var stream = Files.walk(tempDir)) { + blobCount = stream.filter(Files::isRegularFile).count(); + } + assertThat(blobCount).isEqualTo(1); + } + + @Test + void start_secondConcurrentRun_rejected() throws Exception { + for (long i = 1; i <= 30; i++) { + plaintextFile(i, false); + } + service.start(); + assertThatThrownBy(service::start) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already running"); + awaitCompletion(); + // After completion a new run is allowed again. + service.start(); + awaitCompletion(); + } + + @Test + void start_writeDisabled_rejected() { + StorageEncryptionMigrationService disabled = + new StorageEncryptionMigrationService( + fileRepo, + provider, + new StorageEncryptionState( + false, () -> null, null, StorageEncryptionAuditListener.NOOP), + mock(AuditService.class)); + assertThatThrownBy(disabled::start) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("storage.encryption.enabled"); + } + + @Test + void migrate_writeFlagOffMidRun_stopsFailedWithoutChurningTheRestOfTheBacklog() + throws Exception { + for (long i = 1; i <= 30; i++) { // more than one page, so the run has somewhere to churn + plaintextFile(i, false); + } + AtomicBoolean writeEnabled = new AtomicBoolean(true); + AtomicInteger stores = new AtomicInteger(); + // Flip the flag the instant the first file has been stored, exactly as an admin toggling + // storage.encryption.enabled mid-run would. + StorageProvider countingInner = + new StorageProvider() { + @Override + public StoredObject store(User o, MultipartFile f) throws java.io.IOException { + StoredObject stored = inner.store(o, f); + if (stores.incrementAndGet() == 1) { + writeEnabled.set(false); + } + return stored; + } + + @Override + public org.springframework.core.io.Resource load(String key) + throws java.io.IOException { + return inner.load(key); + } + + @Override + public void delete(String key) throws java.io.IOException { + inner.delete(key); + } + }; + StorageEncryptionState flippable = flippableState(writeEnabled); + service = + new StorageEncryptionMigrationService( + fileRepo, + new EncryptingStorageProvider(countingInner, flippable), + flippable, + mock(AuditService.class)); + + service.start(); + StorageEncryptionMigrationService.MigrationStatus done = awaitCompletion(); + + assertThat(done.state()).isEqualTo(StorageEncryptionMigrationService.State.FAILED); + assertThat(done.processed()).isEqualTo(1); + // The remaining 29 files were never re-stored, so nothing was written and deleted for + // nothing, and none of them is counted as a failure. + assertThat(stores.get()).isEqualTo(1); + assertThat(done.failed()).isZero(); + assertThat(rows.values().stream().filter(f -> f.getEncryptionKeyId() == null).count()) + .isEqualTo(29); + } + + @Test + void start_auditsTheAdminWhoTriggeredIt_onStartAndCompletion() throws Exception { + plaintextFile(1, false); + AuditService audit = mock(AuditService.class); + when(audit.captureCurrentPrincipal()).thenReturn("admin-alice"); + service = + new StorageEncryptionMigrationService( + fileRepo, + provider, + StorageEncryptionState.of( + true, keyService, StorageEncryptionAuditListener.NOOP), + audit); + + service.start(); + awaitCompletion(); + + // Both ends of the run must name the admin: the completion event runs on a virtual thread + // with no security context, where the principal would otherwise resolve to "system". + verify(audit) + .audit( + eq("admin-alice"), + eq(AuditEventType.STORAGE_ENCRYPTION), + argThat(data -> "migration.started".equals(data.get("action")))); + verify(audit) + .audit( + eq("admin-alice"), + eq(AuditEventType.STORAGE_ENCRYPTION), + argThat(data -> "migration.completed".equals(data.get("action")))); + } + + // ---- one-shot backends (S3, database) ------------------------------------------------ + + /** + * Wraps the local provider so {@code load} hands back a stock {@link InputStreamResource}: a + * one-shot stream that refuses {@code contentLength()} once partially read, the way an S3 or + * database resource behaves. Local files are re-openable and never exercise that branch. + */ + private StorageProvider oneShotBacked() { + return new StorageProvider() { + @Override + public StoredObject store(User o, MultipartFile f) throws java.io.IOException { + return inner.store(o, f); + } + + @Override + public org.springframework.core.io.Resource load(String key) + throws java.io.IOException { + return new InputStreamResource(inner.load(key).getInputStream()); + } + + @Override + public void delete(String key) throws java.io.IOException { + inner.delete(key); + } + }; + } + + private void useProvider(StorageProvider backing) { + service = + new StorageEncryptionMigrationService( + fileRepo, + new EncryptingStorageProvider( + backing, + StorageEncryptionState.of( + true, keyService, StorageEncryptionAuditListener.NOOP)), + StorageEncryptionState.of( + true, keyService, StorageEncryptionAuditListener.NOOP), + mock(AuditService.class)); + } + + @Test + void migrate_oneShotResource_fallsBackToTheRecordedSizeAndStillEncrypts() throws Exception { + StoredFile file = plaintextFile(1, false); + useProvider(oneShotBacked()); + + service.start(); + StorageEncryptionMigrationService.MigrationStatus done = awaitCompletion(); + + assertThat(done.state()).isEqualTo(StorageEncryptionMigrationService.State.COMPLETED); + assertThat(done.processed()).isEqualTo(1); + assertThat(file.getEncryptionKeyId()).isNotNull(); + byte[] onDisk = Files.readAllBytes(tempDir.resolve(file.getStorageKey())); + assertThat(new String(onDisk, 0, 8, StandardCharsets.US_ASCII)).isEqualTo("SPDFEAR1"); + // Decrypting through a fresh load must return the original bytes, not a truncated stream. + try (var in = + new EncryptingStorageProvider(inner, keyService, true) + .load(file.getStorageKey()) + .getInputStream()) { + assertThat(in.readAllBytes()).isEqualTo(CONTENT); + } + } + + @Test + void migrate_oneShotResourceWithWrongRecordedSize_failsTheFileAndLeavesTheRowAlone() + throws Exception { + StoredFile file = plaintextFile(1, false); + String originalKey = file.getStorageKey(); + file.setSizeBytes(CONTENT.length + 7L); // stale/incorrect row size + useProvider(oneShotBacked()); + + service.start(); + StorageEncryptionMigrationService.MigrationStatus done = awaitCompletion(); + + // The size mismatch is caught inside store(), before the compare-and-swap, so the file is + // counted as failed and the row still points at its untouched plaintext blob. + assertThat(done.failed()).isEqualTo(1); + assertThat(done.processed()).isZero(); + assertThat(file.getEncryptionKeyId()).isNull(); + assertThat(file.getStorageKey()).isEqualTo(originalKey); + assertThat(Files.readAllBytes(tempDir.resolve(originalKey))).isEqualTo(CONTENT); + } + + @Test + void migrate_oneShotResourceWithNoRecordedSize_failsTheFileRatherThanGuessing() + throws Exception { + // Only the secondary blobs have a nullable size, so that is where "no size available at + // all" is reachable: neither the resource nor the row can say how long the plaintext is. + StoredFile file = plaintextFile(1, true); + String historyKey = file.getHistoryStorageKey(); + file.setHistorySizeBytes(null); + useProvider(oneShotBacked()); + + service.start(); + StorageEncryptionMigrationService.MigrationStatus done = awaitCompletion(); + + assertThat(done.failed()).isEqualTo(1); + assertThat(done.processed()).isZero(); + assertThat(file.getEncryptionKeyId()).isNull(); + assertThat(file.getHistoryStorageKey()).isEqualTo(historyKey); + } + + private StorageEncryptionState flippableState(AtomicBoolean writeEnabled) { + return new StorageEncryptionState( + true, () -> keyService, null, StorageEncryptionAuditListener.NOOP) { + @Override + public boolean isWriteEnabled() { + return writeEnabled.get(); + } + }; + } + + @Test + void migrate_perFileFailure_countsAndContinues() throws Exception { + StoredFile broken = plaintextFile(1, false); + plaintextFile(2, false); + // Point file 1 at a missing blob so its migration throws. + broken.setStorageKey("1/does-not-exist"); + + service.start(); + StorageEncryptionMigrationService.MigrationStatus done = awaitCompletion(); + + assertThat(done.state()).isEqualTo(StorageEncryptionMigrationService.State.COMPLETED); + assertThat(done.failed()).isEqualTo(1); + assertThat(done.processed()).isEqualTo(1); + assertThat(rows.get(2L).getEncryptionKeyId()).isNotNull(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java index cf985d568e..184926574e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java @@ -28,6 +28,7 @@ import stirling.software.common.model.ApplicationProperties.Storage; import stirling.software.common.model.ApplicationProperties.Storage.Signing; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.crypto.StorageKeyRevokedException; import stirling.software.proprietary.storage.model.StoredFile; import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.provider.StoredObject; @@ -639,4 +640,46 @@ class WorkflowSessionServiceTest { assertThat(service.listUserSessions(owner)).isEmpty(); } + + // ------------------------------------------------------------------------- + // revoked encryption keys (storage encryption kill switch) + // ------------------------------------------------------------------------- + + @Test + void getOriginalFile_revokedEncryptionKey_isForbiddenNotServerError() throws Exception { + WorkflowSession session = new WorkflowSession(); + StoredFile original = new StoredFile(); + original.setStorageKey("1/original"); + session.setOriginalFile(original); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(session)); + when(storageProvider.load("1/original")) + .thenThrow(new StorageKeyRevokedException("key disabled")); + + assertThatThrownBy(() -> service.getOriginalFile("s1")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + } + + @Test + void getSignRequestDocument_revokedEncryptionKey_isForbiddenNotServerError() throws Exception { + User participantUser = user("bob"); + WorkflowSession session = sessionWithParticipant("s2", pendingParticipant(participantUser)); + StoredFile original = new StoredFile(); + original.setStorageKey("1/sign-me"); + session.setOriginalFile(original); + when(storageProvider.load("1/sign-me")) + .thenThrow(new StorageKeyRevokedException("key disabled")); + + // This path wraps the read in catch(IOException) -> 500; the revocation must not be + // swallowed by it, because StorageKeyRevokedException IS an IOException. + assertThatThrownBy(() -> service.getSignRequestDocument("s2", participantUser)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + } } diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java index 490b3d5db1..646791f76e 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -152,7 +152,7 @@ public class AccountLinkController { if (rows.isEmpty()) { return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); } - TeamMembership m = rows.get(0); + TeamMembership m = rows.getFirst(); if (m.getRole() != TeamRole.LEADER) { return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); } 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/payg/api/PaygInvoicesController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java index 80c20902ce..ad48a03743 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java @@ -105,7 +105,7 @@ public class PaygInvoicesController { if (rows.isEmpty()) { return ResponseEntity.ok(List.of()); } - Long teamId = rows.get(0).getTeam().getId(); + Long teamId = rows.getFirst().getTeam().getId(); // No PAYG extension row OR no Stripe customer id → team has never subscribed → no // invoices. Empty list, not 404 — the UI distinguishes "no invoices yet" from a diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java index 89ab18230f..22a84eb489 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java @@ -85,7 +85,7 @@ public class PaygPaymentMethodController { if (rows.isEmpty()) { return ResponseEntity.ok(PaymentMethodResponse.absent()); } - Long teamId = rows.get(0).getTeam().getId(); + Long teamId = rows.getFirst().getTeam().getId(); Optional ext = extRepo.findById(teamId); if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 0f18f13284..ffc4a056fc 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -427,7 +427,7 @@ public class PaygWalletController { private Optional primaryMembership(Long userId) { List rows = memberRepo.findPrimaryMembership(userId); - return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.getFirst()); } private List buildMemberRows( diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index 2fc175f224..1310d2ea97 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -324,7 +324,7 @@ public class JobChargeService { List paths = inputs.stream().map(JobInput::path).toList(); DocumentMetrics metrics = multiparts.size() == 1 - ? classifier.classify(multiparts.get(0), paths.get(0), policy) + ? classifier.classify(multiparts.getFirst(), paths.getFirst(), policy) : classifier.classify(multiparts, paths, policy); // Apply the policy-level minChargeUnits floor per design § 3.4. The classifier returns // raw docUnits with a "non-empty input → ≄1" floor; the charge formula's diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java index 09df6ed12f..2a92d941bf 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java @@ -75,7 +75,7 @@ public class JpaJobLineageStore implements JobLineageStore { List matches = hashRepository.findOpenJobsForSignatures( userId, JobStatus.OPEN, since, storageKeys, Limit.of(1)); - return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0)); + return matches.isEmpty() ? Optional.empty() : Optional.of(matches.getFirst()); } @Override diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index a4b2366379..162a5fe408 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -291,7 +291,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { if (!(raw instanceof List amrList) || amrList.isEmpty()) { return WEB; } - Object first = amrList.get(0); + Object first = amrList.getFirst(); if (!(first instanceof Map entry)) { return WEB; } 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/main/resources/db/migration/saas/V33__api_keys.sql b/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql deleted file mode 100644 index abced067e6..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Named, multi-key personal API keys, plus per-key daily usage. --- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments. - -CREATE TABLE IF NOT EXISTS api_keys ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - key_hash VARCHAR(64) NOT NULL, - prefix VARCHAR(32) NOT NULL, - owner_user_id BIGINT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL, - last_used_at TIMESTAMPTZ, - revoked_at TIMESTAMPTZ -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_api_key_hash ON api_keys (key_hash); -CREATE INDEX IF NOT EXISTS idx_api_key_owner ON api_keys (owner_user_id); - -CREATE TABLE IF NOT EXISTS api_key_daily_usage ( - api_key_id BIGINT NOT NULL, - epoch_day BIGINT NOT NULL, - count BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY (api_key_id, epoch_day) -); 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 de0861d8fa..30a9123ebb 100644 --- a/build.gradle +++ b/build.gradle @@ -26,11 +26,11 @@ ext { imageioVersion = "3.13.1" lombokVersion = "1.18.46" bouncycastleVersion = "1.85" - springSecuritySamlVersion = "7.0.5" + springSecuritySamlVersion = "7.1.0" 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" @@ -95,6 +95,12 @@ bootJar { enabled = false } +// :saas is only included for SaaS builds, but a default-flavor clean must still remove artifacts +// left behind by an earlier SaaS build. +tasks.named('clean') { + delete layout.projectDirectory.dir('app/saas/build') +} + // Configure main class for the root project springBoot { mainClass = 'stirling.software.SPDF.SPDFApplication' @@ -102,7 +108,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.14.2' + version = '2.14.3' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" @@ -296,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/devGuide/README.md b/devGuide/README.md index 5c8f60577b..5e8486f013 100644 --- a/devGuide/README.md +++ b/devGuide/README.md @@ -9,6 +9,7 @@ This directory contains all development-related documentation for Stirling PDF. - **[Taskfile.yml](../Taskfile.yml)** - Unified task runner for all build/dev/test/lint commands - **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices - **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide +- **[STORAGE_ENCRYPTION_AT_REST.md](./STORAGE_ENCRYPTION_AT_REST.md)** - Encryption at rest for stored files: key setup, migration, revocation, rotation ### Features & Documentation - **[AGENTS.md](./AGENTS.md)** - Agent-based functionality documentation diff --git a/devGuide/STORAGE_ENCRYPTION_AT_REST.md b/devGuide/STORAGE_ENCRYPTION_AT_REST.md new file mode 100644 index 0000000000..6ec8d047aa --- /dev/null +++ b/devGuide/STORAGE_ENCRYPTION_AT_REST.md @@ -0,0 +1,175 @@ +# Storage Encryption at Rest + +Encrypts files stored by Stirling (My Files, workflow files) so the bytes on disk, in the database, +or in S3 are unreadable without the master key. Requires a Pro or Enterprise licence to enable. + +> **Back up the master key.** Losing it makes every encrypted stored file permanently +> unrecoverable. There is no recovery path by design — that is what makes the encryption +> meaningful. + +> **Audit trail requires Enterprise.** Encryption itself works on Pro, but the audit events +> below (encrypt, decrypt, revocation, plaintext export, migration) are only recorded on an +> Enterprise licence — the audit subsystem is Enterprise-gated platform-wide. On Pro the files +> are encrypted exactly the same way, but there is no access trail, which matters if you are +> enabling this to satisfy an audit-logging requirement (HIPAA, CMMC). A warning is logged at +> startup when encryption is enabled without an Enterprise licence. + +## How it works + +Envelope encryption, three levels: + +| Level | What it is | Where it lives | +|---|---|---| +| Master key | Wraps the scope keys | Config property, env var, or `configs/file-encryption.key` | +| Scope key (KEK) | One per team; wraps each file's data key | `file_encryption_keys` table, master-key-wrapped | +| Data key (DEK) | One per stored blob; encrypts the bytes | Inside the blob's own header, scope-key-wrapped | + +Each blob is self-describing: an `SPDFEAR1` header carries the format version, the scope key's id, +the plaintext length, and the wrapped data key, followed by AES-256-GCM streaming ciphertext +(1 MiB segments). The header is bound as associated data to both the key wrap and the payload, so a +header cannot be transplanted between blobs. + +Consequences of that design worth knowing: + +- Blobs without the magic prefix are treated as plaintext and passed through, so enabling the + feature needs no migration and old files keep working. +- Because the key id is pinned per blob, moving a user between teams never breaks their existing + files. +- Plaintext sizes are what get recorded in the database, so quotas and `Content-Length` are + unaffected (ciphertext on disk is ~96 bytes + 16 bytes/MiB larger). +- Presigned S3 download URLs are suppressed once encrypted content can exist — a presigned GET + would hand raw ciphertext to the browser — so those downloads stream through the application. + +## Enabling it + +```yaml +storage: + encryption: + enabled: true +``` + +The master key is resolved in this order: + +1. `stirling.security.fileEncryptionKey` property +2. `STIRLING_FILE_ENCRYPTION_KEY` environment variable +3. an auto-generated `configs/file-encryption.key` (owner-only permissions) + +Generate a key with: + +```bash +openssl rand -base64 32 +``` + +It must decode to exactly 32 bytes; anything else fails at startup rather than silently +downgrading the cipher. The startup log prints a fingerprint (a SHA-256 prefix, never the key) so +you can verify a backup matches the live key. + +**Cluster mode** (`cluster.enabled=true`) requires the key to be set explicitly and identically on +every node; the auto-generated file is refused, because a node-local key would make files written +elsewhere unreadable. + +### Turning it off + +Disabling only stops encrypting *new* writes. Existing encrypted files stay readable as long as the +key material is present — the decrypt path is always active and is never licence-gated, so a lapsed +licence cannot lock you out of your own data. + +## Encrypting files that already exist + +Enabling the flag does not touch the existing plaintext backlog. To convert it: + +```bash +curl -X POST http://localhost:8080/api/v1/admin/storage-encryption/migrate +curl http://localhost:8080/api/v1/admin/storage-encryption/migrate/status +``` + +The job is throttled, resumable, and safe to re-run: for each file it writes the encrypted copy +under a new storage key, swaps the database row only if nothing else changed it, and deletes the old +blob last. If a user replaces a file mid-migration their copy wins and the job skips it. Progress is +in-memory, so a restart mid-run loses the counters and `migrate/status` reports `IDLE` again — just +start it again; already-encrypted files are skipped. There is currently no way to cancel a run, and +on a cluster the guard is per-node, so trigger the migration on one node only. + +## Revoking access (kill switch) + +Disabling a scope key makes every file already stored under it fail closed with `403` until it is +re-enabled: + +```bash +curl -X POST http://localhost:8080/api/v1/admin/storage-encryption/keys/{keyId}/disable +curl -X POST http://localhost:8080/api/v1/admin/storage-encryption/keys/{keyId}/enable +``` + +This revokes access to existing content; it does **not** stop the scope from storing new files. The +next upload finds no active key for the scope and mints one, so the team keeps working while its +history stays sealed. To stop new writes as well, turn encryption off (or take the scope's access +away at the application level) — the kill switch is aimed at stored bytes. + +Because of that, re-enabling is status-aware: the key returns to `ACTIVE` if its scope has no other +active key, and to `RETIRED` if one was minted while it was revoked. Both statuses decrypt existing +content; only `ACTIVE` wraps new writes, so a scope never ends up with two keys competing for new +uploads. The `enable` response reports which status was applied. + +This is reversible: the key material stays in the database and nothing is destroyed. No API path +deletes key material. On a cluster, other nodes pick the change up within their 60-second key-cache +window. + +## Rotating the master key + +Rotation only re-wraps the small `file_encryption_keys` table — file contents are never rewritten. + +1. Set the new key as `stirling.security.fileEncryptionKey`. +2. Keep the outgoing key in `stirling.security.fileEncryptionKeyPrevious`. +3. Bump `stirling.security.fileEncryptionKeyVersion`. +4. Restart. Startup warns about rows still wrapped by the previous key. On a cluster, wait until + **every** node carries both keys — a node still holding only the outgoing key cannot read a + re-wrapped row, so rotating mid-deploy makes the lagging nodes fail on those scopes until they + catch up. +5. `POST /api/v1/admin/storage-encryption/master/rotate`. +6. Confirm the response's `rewrapped` count and that `/status` shows every key row at the new + `masterKeyVersion`. +7. Remove `fileEncryptionKeyPrevious` and restart. + +**Do not skip step 6.** Until a row is re-wrapped it is still readable only with the outgoing key, so +removing that key while rows remain behind would seal the files under them. Startup verifies every +key row against the configured keys and refuses to start if any cannot be unwrapped, naming the count +and the first affected scope — so this shows up as a failed deploy, recoverable by putting the old key +back, rather than as unreadable files discovered later. Keep the outgoing key archived until a +restart has succeeded without it. + +Key material is never accepted over HTTP; the endpoint only performs the re-wrap step. + +## Auditing + +**Requires an Enterprise licence** (see the note at the top): on Pro these events are silently +dropped by the audit subsystem, and a warning is logged at startup. + +Encrypt, decrypt, denied-decrypt, key lifecycle, rotation, and migration events are written to the +audit trail, along with a `plaintextExport` marker whenever a plaintext copy of encrypted content is +served. Per-read decrypt events can be noisy on busy instances and can be turned off with +`storage.encryption.auditReads: false`; denials and key lifecycle events are always recorded. + +Two semantics worth knowing when reading the trail: + +- A `decrypt` event means a decryption was *authorised and opened*, not that bytes were read to + completion — a load that is discarded still records one, and a re-read of the same open resource + (e.g. an HTTP range request) does not record a second. +- `plaintextExport` is currently emitted for stored-file and share-link downloads. Workflow-file + downloads are not yet marked. + +## Status and backup verification + +```bash +curl http://localhost:8080/api/v1/admin/storage-encryption/status +``` + +Reports whether writes are encrypted, the master-key fingerprint, encrypted vs plaintext file +counts, and every key row with its status history. All endpoints under +`/api/v1/admin/storage-encryption` require an admin account. + +## What this protects against + +Stolen disks, database dumps, exposed object-storage buckets, decommissioned media, and platform +users who are not authorised for a file. It is not a defence against an attacker who already has +root on a running instance — at that point the key is in memory. No storage-level encryption product +claims otherwise. diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index ab79caf9f0..fd26ee2fb7 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application (backend only, no frontend) -FROM gradle:9.6.1-jdk25@sha256:934a520ae0cc1f46764c2e6e1f6510d2fcdf6a7e12328b6aee34192d14f171a2 AS app-build +FROM gradle:9.6.1-jdk25@sha256:e8aeffb8197b17151ce24607811f60e91125f0018f7a3b08dc504ba9168a9c4f AS app-build # JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ @@ -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 4bb8c43dca..95e4e84795 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -5,7 +5,7 @@ ARG TARGETPLATFORM # Stage 1: Build and strip Calibre -FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS calibre-build +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS calibre-build ARG TARGETPLATFORM ARG CALIBRE_VERSION=9.4.0 ARG CALIBRE_STRIP_WEBENGINE=false @@ -270,7 +270,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \ # Stage 2: Build Ghostscript from source -FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS gs-build +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS gs-build ARG TARGETPLATFORM ARG GS_VERSION=10.06.0 @@ -294,7 +294,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # Stage 3: Build PDF Tools (QPDF and ImageMagick 7) -FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS pdf-tools-build +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS pdf-tools-build ARG TARGETPLATFORM ARG QPDF_VERSION=12.3.2 ARG IM_VERSION=7.1.2-13 @@ -339,7 +339,7 @@ RUN mkdir -p /magick-export/usr/bin \ # Stage 4: Build Python venv -FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS python-venv-build +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS python-venv-build ARG TARGETPLATFORM ARG UNOSERVER_VERSION=3.6 @@ -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 4ae0ee81e7..d4ad422476 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.6.1-jdk25@sha256:934a520ae0cc1f46764c2e6e1f6510d2fcdf6a7e12328b6aee34192d14f171a2 AS app-build +FROM gradle:9.6.1-jdk25@sha256:e8aeffb8197b17151ce24607811f60e91125f0018f7a3b08dc504ba9168a9c4f AS app-build ARG TASK_VERSION=3.49.1 RUN apt-get update \ @@ -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 6e679b97c8..58eafec851 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -6,7 +6,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.6.1-jdk25@sha256:934a520ae0cc1f46764c2e6e1f6510d2fcdf6a7e12328b6aee34192d14f171a2 AS app-build +FROM gradle:9.6.1-jdk25@sha256:e8aeffb8197b17151ce24607811f60e91125f0018f7a3b08dc504ba9168a9c4f AS app-build ARG TASK_VERSION=3.49.1 RUN apt-get update \ @@ -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 14f9e934d0..d4a21b7812 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -2,7 +2,7 @@ # Single JAR contains both frontend and backend with minimal dependencies # Stage 1: Build application with embedded frontend -FROM gradle:9.6.1-jdk25@sha256:934a520ae0cc1f46764c2e6e1f6510d2fcdf6a7e12328b6aee34192d14f171a2 AS build +FROM gradle:9.6.1-jdk25@sha256:e8aeffb8197b17151ce24607811f60e91125f0018f7a3b08dc504ba9168a9c4f AS build # Install Node.js and npm for frontend build ARG TASK_VERSION=3.49.1 @@ -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/docker/unoserver/Dockerfile b/docker/unoserver/Dockerfile index 0bd9d1da0a..5bd887c8ed 100644 --- a/docker/unoserver/Dockerfile +++ b/docker/unoserver/Dockerfile @@ -1,7 +1,7 @@ # Standalone unoserver image for Stirling-PDF remote UNO mode. # Pinned to unoserver 3.6 to match Stirling-PDF's client (avoids 3.4/3.6 wire mismatch). -FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea ARG UNOSERVER_VERSION=3.6 # ~120 MB of CJK fonts — opt-in. 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/.gitignore b/frontend/.gitignore index c0c467073d..9ab1c65091 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -49,3 +49,4 @@ test-results /scripts/dev-update-test/screenshots/ /editor/src-tauri/tauri.conf.dev-update.json .a11y-scan/ +.a11y-acc/ diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json index fb461fe6d2..0967ef424b 100644 --- a/frontend/.storybook/a11y-baseline.dark.json +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -1,2482 +1 @@ -{ - "editor/src/core/assets/Brand.stories.tsx :: Logos": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ - "aria-required-children" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Disabled": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: No Appearance Set": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ - "button-name" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ - "aria-dialog-name", - "aria-progressbar-name" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: With Expression": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Empty Input": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ - "label-title-only" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ - "button-name" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ - "button-name" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileUploadButton.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileUploadButton.stories.tsx :: With File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ - "color-contrast" - ], - "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "svg-img-alt" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ - "color-contrast" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ObscuredOverlay.stories.tsx :: Unobscured": [ - "color-contrast" - ], - "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Compact Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Empty": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Enforcing": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/toast/ToastRenderer.stories.tsx :: With Action Button": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: With Selected Image": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Embedded Hide Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Custom Placeholder": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Jks": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Pkcs 12": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ - "button-name" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ - "color-contrast" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ - "label" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: With Words": [ - "color-contrast" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ - "button-name" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ - "label" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Dropdown Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Unstyled": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ - "color-contrast" - ], - "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], - "editor/src/core/tokens/Tokens.stories.tsx :: Typography": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Justify": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], - "editor/src/core/ui/Card.stories.tsx :: Accent Matrix": ["color-contrast"], - "editor/src/core/ui/Card.stories.tsx :: In Context Metrics Inside Card": [ - "color-contrast" - ], - "editor/src/core/ui/Card.stories.tsx :: In Context Product Grid": [ - "color-contrast" - ], - "editor/src/core/ui/Card.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ - "button-name" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], - "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ - "color-contrast" - ], - "editor/src/core/ui/Chip.stories.tsx :: Playground": [ - "color-contrast", - "nested-interactive" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/DataRow.stories.tsx :: Single": ["color-contrast"], - "editor/src/core/ui/DataRow.stories.tsx :: Summary": ["color-contrast"], - "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Dropdown.stories.tsx :: Align Start": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: With Divider": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: With Trailing Hints": [ - "color-contrast" - ], - "editor/src/core/ui/EmptyState.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/EmptyState.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Single": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Free Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ - "color-contrast" - ], - "editor/src/core/ui/NavItem.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: With Trailing Badge": [ - "color-contrast" - ], - "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/SectionDivider.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/ui/SectionHeader.stories.tsx :: Collapsible": [ - "color-contrast" - ], - "editor/src/core/ui/SectionHeader.stories.tsx :: Static": ["color-contrast"], - "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ - "color-contrast" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ - "color-contrast" - ], - "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": [ - "color-contrast", - "label" - ], - "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Stack.stories.tsx :: Gap Sizes": ["color-contrast"], - "editor/src/core/ui/Stack.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Empty": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], - "editor/src/core/ui/ToggleSwitch.stories.tsx :: In Context Settings Rows": [ - "color-contrast" - ], - "editor/src/core/ui/ToggleSwitch.stories.tsx :: With Description": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ - "color-contrast" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ - "aria-hidden-focus", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/DownloadEditorModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/LinkAccountFooterItem.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Reauth": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ - "color-contrast" - ], - "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Bare": [ - "color-contrast" - ], - "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: Managed In Stripe": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: With Card": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Unsynced Only": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Breakdown": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Unsynced": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Offer Nudge": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Three": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Two": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: No Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ - "color-contrast" - ], - "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Unlocked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentOverview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Available": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Multiple Selected": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Nothing Selected": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Incompatible Preceding Output": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: No Matches": [ - "color-contrast" - ], - "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Pay": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Request Paid": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Sign": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Upload PO": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Schedule Call": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage Maxed": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment Pending": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Delegated Create": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Load Error": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Preset Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Ready": [ - "color-contrast" - ], - "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Choose Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ - "color-contrast" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ - "color-contrast" - ], - "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ - "color-contrast" - ], - "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ - "color-contrast" - ], - "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Sources And Destinations": [ - "color-contrast" - ], - "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ - "color-contrast" - ], - "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ - "color-contrast", - "landmark-unique" - ], - "editor/src/portal/views/Documents.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Integrations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ - "color-contrast" - ], - "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: First Time Setup": [ - "color-contrast" - ], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ - "aria-hidden-focus" - ], - "editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Subcategory": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Custom Return Url": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ - "image-redundant-alt" - ] -} +{} diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 0245e3101a..0967ef424b 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1,2640 +1 @@ -{ - "editor/src/core/assets/Brand.stories.tsx :: Logos": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Multiple Files": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Compact": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ - "aria-required-children" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Shared With Me": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ - "aria-dialog-name", - "aria-progressbar-name" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Small": [ - "color-contrast" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ - "color-contrast", - "label-title-only" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Custom Fallback": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Custom Placeholder": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "svg-img-alt" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/OverviewHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Large Size": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Minimal Links": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Frontend": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Encrypted": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Multiple Files Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: With Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: With Tool Name": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/ToolRenderer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Text Stamp With Preview": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Invisible": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ - "button-name" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ - "color-contrast" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ - "aria-input-field-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Azw 3 Output": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Custom Area": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx :: Automation Info": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Custom Render Dpi": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Flatten Only Forms": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: No Data": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: All Passed": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Hidden Title": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Custom Empty Message": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx :: Invalid Input": [ - "color-contrast" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: All Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Unknown Type": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Last File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NoToolsFound.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ - "label" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: With Help Text And Number": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Admin With Shared Delete": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: At Capacity": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Poster": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Sections": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: No Method Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Empty Value": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Missing Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Unsupported File": [ - "color-contrast" - ], - "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Csv": [ - "color-contrast" - ], - "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Hidden": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Tsv": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx :: Invalid Json": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ - "color-contrast" - ], - "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ - "button-name" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], - "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ - "color-contrast" - ], - "editor/src/core/ui/Chip.stories.tsx :: Playground": [ - "color-contrast", - "nested-interactive" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Space Between": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ - "color-contrast" - ], - "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ - "color-contrast" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ - "color-contrast" - ], - "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": ["label"], - "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Live": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], - "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ - "color-contrast" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ - "aria-hidden-focus", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ - "color-contrast" - ], - "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Without Lead": [ - "color-contrast" - ], - "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ - "color-contrast" - ], - "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ - "empty-table-header" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ - "empty-table-header" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ - "color-contrast" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ - "color-contrast" - ], - "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ - "color-contrast" - ], - "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ - "color-contrast" - ], - "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], - "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ - "color-contrast" - ], - "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ - "color-contrast", - "landmark-unique" - ], - "editor/src/portal/views/Documents.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Integrations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ - "color-contrast" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ - "aria-hidden-focus" - ], - "editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx :: With Errors": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ - "label" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Sso User": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Load Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: With File Metadata Columns": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Enterprise Plan": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Free Plan": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise With Total": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Simple": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Current": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Popular": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Redirecting": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Completed": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Expired": [ - "color-contrast" - ], - "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ - "image-redundant-alt" - ] -} +{} diff --git a/frontend/.storybook/a11y-changed.mjs b/frontend/.storybook/a11y-changed.mjs index 5ff7a9e633..f6daef5dbb 100644 --- a/frontend/.storybook/a11y-changed.mjs +++ b/frontend/.storybook/a11y-changed.mjs @@ -10,7 +10,8 @@ // embedded interpreter runs on Windows, but sed/grep/sort do not exist for // developers calling tasks from PowerShell. import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { readdirSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; const base = process.argv[2] || "origin/main"; @@ -40,9 +41,25 @@ for (const f of changed) { continue; } if (TEST.test(f) || !SOURCE.test(f)) continue; - const sibling = f.replace(SOURCE, ""); - for (const s of [`${sibling}.stories.tsx`, `${sibling}.stories.ts`]) - if (existsSync(s)) stories.add(s); + // A story file does not have to match its source's case — tokens.css sits + // beside Tokens.stories.tsx. Deriving the name from the source and trusting + // existsSync silently skips those on a case-sensitive filesystem, and on a + // case-insensitive one feeds the scan a path no result will ever match. Read + // the directory instead and compare case-insensitively, then use the name as + // it is actually spelled on disk. + const dir = dirname(f) || "."; + const stem = basename(f).replace(SOURCE, "").toLowerCase(); + let entries; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!STORY.test(entry)) continue; + if (entry.replace(STORY, "").toLowerCase() !== stem) continue; + stories.add(join(dir, entry).split("\\").join("/")); + } } // One line, each path quoted: the output is interpolated into a task command, 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/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index e9d7e7f3d9..f568b34347 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -19,6 +19,11 @@ import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; +import { MantineProvider } from "@mantine/core"; +import { + mantineTheme as editorMantineTheme, + editorCssVariablesResolver, +} from "@core/theme/mantineTheme"; import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; import i18next from "i18next"; @@ -199,6 +204,37 @@ const withLocale: Decorator = (Story, context) => { return ; }; +/** + * Applies the Mantine theme the story's component actually runs under in the + * app: PortalApp wraps the Processor in SuiProvider, while the editor wraps + * everything else in its own ThemeProvider. Getting this wrong is not just + * cosmetic — the two themes carry different neutral ramps, so rendering an + * editor component under the Processor's theme drops it onto Mantine's stock + * greys and reports contrast failures the app doesn't have. + */ +function StoryTheme({ + isPortalStory, + colorScheme, + children, +}: { + isPortalStory: boolean; + colorScheme: "light" | "dark"; + children: React.ReactNode; +}) { + if (isPortalStory) { + return {children}; + } + return ( + + {children} + + ); +} + const withProviders: Decorator = (Story, context) => { const tier = (context.globals.tier as Tier) ?? "pro"; const linkState = @@ -214,16 +250,21 @@ const withProviders: Decorator = (Story, context) => { // the portal's base.css keys its reset/typography on. Give portal stories // the same wrapper (and only them — the scoping exists precisely so portal // styles never apply to editor components). - const isPortalStory = (context.parameters.fileName ?? "").includes( - "/portal/", - ); + // `fileName` is only injected by the dev/build pipeline — under the Vitest + // runner it is absent, so path alone would silently drop every portal story + // onto the editor theme (where portal-only palette entries like `amber` + // resolve to nothing and render unstyled). The title prefix is the fallback + // that survives both environments. + const isPortalStory = + (context.parameters.fileName ?? "").includes("/portal/") || + context.title.startsWith("Portal/"); return ( - + {/* LinkProvider must wrap TierProvider: TierContext derives its tier from useLink() (matches App.tsx's nesting). */} @@ -241,7 +282,7 @@ const withProviders: Decorator = (Story, context) => { - + @@ -274,6 +315,14 @@ const preview: Preview = { // any violation. Context is left at the addon default (the document root) // so it resolves under both the Storybook UI and the Vitest browser mount. test: "error", + context: { + // Nodes carrying this attribute render a facsimile of the user's own + // document — their stamp text, their watermark, in the colour and + // opacity they chose. WCAG contrast governs the interface, not the + // content authored through it, and the controls that set those values + // are checked normally. + exclude: ["[data-user-content-preview]"], + }, }, }, globalTypes: { 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 new file mode 100644 index 0000000000..55c165df4b Binary files /dev/null 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 new file mode 100644 index 0000000000..1b043a72ec Binary files /dev/null and b/frontend/editor/public/android-chrome-512x512.png differ diff --git a/frontend/editor/public/favicon.png b/frontend/editor/public/favicon.png new file mode 100644 index 0000000000..1b043a72ec Binary files /dev/null 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 ae494e9582..bdf6a739ed 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3001,12 +3001,15 @@ tooltip = "Runs in the cloud (included, no extra charge)" tooltip = "Pick colour from screen" [colorPicker] +hue = "Hue" +saturation = "Saturation and brightness" title = "Choose colour" [common] back = "Back" cancel = "Cancel" close = "Close" +codeSample = "Code sample" collapse = "Collapse" confirm = "Confirm" continue = "Continue" @@ -3023,6 +3026,7 @@ refresh = "Refresh" remaining = "Remaining" retry = "Retry" save = "Save" +stepOf = "Step {{current}} of {{total}}" [compare] clearSelected = "Clear selected" @@ -3185,6 +3189,7 @@ title = "Compression Method" [compress.settings] desiredSize = "Desired File Size" desiredSizePlaceholder = "Enter size" +desiredSizeUnit = "Size unit" [compress.tooltip.description] text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." @@ -3824,11 +3829,13 @@ shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" +sortBy = "Sort files" sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" storage = "Storage" storageState = "Storage" +storageUsed = "Storage used" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" @@ -3888,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] @@ -3918,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" @@ -3983,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" @@ -4044,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" @@ -4113,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" @@ -4168,6 +4179,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first." open = "Open" openFile = "Open File" openFiles = "Open Files" +selectFile = "Select {{name}}" selectFromStorage = "Select from Storage" upload = "Upload" uploadFile = "Upload File" @@ -4997,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" @@ -5127,6 +5136,7 @@ activeFiles = "The Active Files view shows all of the PDFs you allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." close = "Close" cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to." +dialogLabel = "Onboarding" fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." @@ -5563,6 +5573,7 @@ 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" metaCategories = "Automation Ā· AI Ā· API requests" @@ -5642,6 +5653,7 @@ automation = "automations" default = "this feature" [payg.spendCapMeter] +barAria = "Spend against cap" capSuffix = "/ {{amount}} cap" metaCategories = "Automation Ā· AI Ā· API spend" resets = "Resets each billing period" @@ -6045,6 +6057,7 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] +currency = "Billing currency" subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -6280,6 +6293,7 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] +actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -6624,6 +6638,7 @@ 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" eyebrow = "Processor trial" @@ -7077,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." @@ -7184,6 +7194,7 @@ sensitiveTitle = "Sensitive — access required" [portal.documents.table.columns] action = "Pipeline / Action" +actions = "Actions" document = "Document" product = "Product" status = "Status" @@ -7481,6 +7492,7 @@ rolledBack = "Rolled back" rolling = "Rolling out" [portal.infrastructure.deployments] +loadAria = "Load for {{name}}" msValue = "{{value}} ms" throughputValue = "{{value}}/min" @@ -7526,6 +7538,7 @@ 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." @@ -7892,6 +7905,7 @@ paused = "Paused" [portal.pipelines.table] name = "Pipeline" +open = "Open" sources = "Sources" status = "Status" steps = "Steps" @@ -8683,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" @@ -8783,6 +8787,7 @@ unused = "Unused" [portal.sources.table] documents = "Documents" +open = "Open" source = "Source" status = "Status" usedBy = "Policies" @@ -9980,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" @@ -10625,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" @@ -10774,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" @@ -11646,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" @@ -11666,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 58e667e4e9..01db314031 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -1879,6 +1879,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" @@ -3005,12 +3008,16 @@ tooltip = "This operation will use your cloud credits" tooltip = "Pick color from screen" [colorPicker] +hue = "Hue" +saturation = "Saturation and brightness" title = "Choose color" [common] +actions = "Actions" back = "Back" cancel = "Cancel" close = "Close" +codeSample = "Code sample" collapse = "Collapse" confirm = "Confirm" continue = "Continue" @@ -3022,12 +3029,14 @@ error = "Error" expand = "Expand" loading = "Loading..." next = "Next" +open = "Open" preview = "Preview" previous = "Previous" refresh = "Refresh" remaining = "Remaining" retry = "Retry" save = "Save" +stepOf = "Step {{current}} of {{total}}" [compare] clearSelected = "Clear selected" @@ -3190,6 +3199,7 @@ title = "Compression Method" [compress.settings] desiredSize = "Desired File Size" desiredSizePlaceholder = "Enter size" +desiredSizeUnit = "Size unit" [compress.tooltip.description] text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." @@ -3833,11 +3843,13 @@ shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" +sortBy = "Sort files" sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" storage = "Storage" storageState = "Storage" +storageUsed = "Storage used" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" @@ -3883,6 +3895,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" @@ -3897,12 +3911,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" @@ -3927,7 +3941,6 @@ backToMyFiles = "Back to My Files" breadcrumbs = "Folder path" cancel = "Cancel" classification = "Classification" -clearSearch = "Clear search" clearSelection = "Clear selection" closeDetails = "Close details" create = "Create" @@ -3992,8 +4005,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" @@ -4053,6 +4064,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" @@ -4122,6 +4137,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" @@ -4177,6 +4197,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first." open = "Open" openFile = "Open File" openFiles = "Open Files" +selectFile = "Select {{name}}" selectFromStorage = "Select from Storage" upload = "Upload" uploadFile = "Upload File" @@ -4809,9 +4830,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" @@ -4993,6 +5011,34 @@ uploadSuccess = "Upload Successful!" uploadSuccessMessage = "Your images have been transferred." validating = "Validating session..." +[mobileSign] +clear = "Clear" +invalidSession = "Session expired" +invalidSessionMessage = "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code." +penSizeLabel = "Pen size" +send = "Send to computer" +sendAnother = "Send another signature" +sendError = "Could not send the signature. Check the connection and try again." +sentMessage = "Signature sent to your computer. You can send another or close this page." +tabsLabel = "Signature source" +undo = "Undo" +validating = "Checking session…" + +[mobileSign.photo] +fromGallery = "From gallery" +hint = "Photograph a signature on white paper, or choose an existing image." +invalidType = "Please choose an image file." +takePhoto = "Take a photo" + +[mobileSign.tab] +draw = "Draw" +photo = "Photo" +type = "Type" + +[mobileSign.type] +placeholder = "Your name" +previewPlaceholder = "Signature preview" + [mobileUpload] description = "Scan to upload photos. Images auto-convert to PDF." descriptionNoConvert = "Scan to upload photos from your mobile device." @@ -5011,8 +5057,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." @@ -5141,6 +5192,7 @@ activeFiles = "The Active Files view shows all of the PDFs you allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." close = "Close" cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to." +dialogLabel = "Onboarding" fileCheckbox = "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." @@ -5577,7 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man freeTitle = "Unlimited PDF editing" [payg.free.hero] -capSuffix = "/ {{limit}} free PDFs" +barAria = "Free PDFs remaining" +capSuffix = "of {{limit}} free PDFs left" metaCategories = "Automation Ā· AI Ā· API requests" [payg.free.member] @@ -5656,6 +5709,7 @@ automation = "automations" default = "this feature" [payg.spendCapMeter] +barAria = "Spend against cap" capSuffix = "/ {{amount}} cap" metaCategories = "Automation Ā· AI Ā· API spend" resets = "Resets each billing period" @@ -6059,6 +6113,7 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] +currency = "Billing currency" subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -6638,11 +6693,12 @@ reachedTitle = "Monthly spend limit reached" title = "Couldn't open Stripe portal" [portal.billing.walletMeter] -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" @@ -6887,6 +6943,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." @@ -7091,11 +7173,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." @@ -7194,12 +7271,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" @@ -7215,6 +7291,7 @@ host = "Host" lastSeen = "Last seen" region = "Region" status = "Status" +target = "Target" version = "Version" [portal.editorAdmin.health.empty] @@ -7290,9 +7367,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] @@ -7301,6 +7380,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." @@ -7317,6 +7404,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" @@ -7402,7 +7494,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] @@ -7430,11 +7522,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" @@ -7506,11 +7593,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" @@ -7524,238 +7606,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] -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" -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" @@ -7765,11 +7619,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" @@ -7787,6 +7639,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" @@ -7817,7 +7673,6 @@ integrations = "Integrations" pipelines = "Pipelines" policies = "Policies" procurement = "Procurement" -settings = "Settings" sources = "Sources" usage = "Usage & Billing" users = "Users" @@ -7834,6 +7689,7 @@ title = "Pipelines" newPipeline = "New pipeline" [portal.pipelines.builder] +activate = "Activate" back = "Back to pipelines" cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" @@ -7841,7 +7697,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" @@ -7850,20 +7705,31 @@ moreActions = "More actions" needsConfiguring = "Needs setting up" 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}}." stepsNeedSetup = "These steps still need setting up before saving: {{tools}}." +supportingFiles = "Supporting files" testRun = "Test with a file" unknownStep = "Unrecognized operation, kept as-is." unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?" unsavedTitle = "Unsaved changes" -uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}." 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" + [portal.pipelines.builder.diagnostic] fan-in = "Combines every incoming file" fan-out = "Runs once per incoming file" @@ -7874,12 +7740,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" @@ -7911,7 +7777,7 @@ confirm = "Delete" title = "Delete pipeline?" [portal.pipelines.detail] -clearHistory = "Clear history" +clearHistory = "Process ignored files in source" delete = "Delete pipeline" run = "Run now" @@ -7964,10 +7830,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." @@ -7977,10 +7842,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" @@ -8295,6 +8160,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" @@ -8769,16 +8652,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" @@ -8829,10 +8702,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" @@ -8869,9 +8738,9 @@ unused = "Unused" [portal.sources.table] documents = "Documents" -open = "Open" source = "Source" status = "Status" +type = "Type" usedBy = "Policies" [portal.sources.types.box] @@ -10067,9 +9936,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" @@ -10254,6 +10120,7 @@ backgroundRemovalFailedTitle = "Background removal failed" hint = "Upload a PNG or JPG image of your signature" label = "Upload signature image" placeholder = "Select image file" +previewAlt = "Current image signature" processing = "Processing image..." removeBackground = "Remove white background (make transparent)" @@ -10267,6 +10134,17 @@ saved = "Select a saved signature above, then click anywhere on the PDF to place text = "After entering your name above, click anywhere on the PDF to place your signature." title = "How to add signature" +[sign.mobile] +createFromPhone = "Mobile upload" +description = "Scan this QR code with your phone or tablet, draw your signature, and it will appear here automatically." +error = "Connection Error" +expiryWarning = "QR Code Expiring Soon" +expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically." +instructions = "Open the camera app on your phone and scan this code. Keep this window open while you draw." +pollingError = "Error checking for the signature" +sessionCreateError = "Failed to create session" +title = "Draw on your phone" + [sign.mode] move = "Move Signature" pause = "Pause placement" @@ -10713,6 +10591,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" @@ -10862,7 +10757,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" @@ -11070,6 +10964,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" @@ -11089,10 +10990,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" @@ -11132,13 +11031,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" @@ -11328,12 +11229,15 @@ searchPlaceholder = "Search attachments" title = "Attachments" [viewer.bookmarks] +addBookmark = "Add bookmark" bookmarkTitle = "Bookmark title" bookmarkTitleRequired = "Bookmark title is required" closeSidebar = "Close bookmarks sidebar" collapseAll = "Collapse all bookmarks" +empty = "No bookmarks in this document" expandAll = "Expand all bookmarks" searchPlaceholder = "Search bookmarks" +title = "Bookmarks" [viewer.comments] addComment = "Add comment" @@ -11355,10 +11259,12 @@ locateAnnotation = "Locate in document" moreActions = "More actions" nComments_one = "{{count}} comment" nComments_other = "{{count}} comments" +noMatch = "No comments match your search" pageLabel = "Page {{page}}" placingHint = "Click a page to place… (cancel)" removeCommentOnly = "Remove comment only" saveReply = "Save reply" +searchPlaceholder = "Search comments" title = "Comments" typeComment = "Comment" typeInsertText = "Insert Text" @@ -11381,6 +11287,7 @@ unsavedDesc = "You have unsaved changes" closeSidebar = "Close layers sidebar" hideAll = "Hide all layers" showAll = "Show all layers" +title = "Layers" [viewer.link] delete = "Delete link" @@ -11737,6 +11644,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" @@ -11757,6 +11665,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 new file mode 100644 index 0000000000..3c47163f60 Binary files /dev/null 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 new file mode 100644 index 0000000000..1833c9340f Binary files /dev/null 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 new file mode 100644 index 0000000000..e5ad21a345 Binary files /dev/null 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 new file mode 100644 index 0000000000..044a032aaf Binary files /dev/null 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 new file mode 100644 index 0000000000..23f118d9f1 Binary files /dev/null and b/frontend/editor/public/mstile-70x70.png differ diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index e975af703b..f9733bd83f 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -16,7 +16,7 @@ "description": "Adds signature to PDF by drawing, text or image" }, "sharedSign": { - "image": "/og_images/home.png", + "image": "/og_images/shared-sign.png", "title": "Shared Signing - Stirling PDF", "description": "Request signatures from others and track signing sessions" }, @@ -305,6 +305,11 @@ "title": "Air-gapped Setup - Stirling PDF", "description": "Link to air-gapped setup guide" }, + "/editor": { + "image": "/og_images/home.png", + "title": "Editor - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "/login": { "image": "/og_images/home.png", "title": "Sign In - Stirling PDF", @@ -645,6 +650,7 @@ "/overlay-pdf": "overlayPdfs", "/split-pdf-by-sections": "split", "/split-pdf-by-chapters": "split", + "/editor": "/editor", "/login": "/login", "/mobile-scanner": "/mobile-scanner", "/files": "/files", diff --git a/frontend/editor/public/og-metadata.saas.json b/frontend/editor/public/og-metadata.saas.json index 3e7151469c..be2e7a3e92 100644 --- a/frontend/editor/public/og-metadata.saas.json +++ b/frontend/editor/public/og-metadata.saas.json @@ -17,7 +17,7 @@ "description": "Adds signature to PDF by drawing, text or image" }, "sharedSign": { - "image": "/og_images/home.png", + "image": "/og_images/shared-sign.png", "title": "Shared Signing - Stirling PDF", "description": "Request signatures from others and track signing sessions" }, @@ -306,6 +306,12 @@ "title": "Air-gapped Setup - Stirling PDF", "description": "Link to air-gapped setup guide" }, + "/editor": { + "image": "/og_images/saas/app-editor.png", + "title": "Stirling - The world's most secure PDF editor", + "ogTitle": "The world's most secure PDF editor", + "description": "Edit, sign, redact, and convert PDFs in your browser. Free forever, open source, and self-hostable." + }, "/login": { "image": "/og_images/home.png", "title": "Sign In - Stirling PDF", @@ -541,12 +547,6 @@ "title": "Stirling Processor - Govern every PDF your organization touches", "ogTitle": "Govern every PDF your organization touches", "description": "Redaction, retention, and encryption policies enforced everywhere PDFs enter your org. Distribute the free Editor anywhere. 1Ā¢ per PDF." - }, - "/editor": { - "image": "/og_images/saas/app-editor.png", - "title": "Stirling - The world's most secure PDF editor", - "ogTitle": "The world's most secure PDF editor", - "description": "Edit, sign, redact, and convert PDFs in your browser. Free forever, open source, and self-hostable." } }, "byPath": { @@ -663,6 +663,7 @@ "/overlay-pdf": "overlayPdfs", "/split-pdf-by-sections": "split", "/split-pdf-by-chapters": "split", + "/editor": "/editor", "/login": "/login", "/mobile-scanner": "/mobile-scanner", "/files": "/files", @@ -709,7 +710,6 @@ "/settings/payg": "/settings/payg", "/settings/account-link": "/settings/account-link", "/signup": "/signup", - "/processor": "/processor", - "/editor": "/editor" + "/processor": "/processor" } } 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 new file mode 100644 index 0000000000..85c1558494 Binary files /dev/null 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 new file mode 100644 index 0000000000..9a7f46f2f6 --- /dev/null +++ b/frontend/editor/public/safari-pinned-tab.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/editor/scripts/generate-og-metadata.mjs b/frontend/editor/scripts/generate-og-metadata.mjs index fe928850f9..942bd9767e 100644 --- a/frontend/editor/scripts/generate-og-metadata.mjs +++ b/frontend/editor/scripts/generate-og-metadata.mjs @@ -180,6 +180,8 @@ const humanizeLabel = (s) => .replace(/\b\w/g, (c) => c.toUpperCase()); const pageTitles = { + // The editor's own URL ("/" only routes, by role). + "/editor": "Editor", "/login": "Sign In", "/mobile-scanner": "Mobile Scanner", "/files": "Files", diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index d5990096af..38eb7fe7b6 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -28,10 +28,11 @@ const ALLOWED_PATH_PREFIXES = [ "/api/v1/integration/", ]; -// File plumbing, not user parameters: `fileInput` is the uploaded document and -// `fileId` a server-side handle. Stripped from every generated request model. -// Named file fields (stampImage, attachments, ...) are real parameters and kept. -const BASE_FILE_FIELDS = new Set(["fileInput", "fileId"]); +// File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document +// (endpoints use one name or the other - `file` is never a second, supporting upload) and `fileId` +// a server-side handle. Stripped from every generated request model. Named supporting-file fields +// (stampImage, attachments, ...) are real parameters and kept. +const BASE_FILE_FIELDS = new Set(["fileInput", "file", "fileId"]); // The shared "upload a file or provide a file ID" wrapper schema and its two // branches. An endpoint whose body is exactly this has no parameters, so it must @@ -73,6 +74,19 @@ function isObject(value: unknown): value is Json { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** A single file upload: `type: string, format: binary` (a Java MultipartFile param). */ +function isBinaryField(schema: unknown): schema is Json { + return ( + isObject(schema) && schema.type === "string" && schema.format === "binary" + ); +} + +/** A multi file upload: an array of binary items (some specs also flag the array itself binary). */ +function isBinaryArrayField(schema: unknown): schema is Json { + if (!isObject(schema) || schema.type !== "array") return false; + return schema.format === "binary" || isBinaryField(schema.items); +} + /** * Recursively sort object keys so the output is byte-stable regardless of the * key ordering springdoc happens to emit. @@ -358,6 +372,9 @@ async function main(): Promise { const usedClassNames = new Set(); const pendingComponents = new Set(); const skipped: string[] = []; + // Named file fields (as File uploads) per model, so a caller can tell a file param from a scalar + // string param - which `format: binary` -> `string` would otherwise erase. + const fileFieldsByClass: Record = {}; for (const path of Object.keys(paths).sort()) { if ( @@ -408,7 +425,32 @@ async function main(): Promise { const query = queryParameters(pathItem); // Body wins over query on a name collision. const properties: Json = { ...query.props, ...bodyProps }; + // `file` is stripped as a primary-document alias (see BASE_FILE_FIELDS). That only holds while + // no endpoint uses `file` as a *supporting* upload beside a primary `fileInput`; if one ever + // does, blanket-stripping would silently drop it. Fail generation so the assumption is fixed + // here rather than shipping a lost file. + if ("file" in properties && "fileInput" in properties) { + throw new Error( + `${path} has both 'fileInput' and 'file' uploads. 'file' is stripped as a primary-document` + + " alias, which would drop it as a supporting file. Rename the supporting param or revise" + + " BASE_FILE_FIELDS handling in this generator.", + ); + } for (const field of BASE_FILE_FIELDS) delete properties[field]; + // Type each named file upload as File/File[] (not the `string` a binary format yields) via + // json-schema-to-typescript's `tsType` override, and record it. Base file fields are already + // stripped, so what remains is the real supporting-file params. + const fileFields: string[] = []; + for (const [name, prop] of Object.entries(properties)) { + if (isBinaryField(prop)) { + prop.tsType = "File"; + fileFields.push(name); + } else if (isBinaryArrayField(prop)) { + prop.tsType = "File[]"; + fileFields.push(name); + } + } + fileFieldsByClass[className] = fileFields; modelSchema.properties = properties; const required = new Set(computeRequired(modelSchema, properties)); for (const name of query.required) { @@ -464,6 +506,7 @@ async function main(): Promise { await compileAndWrite( tools, definitions, + fileFieldsByClass, outputPath, values.check ?? false, skipped, @@ -473,6 +516,7 @@ async function main(): Promise { async function compileAndWrite( tools: DiscoveredTool[], definitions: Record, + fileFieldsByClass: Record, outputPath: string, check: boolean, skipped: string[], @@ -525,6 +569,15 @@ async function compileAndWrite( const endpointList = tools .map((t) => ` ${JSON.stringify(t.path)},`) .join("\n"); + // Endpoints that take supporting files, mapped to those file params' names. Only endpoints with at + // least one are listed, so membership answers "does this tool take extra files". + const fileFieldEntries = tools + .filter((t) => (fileFieldsByClass[t.className] ?? []).length > 0) + .map( + (t) => + ` ${JSON.stringify(t.path)}: ${JSON.stringify(fileFieldsByClass[t.className])},`, + ) + .join("\n"); const footer = [ "/** Endpoint path for a generated tool operation (the operation identity across languages). */", @@ -536,6 +589,9 @@ async function compileAndWrite( "/** Every generated tool endpoint, for iteration. */", `export const TOOL_ENDPOINTS = [\n${endpointList}\n] as const satisfies readonly ToolEndpoint[];`, "", + "/** The supporting-file parameters each endpoint accepts beyond its primary fileInput, by name. */", + `export const TOOL_FILE_FIELDS = {\n${fileFieldEntries}\n} as const satisfies Partial<\n Record\n>;`, + "", "/** Union of every generated tool request model. */", `export type ToolApiRequest = ToolApiParams[ToolEndpoint];`, ].join("\n"); diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 22a3f4dfe8..97b32ef208 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -17,7 +17,7 @@ // // Structural black / white / transparent (shadows, scrims) are always allowed. -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { execSync } from "node:child_process"; import { relative, resolve, join } from "node:path"; @@ -566,12 +566,23 @@ function reportToneContrast() { // App-wide guard that source CSS routes every colour through the palette. File // list from `git ls-files` (a VCS query, never a directory walk feeding a read). // primitives.css (the literal home) and generated output.css are exempt. -function checkAppCss() { - const EXEMPT = /(?:^|\/)(?:primitives\.css|output\.css)$/; - const listed = execSync("git ls-files -- editor/src", { encoding: "utf8" }) +/** + * Tracked source files that are actually present. `git ls-files` is the index + * view, so it still lists files deleted in the working tree - reading one of + * those throws ENOENT and takes the whole lint down with it. + */ +function trackedFiles() { + return execSync("git ls-files -- editor/src", { encoding: "utf8" }) .split("\n") .map((l) => l.trim()) - .filter((l) => l && l.endsWith(".css") && !EXEMPT.test(l)); + .filter((l) => l && existsSync(l)); +} + +function checkAppCss() { + const EXEMPT = /(?:^|\/)(?:primitives\.css|output\.css)$/; + const listed = trackedFiles().filter( + (l) => l.endsWith(".css") && !EXEMPT.test(l), + ); const violations = []; const lineOf = (text, index) => text.slice(0, index).split("\n").length; @@ -631,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\//, @@ -675,13 +685,9 @@ function codeRgbIsColour(inner) { return k !== "0,0,0" && k !== "255,255,255"; } function checkCodeColors() { - const files = execSync("git ls-files -- editor/src", { encoding: "utf8" }) - .split("\n") - .map((l) => l.trim()) - .filter( - (l) => - /\.(ts|tsx)$/.test(l) && !CODE_EXEMPT_PATH.some((re) => re.test(l)), - ); + const files = trackedFiles().filter( + (l) => /\.(ts|tsx)$/.test(l) && !CODE_EXEMPT_PATH.some((re) => re.test(l)), + ); const violations = []; for (const rel of files) { const raw = readFileSync(rel, "utf8"); @@ -715,10 +721,7 @@ function checkCodeColors() { // reference has a definition somewhere (any source .css/.ts/.tsx) or a fallback. // Runtime-injected families (--user-*, --mantine-*, --accent-*) are out of scope. function checkTokenResolution() { - const files = execSync("git ls-files -- editor/src", { encoding: "utf8" }) - .split("\n") - .map((l) => l.trim()) - .filter((l) => /\.(css|ts|tsx)$/.test(l)); + const files = trackedFiles().filter((l) => /\.(css|ts|tsx)$/.test(l)); const DEF_RE = /(--[a-z0-9-]+)\s*:/gi; // Capture the token and the char that follows it (`,` ⇒ has a fallback). const REF_RE = /var\(\s*(--[a-z0-9-]+)\s*(,|\))/gi; @@ -750,14 +753,11 @@ const PRIMITIVE_LAYER = [ /^editor\/src\/core\/ui\/accents\.css$/, ]; function checkNoPrimitives() { - const files = execSync("git ls-files -- editor/src", { encoding: "utf8" }) - .split("\n") - .map((l) => l.trim()) - .filter( - (l) => - /\.(css|scss|ts|tsx)$/.test(l) && - !PRIMITIVE_LAYER.some((re) => re.test(l)), - ); + const files = trackedFiles().filter( + (l) => + /\.(css|scss|ts|tsx)$/.test(l) && + !PRIMITIVE_LAYER.some((re) => re.test(l)), + ); const REF = /var\(\s*(--p-[a-z0-9-]+)/g; const violations = []; const lineOf = (text, index) => text.slice(0, index).split("\n").length; 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/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index dee2cc7023..c308e3ad4e 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling PDF", "mainBinaryName": "Stirling-PDF", - "version": "2.14.2", + "version": "2.14.3", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", 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/Payg.css b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css index 581f3af671..9cbda9b6ca 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css @@ -95,7 +95,7 @@ margin-bottom: 8px; } .payg-planhead__lbl--free { - color: var(--c-success); + color: var(--color-green-dark); } .payg-planhead__lbl--meter { color: var(--payg-accent); @@ -242,7 +242,7 @@ background: color-mix(in srgb, var(--c-success) 14%, transparent); } [data-mantine-color-scheme="dark"] .payg-hero__credit { - color: var(--c-success); + color: var(--color-green-dark); background: color-mix(in srgb, var(--c-success) 18%, transparent); } @@ -459,11 +459,11 @@ } .payg-gate[data-enabled="true"] .payg-gate__chip { background: color-mix(in srgb, var(--c-success) 16%, transparent); - color: var(--c-success); + color: var(--color-green-dark); } .payg-gate[data-enabled="false"] .payg-gate__chip { background: color-mix(in srgb, var(--c-danger) 16%, transparent); - color: var(--c-danger); + color: var(--color-red-dark); } .payg-gate__label { font-size: 0.8125rem; @@ -486,11 +486,11 @@ background: var(--c-surface-sunken); } .payg-gate__tag[data-variant="pause"] { - color: var(--c-danger); + color: var(--color-red-dark); background: color-mix(in srgb, var(--c-danger) 12%, transparent); } [data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] { - color: var(--c-danger); + color: var(--color-red-dark); background: color-mix(in srgb, var(--c-danger) 18%, transparent); } diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css index 9e89fa38eb..af391f0aeb 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css @@ -298,7 +298,7 @@ font-size: 1rem !important; } .paygf-explainer__icon--free { - color: var(--c-success); + color: var(--color-green-dark); } .paygf-explainer__icon--paid { color: var(--payg-accent); diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css index d0ed711434..b6b72f504a 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css @@ -10,7 +10,7 @@ .scc { --scc-accent: var(--c-primary); - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent); --scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent); --scc-chip-bg: var(--c-surface-sunken); @@ -24,7 +24,7 @@ [data-mantine-color-scheme="dark"] .scc { /* Chip surface/border track the neutral --c-* tokens (base rule); only the brand-azure accent is tuned brighter for dark. */ - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent); } 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 abfb0110ca..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,8 +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/App.tsx b/frontend/editor/src/core/App.tsx index 6fd23e5510..81db0564b8 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -9,6 +9,7 @@ import HomePage from "@app/pages/HomePage"; import Onboarding from "@app/components/onboarding/Onboarding"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); // Import global styles import "@app/styles/tailwind.css"; @@ -42,6 +43,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* All other routes need AppProviders for backend integration */} - - - - + \ 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( - = ({ {storageStats.quota && ( 80 diff --git a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx index ec854426e6..bfc53d5252 100644 --- a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx @@ -51,6 +51,13 @@ export const ColorPicker: React.FC = ({ format="hex" value={selectedColor} onChange={onColorChange} + // The saturation area and hue bar are role="slider" divs; these are + // their only accessible names. + saturationLabel={t( + "colorPicker.saturation", + "Saturation and brightness", + )} + hueLabel={t("colorPicker.hue", "Hue")} swatches={[ "#000000", "#0066cc", @@ -73,6 +80,7 @@ export const ColorPicker: React.FC = ({ max={100} value={opacity} onChange={onOpacityChange} + thumbLabel={resolvedOpacityLabel} marks={[ { value: 25, label: "25%" }, { value: 50, label: "50%" }, diff --git a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx index 94d2e35e47..5bce53410e 100644 --- a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx @@ -161,7 +161,7 @@ const AddFileCard = ({ icon={icons.uploadIconName} width="1.25rem" height="1.25rem" - style={{ color: "var(--c-primary)", flexShrink: 0 }} + style={{ color: "var(--c-accent-text)", flexShrink: 0 }} /> {isUploadHover && ( 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/fileManager/CompactFileDetails.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx index 11657cc288..988ecf789c 100644 --- a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx @@ -107,7 +107,7 @@ const CompactFileDetails: React.FC = ({ {currentFile && ` • v${currentFile.versionNumber || 1}`} {hasMultipleFiles && ( - + {currentFileIndex + 1} of {selectedFiles.length} )} diff --git a/frontend/editor/src/core/components/fileManager/DragOverlay.tsx b/frontend/editor/src/core/components/fileManager/DragOverlay.tsx index 023bb59d14..04af539346 100644 --- a/frontend/editor/src/core/components/fileManager/DragOverlay.tsx +++ b/frontend/editor/src/core/components/fileManager/DragOverlay.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Stack, Text, useMantineTheme, alpha } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { useTranslation } from "react-i18next"; @@ -9,7 +9,6 @@ interface DragOverlayProps { const DragOverlay: React.FC = ({ isVisible }) => { const { t } = useTranslation(); - const theme = useMantineTheme(); if (!isVisible) return null; @@ -21,8 +20,9 @@ const DragOverlay: React.FC = ({ isVisible }) => { left: 0, right: 0, bottom: 0, - backgroundColor: alpha(theme.colors.blue[6], 0.1), - border: `0.125rem dashed ${theme.colors.blue[6]}`, + // The prompt below is the drop affordance on its own. Tinting the whole + // region and ringing it in dashed accent reads as a second, competing + // surface, so the overlay stays transparent. borderRadius: "1.875rem", display: "flex", alignItems: "center", @@ -32,10 +32,12 @@ const DragOverlay: React.FC = ({ isVisible }) => { }} > + {/* Muted ink rather than the accent shade: it has to read on whatever + the overlay happens to sit on, in either scheme. */} - + {t("fileManager.dropFilesHere", "Drop files here to upload")} diff --git a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx index 59b42ef954..890906abda 100644 --- a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx +++ b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx @@ -103,7 +103,7 @@ const EmptyFilesState: React.FC = () => { icon={icons.uploadIconName} width="1.25rem" height="1.25rem" - style={{ color: "var(--c-primary)" }} + style={{ color: "var(--c-accent-text)" }} /> {isUploadHover && ( diff --git a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx index f43df3d86c..4f94155d4e 100644 --- a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx @@ -86,19 +86,29 @@ const FileInfoCard: React.FC = ({ }} > - + {t("fileManager.details", "File Details")} - + {/* The viewport is focusable and named so keyboard users can scroll the + detail list once it overflows. */} + diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index ce9be12ad2..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": @@ -906,35 +948,47 @@ function ListView({ return (
+ {/* Each direct child is a columnheader: a role="row" may only own cells, so + the sort controls and the select-all box have to sit inside one. */}
{onSetSelection && visibleFileIds.length > 0 ? ( - { - onSetSelection(allSelected ? new Set() : new Set(visibleFileIds)); - }} - aria-label={ - allSelected - ? t("filesPage.deselectAll", "Clear selection") - : t("filesPage.selectAll", "Select all") - } - /> + + { + onSetSelection( + allSelected ? new Set() : new Set(visibleFileIds), + ); + }} + aria-label={ + allSelected + ? t("filesPage.deselectAll", "Clear selection") + : t("filesPage.selectAll", "Select all") + } + /> + ) : (
@@ -1091,7 +1145,12 @@ function FolderRow({ className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`} >
); } @@ -1254,35 +1317,40 @@ function FileRow({ isInWorkspace ? " is-in-workspace" : "" }`} > - {/* Checkbox only shows in multi-select mode (see FileCard). When the - checkbox is hidden the first grid column collapses, but the row's - CSS grid keeps the columns aligned via the named template, so no - empty cell shows. */} + {/* Each direct child is a gridcell: a role="row" may only own cells, so the + checkbox and the actions menu have to sit inside one. + + The checkbox only shows in multi-select mode (see FileCard). When it is + hidden the first grid column collapses, but the row's CSS grid keeps the + columns aligned via the named template, so no empty cell shows. */} {multiSelectActive ? ( - { - // Toggle this file in/out of the selection without modifier keys. - e.stopPropagation(); - onClick({ - ...e, - shiftKey: false, - ctrlKey: true, - metaKey: true, - } as unknown as React.MouseEvent); - }} - onChange={() => { - /* handled by onClick */ - }} - aria-label={t("filesPage.selectFile", "Select file {{name}}", { - name: file.name, - })} - /> + + { + // Toggle this file in/out of the selection without modifier keys. + e.stopPropagation(); + onClick({ + ...e, + shiftKey: false, + ctrlKey: true, + metaKey: true, + } as unknown as React.MouseEvent); + }} + onChange={() => { + /* handled by onClick */ + }} + aria-label={t("filesPage.selectFile", "Select file {{name}}", { + name: file.name, + })} + /> + ) : ( // Empty cell preserves grid column alignment. ); } diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index cc6a74d09e..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"; @@ -67,12 +77,14 @@ import { parseFilesPageDragPayload, } from "@app/components/filesPage/dragDrop"; import { clearFilesPageReturnRoute } from "@app/components/filesPage/filesPageReturnRoute"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import "@app/components/filesPage/FilesPage.css"; 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(); @@ -578,7 +590,7 @@ export default function FileManagerView() { } else if (materialized.length > 1) { navActions.setWorkbench("fileEditor"); } - navigate("/"); + navigate(EDITOR_BASENAME); }; requestNavigation(() => { @@ -681,16 +693,15 @@ export default function FileManagerView() { const handleClose = useCallback(() => { // Drop the return-route hint so the workbench doesn't show a stale back. clearFilesPageReturnRoute(); - navigate("/"); + navigate(EDITOR_BASENAME); }, [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) => { @@ -734,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) => { @@ -889,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/FileOriginBadge.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx index a4e9b6e3f9..3d94610e0b 100644 --- a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx @@ -32,12 +32,12 @@ const styles = { }, cloud: { background: "color-mix(in srgb, var(--c-primary) 16%, transparent)", - color: "var(--c-primary)", + color: "var(--c-accent-text)", }, shared: { background: "color-mix(in srgb, var(--mantine-color-orange-6) 16%, transparent)", - color: "var(--mantine-color-orange-6)", + color: "var(--color-amber-dark)", }, }; diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 4815a1b31e..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; @@ -339,6 +344,9 @@ } .files-page-list-row.is-header [data-sortable="true"] { + /* Block so the hit area and hover tint fill the columnheader cell that wraps + it, rather than hugging the label text. */ + display: block; cursor: pointer; padding: 0.2rem 0.4rem; margin: -0.2rem -0.4rem; @@ -741,7 +749,7 @@ height: 5rem; border-radius: 50%; background: color-mix(in srgb, var(--c-primary) 12%, transparent); - color: var(--c-primary); + color: var(--c-accent-text); margin-bottom: 0.25rem; } @@ -980,7 +988,7 @@ .files-page-details-version-timeline-count { margin-left: auto; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-transform: none; letter-spacing: 0; } @@ -1112,7 +1120,29 @@ .files-page-details-version-timeline-expand-btn:hover span { color: var(--c-text); } - +.files-page-details-version-timeline-delta { + font-size: 0.82rem; + color: var(--c-text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-flex; + align-items: baseline; + gap: 0.25rem; +} +.files-page-details-version-timeline-delta.is-origin { + font-weight: 400; + color: var(--c-text-subtle); + font-style: italic; +} +.files-page-details-version-timeline-delta-plus { + color: var(--c-accent-text); + font-weight: 700; +} +.files-page-details-version-timeline-spacer { + flex: 1; +} .files-page-details-version-timeline-chevron { color: var(--c-text-subtle); transition: transform 0.15s ease; @@ -1120,7 +1150,7 @@ .files-page-details-version-timeline-chevron.is-expanded { transform: rotate(180deg); - color: var(--c-primary); + color: var(--c-accent-text); } .files-page-details-version-timeline-expanded { @@ -1185,7 +1215,7 @@ justify-content: center; gap: 1rem; pointer-events: none; - color: var(--c-primary); + color: var(--c-accent-text); font-weight: 600; font-size: 1.1rem; z-index: 10; @@ -1351,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/FolderThumbnail.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx index 39554a21ee..928a75eb3c 100644 --- a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx @@ -151,7 +151,10 @@ export function FolderThumbnail({ borderRadius: "999px", background: "var(--c-surface, #fff)", border: `1px solid ${accent}`, - color: accent, + // The ring carries the folder's accent; the numeral does not. + // Folder colours are user-chosen and many are too light to read + // as text on the white pill. + color: "var(--c-text)", fontSize: "0.7rem", fontWeight: 700, display: "inline-flex", 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; + + // The WorkbenchBar carries file-scoped actions, so it only shows once a file + // is open or a custom view supplies content; otherwise the search floats. + const activeCustomView = customWorkbenchViews.find( + (v) => v.workbenchId === currentView, + ); + const topControlsAvailable = + currentView !== "myFiles" && !activeCustomView?.hideTopControls; + const hasWorkbenchContent = + hasFiles || + fileIds.length > 0 || + !isBaseWorkbench(currentView) || + // Shared signing drives the viewer from the sidebar with no file in context. + (currentView === "viewer" && !!signingOverlay?.file); + const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent; + const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent; const handlePreviewClose = () => { setPreviewFile(null); @@ -136,6 +151,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,24 +248,35 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* Workbench Bar - animates in/out based on file presence */} - {currentView !== "myFiles" && - !customWorkbenchViews.find((v) => v.workbenchId === currentView) - ?.hideTopControls && ( -
+ {showWorkbenchBar && ( +
+
- )} + {/* Reopen tab for the retracted viewer tool row. */} + {showReopenTab && ( +
+ )} + {showFloatingSearch && } {/* Dismiss All Errors Button */} diff --git a/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx b/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx new file mode 100644 index 0000000000..38c2be8e6c --- /dev/null +++ b/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx @@ -0,0 +1,233 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from "react"; + +/** + * Touch-first signature canvas for the phone `/mobile-sign` page. + * + * Pointer events (finger / stylus / mouse) with `touch-action: none`, sized to + * its container at device-pixel-ratio resolution so strokes land exactly under + * the finger — a fixed-size canvas stretched by CSS would offset them. Strokes + * are kept as point lists in CSS-pixel space, so undo and resize redraw + * losslessly, and export can crop to the inked region. + */ + +export interface MobileDrawCanvasHandle { + /** Trimmed, transparent-background PNG of the ink, or null when empty. */ + exportPng: () => string | null; + undo: () => void; + clear: () => void; +} + +interface Stroke { + color: string; + size: number; + points: Array<{ x: number; y: number }>; +} + +interface MobileDrawCanvasProps { + penColor: string; + penSize: number; + /** Fired when the canvas goes between empty and inked (gates Send/Undo). */ + onHasInkChange: (hasInk: boolean) => void; +} + +/** Padding kept around the ink when cropping the export, in CSS pixels. */ +const EXPORT_PADDING = 12; + +function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) { + const { points } = stroke; + if (points.length === 0) return; + + ctx.strokeStyle = stroke.color; + ctx.fillStyle = stroke.color; + ctx.lineWidth = stroke.size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + if (points.length === 1) { + // A tap: render a dot, which a zero-length stroke would not show. + ctx.beginPath(); + ctx.arc(points[0].x, points[0].y, stroke.size / 2, 0, Math.PI * 2); + ctx.fill(); + return; + } + + // Quadratic midpoint smoothing: each point becomes the control point of a + // curve to the midpoint of the next segment, turning jagged pointer samples + // into a pen-like line. + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length - 1; i++) { + const midX = (points[i].x + points[i + 1].x) / 2; + const midY = (points[i].y + points[i + 1].y) / 2; + ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); + } + const last = points[points.length - 1]; + ctx.lineTo(last.x, last.y); + ctx.stroke(); +} + +export const MobileDrawCanvas = forwardRef< + MobileDrawCanvasHandle, + MobileDrawCanvasProps +>(function MobileDrawCanvas({ penColor, penSize, onHasInkChange }, ref) { + const canvasRef = useRef(null); + const strokesRef = useRef([]); + const activeStrokeRef = useRef(null); + // Live styling for the stroke currently being drawn, without re-rendering + const penRef = useRef({ color: penColor, size: penSize }); + penRef.current = { color: penColor, size: penSize }; + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + for (const stroke of strokesRef.current) drawStroke(ctx, stroke); + if (activeStrokeRef.current) drawStroke(ctx, activeStrokeRef.current); + }, []); + + // Match the backing store to the element's CSS size Ɨ devicePixelRatio, and + // re-match on resize/rotation. Strokes are CSS-space, so a redraw restores + // them at the new size. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(rect.width * dpr)); + canvas.height = Math.max(1, Math.round(rect.height * dpr)); + redraw(); + }; + + resize(); + const observer = new ResizeObserver(resize); + observer.observe(canvas); + return () => observer.disconnect(); + }, [redraw]); + + const pointFromEvent = (e: React.PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }; + + const handlePointerDown = (e: React.PointerEvent) => { + // One stroke at a time: a second touch while drawing would scribble. + if (activeStrokeRef.current) return; + e.currentTarget.setPointerCapture(e.pointerId); + activeStrokeRef.current = { + color: penRef.current.color, + size: penRef.current.size, + points: [pointFromEvent(e)], + }; + redraw(); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + // Coalesced events give the full sample train on high-rate digitizers, + // where the per-frame synthetic event alone would drop curvature. + const events = + "getCoalescedEvents" in e.nativeEvent + ? (e.nativeEvent as PointerEvent).getCoalescedEvents() + : [e.nativeEvent as PointerEvent]; + const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect(); + for (const ev of events) { + stroke.points.push({ + x: ev.clientX - rect.left, + y: ev.clientY - rect.top, + }); + } + redraw(); + }; + + const endStroke = () => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + activeStrokeRef.current = null; + strokesRef.current = [...strokesRef.current, stroke]; + redraw(); + onHasInkChange(true); + }; + + useImperativeHandle(ref, () => ({ + exportPng: () => { + const strokes = strokesRef.current; + const canvas = canvasRef.current; + if (strokes.length === 0 || !canvas) return null; + + // Crop to the inked region so the signature stamps tightly, clamped to + // what was actually visible. + const rect = canvas.getBoundingClientRect(); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const stroke of strokes) { + const reach = stroke.size / 2 + EXPORT_PADDING; + for (const p of stroke.points) { + minX = Math.min(minX, p.x - reach); + minY = Math.min(minY, p.y - reach); + maxX = Math.max(maxX, p.x + reach); + maxY = Math.max(maxY, p.y + reach); + } + } + minX = Math.max(0, minX); + minY = Math.max(0, minY); + maxX = Math.min(rect.width, maxX); + maxY = Math.min(rect.height, maxY); + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + + const dpr = window.devicePixelRatio || 1; + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = Math.round(width * dpr); + exportCanvas.height = Math.round(height * dpr); + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return null; + // Translate args are device pixels; scale args map stroke space to them. + ctx.setTransform(dpr, 0, 0, dpr, -minX * dpr, -minY * dpr); + for (const stroke of strokes) drawStroke(ctx, stroke); + return exportCanvas.toDataURL("image/png"); + }, + undo: () => { + strokesRef.current = strokesRef.current.slice(0, -1); + redraw(); + onHasInkChange(strokesRef.current.length > 0); + }, + clear: () => { + strokesRef.current = []; + activeStrokeRef.current = null; + redraw(); + onHasInkChange(false); + }, + })); + + return ( + + ); +}); diff --git a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index f9439dcdd2..58108b55c1 100644 --- a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -325,7 +325,7 @@ .v2Badge { background: var(--c-primary-tint); - color: var(--c-accent-fg); + color: var(--c-accent-text); padding: 3px 9px; border-radius: 6px; font-size: 12px; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx index 49d94662eb..3d077301eb 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx @@ -101,7 +101,10 @@ export default function OnboardingSlideShell({ ); return ( - , because only Modal.Content lands + // props on the role="dialog" element — the slide draws its own title, so the + // dialog needs an aria-label to have an accessible name. + -
-
-
- - Stirling -
-
- {showProgress && ( - - {t("onboarding.stepOf", "Step {{current}} of {{total}}", { - current: stepIndex + 1, - total: stepCount, - })} - - )} - {allowDismiss && ( - - + + +
+
+
+ - - )} -
-
+ Stirling +
+
+ {showProgress && ( + + {t("onboarding.stepOf", "Step {{current}} of {{total}}", { + current: stepIndex + 1, + total: stepCount, + })} + + )} + {allowDismiss && ( + + + + )} +
+
- {showProgress && ( -
- {Array.from({ length: stepCount }, (_, index) => ( - - ))} -
- )} - -
- -
-
-
- {hero} -
-
- -
- {title} -
- -
- {body} - -
- -
- {backButtons.length === 0 ? ( -
{actions}
- ) : ( -
-
- {backButtons.map((button) => ( - onAction(button.action)} - variant="tertiary" - accent="neutral" - disabled={button.disabled} - aria-label={t("onboarding.buttons.back", "Back")} - > - - - ))} -
- {actions} + {showProgress && ( +
+ {Array.from({ length: stepCount }, (_, index) => ( + + ))}
)} + +
+ +
+
+
+ {hero} +
+
+ +
+ {title} +
+ +
+ {body} + +
+ +
+ {backButtons.length === 0 ? ( +
{actions}
+ ) : ( +
+
+ {backButtons.map((button) => ( + onAction(button.action)} + variant="tertiary" + accent="neutral" + disabled={button.disabled} + aria-label={t("onboarding.buttons.back", "Back")} + > + + + ))} +
+ {actions} +
+ )} +
+
-
-
- + + + ); } diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx index 23566a5527..aa04911832 100644 --- a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -50,7 +50,7 @@ export default function AnalyticsChoiceSlide({
{analyticsError && ( -
+
{analyticsError}
)} diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx index d684592a4a..0b1f5bb8f2 100644 --- a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -125,7 +125,7 @@ function FirstLoginForm({ icon="info-rounded" width={20} height={20} - style={{ color: "var(--c-primary)", flexShrink: 0 }} + style={{ color: "var(--c-accent-text)", flexShrink: 0 }} /> {t( diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 6101605772..7f3e9f12a5 100644 --- a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -26,7 +26,7 @@ export default function SecurityCheckSlide({ icon="error" width={20} height={20} - style={{ color: "var(--c-danger)", flexShrink: 0 }} + style={{ color: "var(--color-red-dark)", flexShrink: 0 }} /> {i18n.t( diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css index 1ecd05808b..59729a79e0 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css @@ -222,7 +222,8 @@ /* Error helper text above the input */ .errorText { margin-top: 0.25rem; - color: var(--text-brand-accent); + /* The brand red is a fill; error copy takes the theme's error ink. */ + color: var(--color-red-dark); } /* Compact error container for inline tool settings */ @@ -237,7 +238,7 @@ /* Two-line clamp for compact error text */ .errorTextClamp { - color: var(--text-brand-accent); + color: var(--color-red-dark); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; 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 3805b4b912..12faf6eea5 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -29,8 +29,8 @@ 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"; interface AppConfigModalProps { opened: boolean; @@ -45,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. */ @@ -66,6 +68,7 @@ const AppConfigModalInner: React.FC = ({ onClose, urlSync = true, initialSection, + initialFocus, extraSections, hiddenSectionKeys, }) => { @@ -149,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(() => { @@ -184,21 +216,23 @@ 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. if (urlSync && location.pathname.startsWith("/settings")) { // "default" key = first entry (deep link/refresh); nothing to pop to. if (location.key === "default") { - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } else { navigate(-1); } } onClose(); + return true; }, [ confirmIfDirty, location.key, @@ -213,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, @@ -412,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/DropdownListWithFooter.tsx b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx index 0816ac9a14..4def62470d 100644 --- a/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx +++ b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx @@ -135,7 +135,11 @@ const DropdownListWithFooter: React.FC = ({ zIndex={zIndex} > + {/* A real button: Popover.Target stamps aria-haspopup/aria-expanded on + its child, and those are only permitted on an actual control. */} = ({ padding: "8px 12px", backgroundColor: "light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))", + color: "inherit", + textAlign: "left", + width: "100%", opacity: disabled ? 0.6 : 1, cursor: disabled ? "not-allowed" : "pointer", minHeight: "36px", diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.tsx index a515a46cae..cce50f90a0 100644 --- a/frontend/editor/src/core/components/shared/EditableSecretField.tsx +++ b/frontend/editor/src/core/components/shared/EditableSecretField.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useId, useState, useRef, useEffect } from "react"; import { PasswordInput, Group, Tooltip, TextInput } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ActionIcon } from "@app/ui/ActionIcon"; @@ -33,6 +33,7 @@ export default function EditableSecretField({ }: EditableSecretFieldProps) { const { t } = useTranslation(); const resolvedPlaceholder = placeholder ?? t("common.enterValue"); + const fieldId = useId(); const [isEditing, setIsEditing] = useState(false); const [tempValue, setTempValue] = useState(""); const inputRef = useRef(null); @@ -67,6 +68,7 @@ export default function EditableSecretField({
{label && (
)} @@ -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 d43385ece0..874bf47e28 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -183,7 +183,7 @@ } .file-sidebar-file-item.selected .file-sidebar-file-name { - color: var(--c-accent-fg); + color: var(--c-accent-text); } .file-sidebar-file-meta-row { @@ -208,7 +208,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--c-primary); + color: var(--c-accent-text); } /* ---- Folder membership tags ---- */ @@ -302,7 +302,7 @@ } .file-sidebar-file-item.viewed .file-sidebar-file-name { - color: var(--c-success); + color: var(--color-green-dark); } .file-sidebar-file-item.viewed .file-sidebar-file-check { @@ -321,7 +321,7 @@ /* Always show eye for the currently viewed file */ .file-sidebar-file-item.viewed .file-sidebar-eye-btn { opacity: 1; - color: var(--c-success); + color: var(--color-green-dark); } .file-sidebar-eye-btn:hover { @@ -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 a081f60bbe..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(--mantine-color-orange-9)", - 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({ <> - - - ))} - + {t("update.releaseNotes", "Release Notes")}{" "} + + + + {t("update.allReleases", "All Releases")}{" "} + + + + - )} - {/* ── Version history ─────────────────────────────────────────────── */} - - {loading ? ( -
- - - - {t( - "update.loadingDetailedInfo", - "Loading version details...", - )} - - -
- ) : visibleVersions.length > 0 ? ( - - - - {t("update.versionHistory", "Version History")} - - - {sortedVersions.length}{" "} - {sortedVersions.length === 1 ? "version" : "versions"} - - - - {visibleVersions.map((version, index) => { - const isExpanded = expandedVersions.has(index); - return ( - - toggleVersion(index)} - > - - - {version.version} - - - {getPriorityLabel(version.priority)} - - {version.compatibility.breaking_changes && ( - - {t("update.breaking", "Breaking")} - - )} - {!isExpanded && version.announcement?.title && ( - - {version.announcement.title} - - )} - - - - {isExpanded ? ( - - ) : ( - - )} - - - + {/* Migration guides */} + {updateSummary.migration_guides && + updateSummary.migration_guides.length > 0 && ( + + + {t("update.migrationGuides", "Migration Guides")} + + + {t( + "update.migrationGuidesDesc", + "Review important changes before updating.", + )} + + + {updateSummary.migration_guides.map((guide, idx) => ( + + + + + {guide.version} + + + {guide.notes} + + + + + + ))} + + + )} + + {/* ── Version history ─────────────────────────────────────────────── */} + + {loading ? ( +
+ + + + {t( + "update.loadingDetailedInfo", + "Loading version details...", + )} + + +
+ ) : visibleVersions.length > 0 ? ( + + + + {t("update.versionHistory", "Version History")} + + + {sortedVersions.length}{" "} + {sortedVersions.length === 1 ? "version" : "versions"} + + + + {visibleVersions.map((version, index) => { + const isExpanded = expandedVersions.has(index); + return ( + - - {version.announcement?.message && ( - - {version.announcement.message} + toggleVersion(index)} + > + + + {version.version} - )} - {version.compatibility.breaking_changes && ( - - } - title={t( - "update.breakingChanges", - "Breaking Changes", - )} > - - {version.compatibility.breaking_description || - t( - "update.breakingChangesDefault", - "This version contains breaking changes.", - )} + {getPriorityLabel(version.priority)} + + {version.compatibility.breaking_changes && ( + + {t("update.breaking", "Breaking")} + + )} + {!isExpanded && version.announcement?.title && ( + + {version.announcement.title} - {version.compatibility.migration_guide_url && ( - + )} + + + + {isExpanded ? ( + + ) : ( + + )} + + + + + + {version.announcement?.message && ( + + {version.announcement.message} + + )} + {version.compatibility.breaking_changes && ( + + } + title={t( + "update.breakingChanges", + "Breaking Changes", + )} + > + + {version.compatibility + .breaking_description || + t( + "update.breakingChangesDefault", + "This version contains breaking changes.", + )} + + {version.compatibility + .migration_guide_url && ( + + )} + + )} + + + -
-
- ); - })} -
- {sortedVersions.length > 10 && ( -
- -
- )} -
- ) : null} + ); + })} + + {sortedVersions.length > 10 && ( +
+ +
+ )} + + ) : null} - {/* Desktop install progress */} - {desktopInstall && desktopInstall.state !== "idle" && ( - - {(desktopInstall.state === "downloading" || - desktopInstall.state === "installing") && ( - - - - {desktopInstall.state === "downloading" - ? t( - "desktopUpdate.downloading", - "Downloading update...", - ) - : t("desktopUpdate.installing", "Installing update...")} - - {desktopInstall.progress && - desktopInstall.progress.total !== null && ( - - {formatBytes(desktopInstall.progress.downloaded)} /{" "} - {formatBytes(desktopInstall.progress.total)} + {/* Desktop install progress */} + {desktopInstall && desktopInstall.state !== "idle" && ( + + {(desktopInstall.state === "downloading" || + desktopInstall.state === "installing") && ( + + + + {desktopInstall.state === "downloading" + ? t( + "desktopUpdate.downloading", + "Downloading update...", + ) + : t( + "desktopUpdate.installing", + "Installing update...", + )} + {desktopInstall.progress && + desktopInstall.progress.total !== null && ( + + {formatBytes(desktopInstall.progress.downloaded)}{" "} + / {formatBytes(desktopInstall.progress.total)} + + )} + + + {desktopInstall.state === "installing" && ( + + + {t( + "desktopUpdate.installingWarning", + "The app will close automatically to complete the installation.", + )} + + + )} + + )} + {desktopInstall.state === "ready-to-restart" && ( + } + color="green" + variant="light" + radius="md" + title={t("desktopUpdate.readyToRestart", "Update Ready")} + > + {t( + "desktopUpdate.restartMessage", + "The update has been installed. Restart the app to finish.", )} - - - {desktopInstall.state === "installing" && ( - - - {t( - "desktopUpdate.installingWarning", - "The app will close automatically to complete the installation.", - )} - )} - - )} - {desktopInstall.state === "ready-to-restart" && ( - } - color="green" - variant="light" - radius="md" - title={t("desktopUpdate.readyToRestart", "Update Ready")} - > - {t( - "desktopUpdate.restartMessage", - "The update has been installed. Restart the app to finish.", + {desktopInstall.state === "error" && ( + } + color="red" + variant="light" + radius="md" + title={t("desktopUpdate.updateFailed", "Update Failed")} + > + {desktopInstall.errorMessage ?? + t( + "desktopUpdate.updateFailedMessage", + "Failed to download or install the update.", + )} + )} - + )} - {desktopInstall.state === "error" && ( - } - color="red" - variant="light" - radius="md" - title={t("desktopUpdate.updateFailed", "Update Failed")} - > - {desktopInstall.errorMessage ?? - t( - "desktopUpdate.updateFailedMessage", - "Failed to download or install the update.", - )} - - )} - - )} - - + + - {/* ── Sticky footer ──────────────────────────────────────────────────── */} - - - - {desktopInstall ? ( - desktopInstall.state === "ready-to-restart" ? ( + - ) : desktopInstall.state === "idle" || - desktopInstall.state === "error" ? ( - <> - {/* When install is blocked (non-admin) or the tauri updater + {desktopInstall ? ( + desktopInstall.state === "ready-to-restart" ? ( + + ) : desktopInstall.state === "idle" || + desktopInstall.state === "error" ? ( + <> + {/* When install is blocked (non-admin) or the tauri updater failed, the user still needs a way forward — show a "Download Latest" link to the GitHub release page as a fallback alongside the disabled Install Now button. */} - {(installBlocked || desktopInstall.state === "error") && - downloadUrl && ( + {(installBlocked || desktopInstall.state === "error") && + downloadUrl && ( + + )} - )} - - - ) : null - ) : ( - // Tauri updater not available at all — only show the external - // download link. This is the fallback when latest.json is - // unreachable, the pubkey is wrong, signatures don't match, etc. - downloadUrl && ( - - ) - )} - - - + + ) : null + ) : ( + // Tauri updater not available at all — only show the external + // download link. This is the fallback when latest.json is + // unreachable, the pubkey is wrong, signatures don't match, etc. + downloadUrl && ( + + ) + )} + + + + + ); }; diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.css b/frontend/editor/src/core/components/shared/WorkbenchBar.css index 820a7cdf46..f805f69721 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.css +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.css @@ -2,11 +2,17 @@ /* Horizontal toolbar at the top of the workbench area. */ .workbench-bar { + /* Flex-wrap layout that reflows by content width (measured in JS, which sets + data-wrapped). Roomy: row 1 is [views | search | globals] and the tool + icons sit on their own full-width row below. Crowded (data-wrapped): the + search bumps to its own row, leaving [views | globals] on row 1 and the + tools on row 3 — three rows total. `order` drives the reflow. */ display: flex; flex-wrap: wrap; align-items: center; align-content: flex-start; - min-height: 40px; + column-gap: 8px; + min-height: 38px; padding: 0 8px; /* No left margin: the file sidebar's own 0.5rem padding already provides the gutter on that side, so adding one here would double it and leave the bar @@ -21,12 +27,16 @@ /* ---- View switcher (left) ---- */ .workbench-bar-views { + order: 1; + flex-shrink: 0; display: flex; align-items: center; gap: 2px; - flex-shrink: 0; - order: 1; - height: 40px; + /* min-width:0 lets it shrink instead of forcing a wrap; overflow clips labels + on very narrow viewports. */ + min-width: 0; + overflow: hidden; + height: 38px; z-index: 0; } @@ -34,7 +44,7 @@ display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; + padding: 2px 8px; border: none; border-radius: 8px; background: transparent; @@ -55,7 +65,7 @@ .workbench-bar-view-btn.active { background-color: var(--c-primary-subtle); - color: var(--c-accent-fg); + color: var(--c-accent-text); } .workbench-bar-view-btn.workbench-bar-back-btn { @@ -65,7 +75,7 @@ .workbench-bar-view-btn.workbench-bar-back-btn:hover { background-color: color-mix(in srgb, var(--c-primary) 12%, transparent); - color: var(--c-accent-fg); + color: var(--c-accent-text); } .workbench-bar-view-btn svg { @@ -86,27 +96,47 @@ height: 16px; } -/* ---- Center: tool buttons ---- */ - -/* Single-row: center sits between views and globals */ -.workbench-bar-center { +/* ---- Super search ---- */ +/* Roomy: grows to fill the centre between views and globals. */ +.workbench-bar-search { order: 2; flex: 1 1 auto; min-width: 0; display: flex; align-items: center; justify-content: center; - gap: 2px; - height: 40px; - overflow: hidden; + height: 38px; + transform: translateX(var(--workbench-bar-search-offset, 0px)); } -/* Two-row: center drops below views+globals */ -.workbench-bar[data-wrapped="true"] .workbench-bar-center { +.workbench-bar-search .super-search { + flex: 0 1 24rem; + width: min(100%, 24rem); + max-width: 24rem; +} + +.workbench-bar-search .super-search input { + background-color: transparent; + padding-top: 4px; + padding-bottom: 4px; + font-size: 12.5px; +} + +[data-mantine-color-scheme="dark"] .workbench-bar-search .super-search input { + background-color: transparent; +} + +/* Crowded: drops to its own full-width row between the top row and the tools. */ +.workbench-bar[data-wrapped="true"] .workbench-bar-search { order: 3; flex: 0 0 100%; height: auto; padding: 4px 0; + transform: none; +} + +/* Two-row: the tool row scrolls sideways instead of wrapping. */ +.workbench-bar[data-wrapped="true"] .workbench-bar-center { border-top: 1px solid var(--c-border-subtle); justify-content: flex-start; overflow-x: auto; @@ -118,21 +148,68 @@ flex-shrink: 0; } -/* ---- Right: global buttons (theme / language / download) ---- */ +/* ---- Tool buttons (own full-width row, always below the top row) ---- */ +.workbench-bar-center { + order: 4; + flex: 0 0 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 2px; + /* Symmetric side padding leaves room for the retract handle pinned right + without knocking the centred tool icons off-centre. */ + padding: 4px 36px; + border-top: 1px solid var(--c-border-subtle); +} + +/* Retract / reopen handle for the viewer tool row. */ +.workbench-bar-toolbar-handle { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--c-text-subtle); + cursor: pointer; + padding: 0; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.workbench-bar-toolbar-handle:hover { + color: var(--c-text); + background: var(--c-hover); +} + +/* Pinned to the right edge of the tool row. */ +.workbench-bar-toolbar-handle-retract { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 28px; + height: 28px; +} + +/* ---- Right: global buttons (print / download / save / close), row 1 ---- */ .workbench-bar-globals { + order: 3; + flex-shrink: 0; display: flex; align-items: center; gap: 2px; - flex-shrink: 0; - order: 3; - height: 40px; + min-width: 0; + height: 38px; margin-left: auto; } -/* In two-row mode globals moves to row 1 right side */ +/* Crowded: globals stays on row 1, moving up beside the views. */ .workbench-bar[data-wrapped="true"] .workbench-bar-globals { order: 2; - margin-left: auto; } /* Shared action icon style - applies to both center and global buttons. diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 55ca01d307..241d658bad 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -1,6 +1,6 @@ import React, { useCallback, - useEffect, + useLayoutEffect, useMemo, useRef, useSyncExternalStore, @@ -33,6 +33,8 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext"; import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; +import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; +import { useEditorSearchScopes } from "@app/hooks/useSuperSearch"; import ViewerShareButton from "@app/components/viewer/ViewerShareButton"; import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; @@ -53,6 +55,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; import CloseIcon from "@mui/icons-material/Close"; import PrintIcon from "@mui/icons-material/Print"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import "@app/components/shared/WorkbenchBar.css"; @@ -68,6 +71,10 @@ interface WorkbenchBarProps { currentView: WorkbenchType; setCurrentView: (view: WorkbenchType) => void; hasFiles: boolean; + /** Whether the viewer's tool row is currently retracted. */ + viewerToolbarCollapsed?: boolean; + /** Setter for the viewer tool-row retract state (owned by Workbench). */ + onCollapseViewerToolbar?: (collapsed: boolean) => void; } function renderWithTooltip( @@ -92,9 +99,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles, + viewerToolbarCollapsed = false, + onCollapseViewerToolbar, }: WorkbenchBarProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const searchScopes = useEditorSearchScopes(); const returnRoute = useSyncExternalStore( subscribeFilesPageReturnRoute, getFilesPageReturnRoute, @@ -115,6 +125,7 @@ export default function WorkbenchBar({ } = useToolWorkflow(); const { selectedTool } = useNavigationState(); const isCustomView = !isBaseWorkbench(currentView); + const isViewer = currentView === "viewer"; const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; const terminology = useFileActionTerminology(); @@ -435,34 +446,47 @@ export default function WorkbenchBar({ })), ]; + // Reflow the top row by content width: when the views + globals leave too + // little room for a usable search, bump the search to its own row const barRef = useRef(null); - - useEffect(() => { + useLayoutEffect(() => { const bar = barRef.current; if (!bar) return; - + const MIN_SEARCH_WIDTH = 320; const measure = () => { const viewsEl = bar.querySelector(".workbench-bar-views"); const globalsEl = bar.querySelector( ".workbench-bar-globals", ); - const centerEl = bar.querySelector(".workbench-bar-center"); - const viewsWidth = viewsEl?.offsetWidth ?? 0; const globalsWidth = globalsEl?.offsetWidth ?? 0; - const centerChildren = centerEl - ? (Array.from(centerEl.children) as HTMLElement[]) - : []; - const centerWidth = - centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) + - Math.max(0, centerChildren.length - 1) * 2; // gap: 2px - - const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding - bar.dataset.wrapped = String(needed > bar.clientWidth); + // clientWidth minus the two side clusters, the bar's 16px h-padding and + // the two 8px column gaps flanking the search. + const available = bar.clientWidth - viewsWidth - globalsWidth - 16 - 16; + const wrapped = available < MIN_SEARCH_WIDTH; + bar.dataset.wrapped = String(wrapped); + // Centre the search on the bar rather than its slot — clamped to the + // slot's spare width, because the shift is a transform (no layout) and + // an unclamped value would paint the pill over the adjacent cluster. + const slotEl = bar.querySelector(".workbench-bar-search"); + const pillEl = slotEl?.querySelector(".super-search"); + const slack = Math.max( + 0, + ((slotEl?.offsetWidth ?? 0) - (pillEl?.offsetWidth ?? 0)) / 2, + ); + const centred = (globalsWidth - viewsWidth) / 2; + const offset = Math.min(slack, Math.max(-slack, centred)); + bar.style.setProperty( + "--workbench-bar-search-offset", + wrapped ? "0px" : `${offset}px`, + ); }; - const ro = new ResizeObserver(measure); ro.observe(bar); + const viewsEl = bar.querySelector(".workbench-bar-views"); + const globalsEl = bar.querySelector(".workbench-bar-globals"); + if (viewsEl) ro.observe(viewsEl); + if (globalsEl) ro.observe(globalsEl); measure(); return () => ro.disconnect(); }, []); @@ -471,7 +495,7 @@ export default function WorkbenchBar({
{/* Left: optional "Back to My Files" + view switcher */} @@ -524,27 +548,50 @@ export default function WorkbenchBar({ )}
- {/* Tool buttons - second row, only rendered when buttons exist */} - {sectionsWithButtons.length > 0 && ( -
- {sectionsWithButtons.map( - ({ section, buttons: sectionButtons }, idx) => ( - - {idx > 0 &&
} - {sectionButtons.map((btn) => { - const content = renderButton(btn); - if (!content) return null; - return ( -
- {content} -
- ); - })} - - ), - )} -
- )} + {/* Global super search - always present, even on the homepage */} +
+ +
+ + {/* Tool buttons - second row, only rendered when buttons exist. In the + viewer the row is retractable: a handle on its right edge hides the + whole row; Workbench then shows a tab below the bar to bring it back. */} + {sectionsWithButtons.length > 0 && + !(isViewer && viewerToolbarCollapsed) && ( +
+ {sectionsWithButtons.map( + ({ section, buttons: sectionButtons }, idx) => ( + + {idx > 0 &&
} + {sectionButtons.map((btn) => { + const content = renderButton(btn); + if (!content) return null; + return ( +
+ {content} +
+ ); + })} + + ), + )} + {isViewer && onCollapseViewerToolbar && ( +
+ )} {/* Right: Global buttons - export group left, close anchored right */}
diff --git a/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.css b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.css new file mode 100644 index 0000000000..47c7e9eed4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.css @@ -0,0 +1,31 @@ +/* Unpainted floating search for the empty workbench. Height + top margin match + the WorkbenchBar's band so opening a file swaps it in without a shift. */ +.workbench-floating-search { + display: flex; + align-items: center; + justify-content: center; + min-height: 38px; + margin-top: var(--nav-gutter); + padding: 0 1rem; + flex-shrink: 0; +} + +.workbench-floating-search .super-search { + flex: 0 1 24rem; + width: min(100%, 24rem); + max-width: 24rem; +} + +.workbench-floating-search .super-search input { + background-color: transparent; + padding-top: 4px; + padding-bottom: 4px; + font-size: 12.5px; +} + +[data-mantine-color-scheme="dark"] + .workbench-floating-search + .super-search + input { + background-color: transparent; +} diff --git a/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.tsx b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.tsx new file mode 100644 index 0000000000..37e858adff --- /dev/null +++ b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.tsx @@ -0,0 +1,15 @@ +import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; +import { useEditorSearchScopes } from "@app/hooks/useSuperSearch"; +import "@app/components/shared/WorkbenchFloatingSearch.css"; + +// The editor's global search, floated while no file is open (mirrors the +// processor's PortalSearchBar). Renders only when the WorkbenchBar doesn't, so +// reusing the default input id is safe. +export default function WorkbenchFloatingSearch() { + const scopes = useEditorSearchScopes(); + return ( +
+ +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/ZipWarningModal.tsx b/frontend/editor/src/core/components/shared/ZipWarningModal.tsx index 31c47f0ed7..3a5c993ba5 100644 --- a/frontend/editor/src/core/components/shared/ZipWarningModal.tsx +++ b/frontend/editor/src/core/components/shared/ZipWarningModal.tsx @@ -18,7 +18,7 @@ const WARNING_ICON_STYLE: CSSProperties = { fontSize: 36, display: "block", margin: "0 auto 8px", - color: "var(--mantine-color-blue-6)", + color: "var(--c-accent-text)", }; const ZipWarningModal = ({ diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx deleted file mode 100644 index 28d5a1c3e0..0000000000 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; -import type { ConfigNavSection } from "@app/components/shared/config/configNavSections"; - -const mockConfigNavSections: ConfigNavSection[] = [ - { - title: "Preferences", - items: [ - { - key: "general", - label: "General", - icon: "settings-rounded", - component: null, - }, - { - key: "hotkeys", - label: "Keyboard Shortcuts", - icon: "keyboard-rounded", - component: null, - }, - ], - }, - { - title: "Workspace", - items: [ - { - key: "people", - label: "People", - icon: "group-rounded", - component: null, - }, - { - key: "teams", - label: "Teams", - icon: "groups-rounded", - component: null, - disabled: true, - }, - ], - }, -]; - -const meta = { - title: "Shared/Config/SettingsSearchBar", - component: SettingsSearchBar, - parameters: { layout: "padded" }, - args: { - configNavSections: mockConfigNavSections, - onNavigate: async () => {}, - isMobile: false, - }, -} satisfies Meta; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Mobile: Story = { - args: { - isMobile: true, - }, -}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx deleted file mode 100644 index 397347dcf6..0000000000 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { useMemo, useState, useCallback } from "react"; -import { Select, Text } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import LocalIcon from "@app/components/shared/LocalIcon"; -import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; -import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import type { - ConfigNavSection, - ConfigNavItem, -} from "@app/components/shared/config/configNavSections"; - -interface SettingsSearchBarProps { - configNavSections: ConfigNavSection[]; - onNavigate: (key: NavKey) => Promise; - isMobile: boolean; -} - -interface SettingsSearchOption { - value: NavKey; - label: string; - sectionTitle: string; - destinationPath: string; - searchableContent: string[]; - matchedContext?: string; -} - -const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = - { - general: ["settings.general"], - hotkeys: ["settings.hotkeys"], - account: ["account"], - people: ["settings.workspace"], - teams: ["settings.workspace", "settings.team"], - "api-keys": ["settings.developer"], - connectionMode: ["settings.connection"], - planBilling: ["settings.planBilling"], - adminGeneral: ["admin.settings.general"], - adminFeatures: ["admin.settings.features"], - adminEndpoints: ["admin.settings.endpoints"], - adminDatabase: ["admin.settings.database"], - adminAdvanced: ["admin.settings.advanced"], - adminSecurity: ["admin.settings.security"], - adminMcp: ["admin.settings.mcp"], - adminConnections: [ - "admin.settings.connections", - "admin.settings.mail", - "admin.settings.security", - "admin.settings.telegram", - "admin.settings.premium", - "admin.settings.general", - "settings.securityAuth", - "settings.connection", - ], - adminPlan: [ - "settings.planBilling", - "admin.settings.premium", - "settings.licensingAnalytics", - ], - adminAudit: ["settings.licensingAnalytics"], - adminUsage: ["settings.licensingAnalytics"], - adminLegal: ["admin.settings.legal"], - adminPrivacy: ["admin.settings.privacy"], - }; - -const getTranslationPrefixesForNavKey = (key: string): string[] => { - const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? []; - - const inferredPrefixes: string[] = []; - - if (key.startsWith("admin")) { - const adminSuffix = key.replace(/^admin/, ""); - const normalizedAdminSuffix = - adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); - inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); - } else { - inferredPrefixes.push(`settings.${key}`); - } - - return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes])); -}; - -const flattenTranslationStrings = (value: unknown): string[] => { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - - if (Array.isArray(value)) { - return value.flatMap(flattenTranslationStrings); - } - - if (value && typeof value === "object") { - return Object.values(value as Record).flatMap( - flattenTranslationStrings, - ); - } - - return []; -}; - -const buildMatchSnippet = (text: string, query: string): string => { - const normalizedText = text.toLocaleLowerCase(); - const normalizedQuery = query.toLocaleLowerCase(); - const matchIndex = normalizedText.indexOf(normalizedQuery); - - if (matchIndex === -1) { - return text; - } - - const maxLength = 84; - const contextPadding = 28; - const start = Math.max(0, matchIndex - contextPadding); - const end = Math.min(text.length, matchIndex + query.length + contextPadding); - const snippet = text.slice(start, end); - - if (snippet.length <= maxLength) { - return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`; - } - - return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`; -}; - -export const SettingsSearchBar: React.FC = ({ - configNavSections, - onNavigate, - isMobile, -}) => { - const { t } = useTranslation(); - const [searchValue, setSearchValue] = useState(""); - - // Build a global index from every accessible settings tab in the modal navigation. - // This does not render section components, so API calls still happen only when a tab is opened. - const searchableSections = useMemo(() => { - return configNavSections.flatMap((section) => - section.items - .filter((item: ConfigNavItem) => !item.disabled) - .map((item: ConfigNavItem) => { - const translationPrefixes = getTranslationPrefixesForNavKey(item.key); - const translationContent = translationPrefixes.flatMap((prefix) => - flattenTranslationStrings( - t(prefix, { returnObjects: true, defaultValue: {} }), - ), - ); - - const searchableContent = Array.from( - new Set([ - item.label, - section.title, - `/settings/${item.key}`, - ...translationContent, - ]), - ); - - return { - value: item.key, - label: item.label, - sectionTitle: section.title, - destinationPath: `/settings/${item.key}`, - searchableContent, - }; - }), - ); - }, [configNavSections, t]); - - const filteredSearchableSections = useMemo(() => { - const query = searchValue.trim(); - if (!query) { - return searchableSections; - } - - const normalizedQuery = query.toLocaleLowerCase(); - - return searchableSections.reduce( - (accumulator, option) => { - const matchedEntry = option.searchableContent.find((entry) => - entry.toLocaleLowerCase().includes(normalizedQuery), - ); - - if (!matchedEntry) { - return accumulator; - } - - accumulator.push({ - ...option, - matchedContext: buildMatchSnippet(matchedEntry, query), - }); - - return accumulator; - }, - [], - ); - }, [searchValue, searchableSections]); - - const handleSearchNavigation = useCallback( - async (value: string | null) => { - if (!value) return; - if (!VALID_NAV_KEYS.includes(value as NavKey)) return; - await onNavigate(value as NavKey); - setSearchValue(""); - }, - [onNavigate], - ); - - return ( - { @@ -469,6 +478,7 @@ const GeneralSection: React.FC = ({
= ({ {/* Language */}
= ({
= ({ />
= ({ />
= ({ }} >
- + {t("settings.general.defaultViewerZoom", "Default reader zoom")} @@ -633,6 +647,7 @@ const GeneralSection: React.FC = ({
value && onParameterChange("alphabet", value)} data={alphabetOptions} diff --git a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx index c8c20cf607..389cd79da6 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx @@ -104,6 +104,11 @@ export default function AutomationImportModal({ } }; + const dropzoneLabel = t( + "automate.importModal.dropzoneAriaLabel", + "Drop an automation JSON file here", + ); + const formatLabel = parsed?.format === "automate" ? t("automate.importModal.detectedAutomation", "Automate JSON") @@ -133,10 +138,10 @@ export default function AutomationImportModal({ accept={["application/json", "text/plain"]} multiple={false} maxSize={10 * 1024 * 1024} - aria-label={t( - "automate.importModal.dropzoneAriaLabel", - "Drop an automation JSON file here", - )} + aria-label={dropzoneLabel} + // Dropzone's own aria-label lands on the wrapper; the hidden file + // input it renders needs naming separately. + inputProps={{ "aria-label": dropzoneLabel }} > {parsed.unresolvedOperations.length > 0 && ( - + {t("automate.importModal.unresolved", "Unmapped: {{ops}}", { ops: parsed.unresolvedOperations.join(", "), })} diff --git a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx index 4f63a80d3c..2e3e5f1539 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx @@ -216,7 +216,7 @@ export default function AutomationRun({ {step.name} {step.error && ( - + {step.error} )} diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx index 1fbf447a21..ed0f96bb2e 100644 --- a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx +++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -70,7 +70,7 @@ const BookletImpositionSettings = ({ {/* Manual Duplex Pass Selection - only show when double-sided is OFF */} {!parameters.doubleSided && ( - + {t("bookletImposition.manualDuplex.title", "Manual Duplex Mode")} @@ -97,7 +97,7 @@ const BookletImpositionSettings = ({ disabled={disabled} /> - + {parameters.duplexPass === "FIRST" ? t( "bookletImposition.duplexPass.firstInstructions", diff --git a/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx index f11e24596c..43a2ef9d36 100644 --- a/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx +++ b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx @@ -249,7 +249,7 @@ export const CertificateConfigModal: React.FC = ({ fontSize="small" style={{ color: "var(--mantine-color-green-6)" }} /> - + {t( "certSign.collab.signRequest.certModal.certValidUntil", "Certificate valid until {{date}}", @@ -271,7 +271,7 @@ export const CertificateConfigModal: React.FC = ({ fontSize="small" style={{ color: "var(--mantine-color-red-6)" }} /> - + {t( "certSign.collab.signRequest.certModal.certInvalid", "Certificate invalid: {{error}}", diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx index 15fe50eb90..2a39694dc9 100644 --- a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx @@ -338,7 +338,9 @@ export default function SignControlsPanel({ }} /> ) : ( - + // Sits on the white signature sheet in both schemes, so it takes a fixed + // accent ink rather than the scheme-dependent one. + {t("certSign.collab.signRequest.preview.create", "Add signature")} diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx index 0f21335833..b412a11d90 100644 --- a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx @@ -318,7 +318,7 @@ const SignRequestPanel = ({ data }: SignRequestPanelProps) => { fullWidth style={{ backgroundColor: "var(--c-surface-raised)", - color: "var(--c-primary)", + color: "var(--c-accent-text)", border: "1px solid var(--c-border)", }} > diff --git a/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx index bde8102d19..f858c1b0fd 100644 --- a/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx +++ b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx @@ -135,7 +135,7 @@ export const AddSignaturesStep: React.FC = ({ {placementMode && ( - + {t( "certSign.collab.signRequest.steps.clickMultipleTimes", "Click on the PDF multiple times to place signatures. Drag any signature to move or resize it.", diff --git a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx index 2eff20b23b..07ba4e7e05 100644 --- a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx +++ b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx @@ -1,5 +1,7 @@ +import { useContext, useEffect, useState } from "react"; import { Stack, Divider, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { ViewerContext } from "@app/contexts/ViewerContext"; import { ChangeMetadataParameters, createCustomMetadataFunctions, @@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps { disabled?: boolean; } +/** + * Pre-fills the form from the currently open document's existing metadata. + * Isolated in its own component so it only mounts where a ViewerProvider exists + * (the editor and the in-editor Automate modal). The pipeline builder has no + * viewer and no single "current document", so it is skipped there rather than + * crashing on useViewer. + */ +const MetadataPrefill = ({ + onParameterChange, + onExtractingChange, +}: { + onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"]; + onExtractingChange: (extracting: boolean) => void; +}) => { + const { isExtractingMetadata } = useMetadataExtraction({ + updateParameter: onParameterChange, + }); + + useEffect(() => { + onExtractingChange(isExtractingMetadata); + }, [isExtractingMetadata, onExtractingChange]); + + return null; +}; + const ChangeMetadataSingleStep = ({ parameters, onParameterChange, @@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({ }: ChangeMetadataSingleStepProps) => { const { t } = useTranslation(); + // Auto-prefill reads the viewer/file contexts, which only exist in the editor. + // Gate on the viewer so the pipeline builder renders the fields without it. + const hasViewerContext = useContext(ViewerContext) !== null; + const [isExtractingMetadata, setIsExtractingMetadata] = useState(false); + // Get custom metadata functions using the utility const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions(parameters, onParameterChange); - // Extract metadata from uploaded files - const { isExtractingMetadata } = useMetadataExtraction({ - updateParameter: onParameterChange, - }); - const isDeleteAllEnabled = parameters.deleteAll; const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata; return ( - - {/* Delete All */} - - - {t("changeMetadata.deleteAll.label", "Delete All Metadata")} - - + {hasViewerContext && ( + - - - - - {/* Standard Metadata Fields */} + )} - - {t("changeMetadata.standardFields.title", "Standard Metadata")} - - + {/* Delete All */} + + + {t("changeMetadata.deleteAll.label", "Delete All Metadata")} + + + + + + + {/* Standard Metadata Fields */} + + + {t("changeMetadata.standardFields.title", "Standard Metadata")} + + + + + + + {/* Document Dates */} + + + {t("changeMetadata.dates.title", "Document Dates")} + + + + + + + {/* Advanced Options */} + + + {t("changeMetadata.advanced.title", "Advanced Options")} + + + - - - - {/* Document Dates */} - - - {t("changeMetadata.dates.title", "Document Dates")} - - - - - - - {/* Advanced Options */} - - - {t("changeMetadata.advanced.title", "Advanced Options")} - - - - + ); }; diff --git a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx index d6e3c55f71..d45ccd75a0 100644 --- a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx @@ -188,7 +188,7 @@ const ComparePixelWorkbenchView = ({ {result.warnings.length > 0 && ( {result.warnings.map((w, i) => ( - + {w} ))} diff --git a/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx b/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx index f0cd39b2c0..e083da5ca2 100644 --- a/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx +++ b/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx @@ -116,6 +116,7 @@ const CompressSettings = ({ style={{ flex: 1 }} /> onParameterChange("pdfaOptions", { diff --git a/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx index 61300fbb68..15730c35f3 100644 --- a/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx +++ b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx @@ -70,7 +70,7 @@ const MergeFileSorter: React.FC = ({ = ({ keep the workspace file picker; + // absent (portal) -> fall back to the plain file input below. + const filesModal = useContext(FilesModalContext); + // Clears the FilePicker so the same file can be re-selected (Mantine resetRef). + const resetOverlayPicker = useRef<() => void>(null); const handleOverlayFilesChange = (files: File[]) => { onParameterChange("overlayFiles", files); @@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({ }; const handleOpenOverlayFilesModal = () => { - if (disabled) return; - openFilesModal({ + if (disabled || !filesModal) return; + filesModal.openFilesModal({ customHandler: (files: File[]) => { handleOverlayFilesChange([ ...(parameters.overlayFiles || []), @@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({ }); }; + const appendOverlayFiles = (files: File[]) => { + if (files.length === 0) return; + handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]); + resetOverlayPicker.current?.(); + }; + + const overlayFilesButtonLabel = + parameters.overlayFiles?.length > 0 + ? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...") + : t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)..."); + return ( @@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({ {t("overlay-pdfs.overlayFiles.label", "Overlay Files")} - + {filesModal ? ( + + ) : ( + } + fullWidth + > + {overlayFilesButtonLabel} + + )} {parameters.overlayFiles?.length > 0 && (() => { diff --git a/frontend/editor/src/core/components/tools/pdfTextEditor/FontStatusPanel.tsx b/frontend/editor/src/core/components/tools/pdfTextEditor/FontStatusPanel.tsx index 1336997c6f..cc140759bb 100644 --- a/frontend/editor/src/core/components/tools/pdfTextEditor/FontStatusPanel.tsx +++ b/frontend/editor/src/core/components/tools/pdfTextEditor/FontStatusPanel.tsx @@ -167,7 +167,7 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => { {/* Warnings */} {analysis.warnings.length > 0 && ( - + {t("pdfTextEditor.fontAnalysis.warnings", "Warnings")}: @@ -183,7 +183,7 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => { {/* Suggestions */} {analysis.suggestions.length > 0 && ( - + {t("pdfTextEditor.fontAnalysis.suggestions", "Notes")}: diff --git a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx index 1d314fec14..714f163610 100644 --- a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx +++ b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -1691,7 +1691,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { > {t("pdfTextEditor.empty.title", "No document loaded")} diff --git a/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.tsx b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.tsx index b3e2f43ef7..d8fc69c237 100644 --- a/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.tsx +++ b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { Stack, Text, @@ -25,6 +26,11 @@ const RemoveBlanksSettings = ({ disabled = false, }: RemoveBlanksSettingsProps) => { const { t } = useTranslation(); + const whitePercentLabelId = useId(); + const whitePercentLabel = t( + "removeBlanks.whitePercent.label", + "White Percent", + ); return ( @@ -46,11 +52,12 @@ const RemoveBlanksSettings = ({ - - {t("removeBlanks.whitePercent.label", "White Percent")} + + {whitePercentLabel} onParameterChange("whitePercent", typeof v === "number" ? v : 0.1) @@ -71,6 +78,8 @@ const RemoveBlanksSettings = ({ step={0.1} style={{ flex: 1 }} disabled={disabled} + // The thumb is a div, so the heading above cannot name it. + thumbLabel={whitePercentLabel} /> diff --git a/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx index 36fe99a6b0..82cf3af897 100644 --- a/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx +++ b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { Stack, Text, Select, ColorInput } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ReplaceColorParameters } from "@app/hooks/tools/replaceColor/useReplaceColorParameters"; @@ -18,6 +19,10 @@ const ReplaceColorSettings = ({ disabled = false, }: ReplaceColorSettingsProps) => { const { t } = useTranslation(); + const operationLabelId = useId(); + const highContrastLabelId = useId(); + const textColorLabelId = useId(); + const backgroundColorLabelId = useId(); const replaceAndInvertOptions = [ { @@ -60,10 +65,11 @@ const ReplaceColorSettings = ({ return ( - + {t("replaceColor.labels.colourOperation", "Colour operation")} value && @@ -108,10 +115,11 @@ const ReplaceColorSettings = ({ {parameters.replaceAndInvertOption === "CUSTOM_COLOR" && ( <> - + {t("replace-color.selectText.10", "Choose text Color")} onParameterChange("textColor", value)} format="hex" @@ -124,10 +132,11 @@ const ReplaceColorSettings = ({ - + {t("replace-color.selectText.11", "Choose background Color")} onParameterChange("backGroundColor", value)} format="hex" diff --git a/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.tsx b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.tsx index 46c27ad57f..2b5af35ba2 100644 --- a/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.tsx +++ b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useId, useState, useEffect } from "react"; import { Stack, Text, NumberInput } from "@mantine/core"; interface NumberInputWithUnitProps { @@ -20,6 +20,7 @@ const NumberInputWithUnit = ({ max, disabled = false, }: NumberInputWithUnitProps) => { + const labelId = useId(); const [localValue, setLocalValue] = useState(value); // Sync local value when external value changes @@ -34,6 +35,7 @@ const NumberInputWithUnit = ({ return ( {unit} diff --git a/frontend/editor/src/core/components/tools/shared/ToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ToolStep.tsx index 5b261d4883..790775f13e 100644 --- a/frontend/editor/src/core/components/tools/shared/ToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ToolStep.tsx @@ -109,9 +109,8 @@ const ToolStep = ({
{/* Chevron icon to collapse/expand the step */} diff --git a/frontend/editor/src/core/components/tools/showJS/ShowJSView.css b/frontend/editor/src/core/components/tools/showJS/ShowJSView.css index 7237fe7a3b..fa055e69d6 100644 --- a/frontend/editor/src/core/components/tools/showJS/ShowJSView.css +++ b/frontend/editor/src/core/components/tools/showJS/ShowJSView.css @@ -129,7 +129,7 @@ .showjs-outline-button { background: transparent; border: 1px solid currentColor; - color: var(--mantine-color-blue-5); + color: var(--c-accent-text); } .showjs-scrollarea { diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx new file mode 100644 index 0000000000..c8607d55c4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx @@ -0,0 +1,176 @@ +/** + * Receive-flow contract for the phone-signature QR modal. + * + * The transfer session's upload endpoint accepts any file from anyone holding + * the QR URL, so the modal must treat arrivals as untrusted. Images become + * draw/photo payloads by filename prefix, a signature-text JSON payload is + * parsed and clamped field by field, and anything else is ignored. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import apiClient from "@app/services/apiClient"; +import { expectConsole } from "@app/tests/failOnConsole"; + +// Render the English fallbacks (the test i18n instance has no loaded locale). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +vi.mock("@app/services/apiClient", () => ({ + default: { + defaults: { baseURL: "http://localhost:8080" }, + post: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { enableMobileSignature: true } }), +})); + +const mockedApi = vi.mocked(apiClient, true); + +const SESSION_INFO = { + sessionId: "s", + createdAt: Date.now(), + expiresAt: Date.now() + 600_000, + timeoutMs: 600_000, +}; + +function primeSession( + files: Array<{ filename: string; contentType: string; body?: string }>, +) { + mockedApi.post.mockResolvedValue({ + status: 200, + data: SESSION_INFO, + } as never); + mockedApi.delete.mockResolvedValue({ status: 200 } as never); + mockedApi.get.mockImplementation(((url: string, config?: unknown) => { + if (url.includes("/files/")) { + return Promise.resolve({ status: 200, data: { files } } as never); + } + if (url.includes("/download/")) { + const filename = url.split("/").pop() ?? ""; + const meta = files.find((f) => f.filename === filename); + return Promise.resolve({ + status: 200, + data: new Blob([meta?.body ?? "fake-bytes"], { + type: meta?.contentType, + }), + config, + } as never); + } + return Promise.reject(new Error(`unexpected GET ${url}`)); + }) as never); +} + +function renderModal( + onSignatureReceived: (payload: MobileSignaturePayload) => void, + onClose: () => void, +) { + return render( + + + , + ); +} + +describe("MobileSignatureModal", () => { + // clearAllMocks (not restoreAllMocks): the hook's unmount cleanup still + // calls apiClient.delete during test teardown, so implementations must + // survive until React Testing Library's auto-cleanup has unmounted. + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hands a drawn signature to the caller as a draw payload and closes", async () => { + primeSession([ + { filename: "signature-draw-1.png", contentType: "image/png" }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + const payload = onSignatureReceived.mock.calls[0][0]; + expect(payload.kind).toBe("draw"); + expect(payload.dataUrl).toMatch(/^data:image\/png/); + expect(onClose).toHaveBeenCalled(); + }); + + it("routes a photographed signature as a photo payload", async () => { + primeSession([ + { filename: "signature-photo-1.jpg", contentType: "image/jpeg" }, + ]); + const onSignatureReceived = vi.fn(); + + renderModal(onSignatureReceived, vi.fn()); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0].kind).toBe("photo"); + }); + + it("parses a typed signature as text, clamping unknown font and colour", async () => { + primeSession([ + { + filename: "signature-text-1.json", + contentType: "application/json", + body: JSON.stringify({ + text: " Reece ", + fontFamily: "Wingdings", + color: "javascript:alert(1)", + }), + }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0]).toEqual({ + kind: "text", + text: "Reece", + fontFamily: "Helvetica", + color: "#000000", + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("ignores a non-image upload instead of setting it as the signature", async () => { + // Rejecting the upload logs a warning - that's the contract under test. + expectConsole.warn(/Ignoring non-image upload/); + primeSession([{ filename: "evil.html", contentType: "text/html" }]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + // The poll + download cycle must have run before we assert the negative. + await waitFor(() => + expect( + mockedApi.get.mock.calls.some(([url]) => + String(url).includes("/download/"), + ), + ).toBe(true), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onSignatureReceived).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx new file mode 100644 index 0000000000..82deae8b5f --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx @@ -0,0 +1,155 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import MobileTransferModal from "@app/components/shared/MobileTransferModal"; + +/** + * What the phone sent, routed to the matching signature source: ink and + * photos as pixels, typed signatures as data so they stay editable text. + */ +export type MobileSignaturePayload = + | { kind: "draw"; dataUrl: string } + | { kind: "photo"; dataUrl: string } + | { kind: "text"; text: string; fontFamily: string; color: string }; + +/** Fonts the sign tool's text mode offers; anything else falls back. */ +const TEXT_FONTS = new Set([ + "Helvetica", + "Times-Roman", + "Courier", + "Arial", + "Georgia", +]); +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; +const MAX_TEXT_LENGTH = 200; + +interface MobileSignatureModalProps { + opened: boolean; + onClose: () => void; + onSignatureReceived: (payload: MobileSignaturePayload) => void; +} + +/** FileReader-based (rather than File.text/arrayBuffer, absent in jsdom). */ +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +/** + * QR modal for creating a signature on a phone or tablet. The phone opens the + * public `/mobile-sign` page; the first valid arrival becomes the signature + * and the modal closes. + */ +export default function MobileSignatureModal({ + opened, + onClose, + onSignatureReceived, +}: MobileSignatureModalProps) { + const { t } = useTranslation(); + + // The session endpoints accept any upload from anyone holding the QR URL, + // so nothing here is trusted: images pass as pixels, a typed signature is + // parsed and clamped field by field, everything else is ignored. + const handleFileReceived = useCallback( + async (file: File) => { + if ( + file.type === "application/json" && + file.name.startsWith("signature-text") + ) { + try { + const parsed: unknown = JSON.parse(await readFileAsText(file)); + const record = parsed as Record; + const text = + typeof record?.text === "string" + ? record.text.trim().slice(0, MAX_TEXT_LENGTH) + : ""; + if (!text) return; + onSignatureReceived({ + kind: "text", + text, + fontFamily: TEXT_FONTS.has(record.fontFamily as string) + ? (record.fontFamily as string) + : "Helvetica", + color: HEX_COLOR.test(record.color as string) + ? (record.color as string) + : "#000000", + }); + onClose(); + } catch { + console.warn( + "[MobileSignatureModal] Ignoring malformed text payload", + ); + } + return; + } + + if (!file.type.startsWith("image/")) { + console.warn( + "[MobileSignatureModal] Ignoring non-image upload:", + file.type, + ); + return; + } + + await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result; + if (typeof dataUrl === "string") { + onSignatureReceived({ + kind: file.name.startsWith("signature-photo") ? "photo" : "draw", + dataUrl, + }); + onClose(); + } + resolve(); + }; + reader.onerror = () => resolve(); + reader.readAsDataURL(file); + }); + }, + [onSignatureReceived, onClose], + ); + + return ( + + t( + "sign.mobile.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds }, + ) + } + errorTitle={t("sign.mobile.error", "Connection Error")} + sessionCreateErrorMessage={t( + "sign.mobile.sessionCreateError", + "Failed to create session", + )} + pollingErrorMessage={t( + "sign.mobile.pollingError", + "Error checking for the signature", + )} + /> + ); +} diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index 648ada1c47..4aa9f8ffd3 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -34,6 +34,11 @@ import { AddSignatureResult, } from "@app/hooks/tools/sign/useSavedSignatures"; import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { buildSignaturePreview } from "@app/utils/signaturePreview"; type SignatureDrafts = { @@ -116,6 +121,12 @@ const SignSettings = ({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); + const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobileViewport = useIsMobile(); + // Drawing on a phone needs a second device, so the QR entry is desktop-only. + const canDrawOnPhone = + Boolean(config?.enableMobileSignature) && !isMobileViewport; // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState< @@ -625,6 +636,71 @@ const SignSettings = ({ [onActivateSignaturePlacement], ); + // Route a signature made on a phone to the matching source, mirroring + // handleUseSavedSignature: ink is a canvas signature, a photo is an image + // signature, and typed text stays editable text rather than baked pixels. + const handleMobileSignatureReceived = useCallback( + (payload: MobileSignaturePayload) => { + // Receiving a signature is as clear an intent to place it as drawing + // one, so placement goes live even if it was paused beforehand. + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + if (payload.kind === "draw") { + if (parameters.signatureType !== "canvas") { + onParameterChange("signatureType", "canvas"); + } + handleCanvasSignatureChange(payload.dataUrl); + } else if (payload.kind === "photo") { + if (parameters.signatureType !== "image") { + onParameterChange("signatureType", "image"); + } + setImageSignatureData(payload.dataUrl); + } else { + if (parameters.signatureType !== "text") { + onParameterChange("signatureType", "text"); + } + onParameterChange("signerName", payload.text); + onParameterChange("fontFamily", payload.fontFamily); + onParameterChange("textColor", payload.color); + // Move the draft mirror in the same commit as the parameters. The + // record/restore effect pair otherwise sees them one commit apart and + // ping-pongs old draft against new params, wiping the received text + // and looping until React aborts the update depth. + const nextDraft = { + signerName: payload.text, + fontSize: parameters.fontSize ?? 16, + fontFamily: payload.fontFamily, + textColor: payload.color, + }; + lastSyncedTextDraft.current = nextDraft; + setSignatureDrafts((prev) => ({ ...prev, text: nextDraft })); + } + // Activate directly for every kind: the canvas-change handler only + // activates when the data actually changed, and the auto-activate + // effect only reacts to state transitions - neither fires for a + // repeat of the same signature. Fired twice because the first shot can + // land between the receive commit and the ready-state settling, where + // the placement effect immediately deactivates it; the second shot is + // after everything has settled, and re-activating is idempotent. + if (typeof window !== "undefined") { + window.setTimeout( + () => onActivateSignaturePlacement?.(), + PLACEMENT_ACTIVATION_DELAY, + ); + window.setTimeout(() => onActivateSignaturePlacement?.(), 500); + } else { + onActivateSignaturePlacement?.(); + } + }, + [ + parameters.signatureType, + parameters.fontSize, + onParameterChange, + handleCanvasSignatureChange, + onActivateSignaturePlacement, + ], + ); + const hasCanvasSignature = useMemo( () => Boolean(canvasSignatureData), [canvasSignatureData], @@ -980,6 +1056,29 @@ const SignSettings = ({ if (signatureSource === "image") { return ( + {imageSignatureData && ( + + {translate("image.previewAlt", + + )} + {canDrawOnPhone && ( + <> + + setIsMobileSignModalOpen(false)} + onSignatureReceived={handleMobileSignatureReceived} + /> + + )} {sourceOptions.length > 1 && ( = ({ return ( {signature.errorMessage && ( - + {signature.errorMessage} )} diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx index 773eb81c8f..04431e3bad 100644 --- a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx @@ -18,35 +18,40 @@ const SignatureStatusBadge = ({ neutral: "status-badge status-badge--neutral", } as const; + // With no details there is nothing to open, so the badge stays a plain label. + // Popover.Target stamps aria-haspopup/aria-expanded onto whatever it wraps, + // and those are only permitted on an element that is actually a control. + if (status.details.length === 0) { + return ( + + {status.label} + + ); + } + return ( - + {status.label} - {status.details.length > 0 && ( - - - {t("details", "Details")} + + + {t("details", "Details")} + + {status.details.map((d, i) => ( + + - {d} - {status.details.map((d, i) => ( - - - {d} - - ))} - - )} + ))} + ); }; diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.css b/frontend/editor/src/core/components/viewer/AttachmentSidebar.css index 1afd184bc6..f4396b312f 100644 --- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.css +++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.css @@ -55,7 +55,7 @@ } .attachment-item:hover .attachment-item__download-icon { - color: var(--mantine-color-blue-6); + color: var(--c-accent-text); transform: scale(1.1); } diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx index 4dcc61fe2b..ee8863ac5f 100644 --- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import { Box, ScrollArea, Text, Loader, Stack, TextInput } from "@mantine/core"; +import { Text, Loader, Stack } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; @@ -9,7 +9,7 @@ import { PdfAttachmentObject } from "@embedpdf/models"; import AttachmentIcon from "@mui/icons-material/AttachmentRounded"; import DownloadIcon from "@mui/icons-material/DownloadRounded"; import { useTranslation } from "react-i18next"; -import "@app/components/viewer/SidebarBase.css"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; import "@app/components/viewer/AttachmentSidebar.css"; interface AttachmentSidebarProps { @@ -20,8 +20,6 @@ interface AttachmentSidebarProps { preloadCacheKeys?: string[]; } -const SIDEBAR_WIDTH = "15rem"; - interface AttachmentCacheEntry { status: "idle" | "loading" | "success" | "error"; attachments: PdfAttachmentObject[] | null; @@ -360,179 +358,129 @@ export const AttachmentSidebar = ({ const showNoDocument = attachmentSupport && !documentCacheKey; return ( - } + rightOffset={`${(thumbnailVisible ? 15 : 0) + (bookmarkVisible ? 15 : 0)}rem`} + visible={visible} + onClose={toggleAttachmentSidebar} + closeLabel={t( + "viewer.attachments.closeSidebar", + "Close attachments sidebar", + )} + searchTerm={searchTerm} + searchPlaceholder={t( + "viewer.attachments.searchPlaceholder", + "Search attachments", + )} + onSearchChange={setSearchTerm} > -
-
- - - - - {t("viewer.attachments.title", "Attachments")} + {!attachmentSupport && ( +
+ + {t( + "viewer.attachments.noSupport", + "Attachment support is unavailable for this viewer.", + )}
- - + + {t( + "viewer.attachments.noDocument", + "Open a PDF to view its attachments.", )} - title={t("viewer.attachments.close", "Close attachments")} + +
+ )} + + {attachmentSupport && documentCacheKey && currentError && ( + + + {currentError} + + - + - -
+
+ )} - - setSearchTerm(event.currentTarget.value)} - leftSection={ - - } - size="xs" - /> - - - - - {!attachmentSupport && ( -
- - {t( - "viewer.attachments.noSupport", - "Attachment support is unavailable for this viewer.", - )} - -
- )} + + + {t("viewer.attachments.loading", "Loading attachments...")} + + + )} - {attachmentSupport && showNoDocument && ( -
- - {t( - "viewer.attachments.noDocument", - "Open a PDF to view its attachments.", - )} - -
- )} + {showEmptyState && ( + + + + {t("viewer.attachments.empty", "No attachments in this document")} + + + + )} - {attachmentSupport && documentCacheKey && currentError && ( - - - {currentError} - - - - - - )} + {showAttachmentList && ( + <> + +
+ {renderAttachments(filteredAttachments)} +
+ + )} - {attachmentSupport && documentCacheKey && isLocalLoading && ( - - - - {t("viewer.attachments.loading", "Loading attachments...")} - - - )} - - {showEmptyState && ( - - - - {t( - "viewer.attachments.empty", - "No attachments in this document", - )} - - - - )} - - {showAttachmentList && ( - <> - -
- {renderAttachments(filteredAttachments)} -
- - )} - - {showSearchEmpty && ( -
- - {t( - "viewer.attachments.noMatch", - "No attachments match your search", - )} - -
- )} -
-
- + {showSearchEmpty && ( +
+ + {t( + "viewer.attachments.noMatch", + "No attachments match your search", + )} + +
+ )} + ); }; diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index 2acd3e309d..9c92730077 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { Box, - ScrollArea, Text, Loader, Stack, @@ -18,10 +17,12 @@ import { useFileContext } from "@app/contexts/FileContext"; import { isStirlingFile, type FileId } from "@app/types/fileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import apiClient from "@app/services/apiClient"; +import { openExternalTab } from "@app/platform/openExternalTab"; +import { getExternalHref } from "@app/utils/externalUrl"; import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models"; import { useTranslation } from "react-i18next"; import BookmarksIcon from "@mui/icons-material/BookmarksRounded"; -import "@app/components/viewer/SidebarBase.css"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; import "@app/components/viewer/BookmarkSidebar.css"; interface BookmarkSidebarProps { @@ -31,8 +32,6 @@ interface BookmarkSidebarProps { preloadCacheKeys?: string[]; } -const SIDEBAR_WIDTH = "15rem"; - type BookmarkNode = PdfBookmarkObject & { id: string }; type BookmarkCacheStatus = "idle" | "loading" | "success" | "error"; @@ -77,6 +76,17 @@ const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => { return null; }; +// Bookmark targets are PDF-supplied, so sanitise before opening. Local paths +// from LaunchAppOrOpenFile fail the allowlist - a browser blocks them anyway. +const openBookmarkTarget = (rawUrl: string): void => { + const href = getExternalHref(rawUrl); + if (!href) { + console.warn("[BookmarkSidebar] Blocked unsafe URL:", rawUrl); + return; + } + void openExternalTab(href); +}; + export const BookmarkSidebar = ({ visible, thumbnailVisible, @@ -518,12 +528,12 @@ export const BookmarkSidebar = ({ const action = target.action; if (action.type === PdfActionType.URI && action.uri) { event.preventDefault(); - window.open(action.uri, "_blank", "noopener"); + openBookmarkTarget(action.uri); return; } if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) { event.preventDefault(); - window.open(action.path, "_blank", "noopener"); + openBookmarkTarget(action.path); return; } } @@ -666,285 +676,208 @@ export const BookmarkSidebar = ({ if (!visible) { return null; } - - return ( - -
-
- - - - - Bookmarks - -
- - {bookmarkSupport && bookmarksWithIds.length > 0 && ( - <> - {Object.values(expanded).some((val) => val === false) ? ( - - - - ) : ( - - - - )} - - )} + const expandCollapseActions = + bookmarkSupport && bookmarksWithIds.length > 0 ? ( + <> + {Object.values(expanded).some((val) => val === false) ? ( - + - -
+ ) : ( + + + + )} + + ) : null; - - setSearchTerm(event.currentTarget.value)} - leftSection={ - - } - size="xs" - /> - + return ( + } + rightOffset={`${thumbnailVisible ? 15 : 0}rem`} + visible={visible} + onClose={toggleBookmarkSidebar} + closeLabel={t("viewer.bookmarks.closeSidebar", "Close bookmarks sidebar")} + headerActions={expandCollapseActions} + searchTerm={searchTerm} + searchPlaceholder={t( + "viewer.bookmarks.searchPlaceholder", + "Search bookmarks", + )} + onSearchChange={setSearchTerm} + > + {bookmarkSupport && showNoDocument && ( +
+ + Open a PDF to view its bookmarks. + +
+ )} - - - {!bookmarkSupport && ( -
- - Bookmark support is unavailable for this viewer. - -
- )} + {bookmarkSupport && documentCacheKey && currentError && ( + + + {currentError} + + + + )} - {bookmarkSupport && showNoDocument && ( -
- - Open a PDF to view its bookmarks. - -
- )} + {bookmarkSupport && documentCacheKey && isLocalLoading && ( + + + + Loading bookmarks... + + + )} + {showEmptyState && !isAddingBookmark && ( + + + + {t("viewer.bookmarks.empty", "No bookmarks in this document")} + + + + )} - {bookmarkSupport && documentCacheKey && currentError && ( - - - {currentError} - - - - )} - - {bookmarkSupport && documentCacheKey && isLocalLoading && ( - - - - Loading bookmarks... - - - )} - {showEmptyState && !isAddingBookmark && ( - - - - No bookmarks in this document - - - - )} - - {isAddingBookmark && ( - - - - Add bookmark - - setNewBookmarkTitle(e.currentTarget.value)} - autoFocus - disabled={isSavingBookmark} - /> - - setNewBookmarkPage(typeof v === "number" ? v : 1) - } - disabled={isSavingBookmark} - /> - {addBookmarkError && ( - - {addBookmarkError} - - )} - - - - - - - )} - {showBookmarkList && ( - <> - {!isAddingBookmark && ( - + {isAddingBookmark && ( + + + + {t("viewer.bookmarks.addBookmark", "Add bookmark")} + + - {renderBookmarks(filteredBookmarks)} -
- - )} - - {showSearchEmpty && ( -
- - No bookmarks match your search + aria-label={t("viewer.bookmarks.bookmarkTitle", "Bookmark title")} + value={newBookmarkTitle} + onChange={(e) => setNewBookmarkTitle(e.currentTarget.value)} + autoFocus + disabled={isSavingBookmark} + /> + + setNewBookmarkPage(typeof v === "number" ? v : 1) + } + disabled={isSavingBookmark} + /> + {addBookmarkError && ( + + {addBookmarkError} -
- )} + )} + + + + +
- + )} + {showBookmarkList && ( + <> + {!isAddingBookmark && ( + + )} +
+ {renderBookmarks(filteredBookmarks)} +
+ + )} + {showSearchEmpty && ( +
+ + No bookmarks match your search + +
+ )} {bookmarkSupport && documentCacheKey && ( )} -
+ ); }; diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index 3bbc5c8f53..9045d3a35c 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -1,7 +1,6 @@ import { useMemo, useState, useCallback, useEffect, useRef } from "react"; import { Box, - ScrollArea, Text, Textarea, Stack, @@ -36,8 +35,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useAnnotation as useAnnotationContext } from "@app/contexts/AnnotationContext"; import LocalIcon from "@app/components/shared/LocalIcon"; import { compareEntriesByVisualOrder } from "@app/components/viewer/commentsSidebarOrder"; - -const SIDEBAR_WIDTH = "18rem"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; /** PDF subtypes that are inherently standalone comment annotations (not linked to other annotations). */ const STANDALONE_COMMENT_SUBTYPES = new Set([ @@ -289,7 +287,7 @@ function AnnotationTypeIcon({ ann }: { ann: PdfAnnotationObject }) { icon={iconName} width="1.25rem" height="1.25rem" - style={{ flexShrink: 0, color: "var(--mantine-color-blue-5)" }} + style={{ flexShrink: 0, color: "var(--c-accent-text)" }} /> ); } @@ -455,18 +453,60 @@ export function CommentsSidebar({ return ids; }, [state]); + const [searchTerm, setSearchTerm] = useState(""); + + const filteredByPage = useMemo(() => { + const query = searchTerm.trim().toLowerCase(); + if (!query) { + return byPage; + } + const result: Record = {}; + for (const [pageStr, entries] of Object.entries(byPage)) { + const matching = entries.filter((entry) => { + const ann = entry.annotation.object; + const contents = (ann.contents || "").toLowerCase(); + const author = (ann.author || "").toLowerCase(); + const replies = entry.replies || []; + const replyMatch = replies.some( + (r) => + (r.object.contents || "").toLowerCase().includes(query) || + (r.object.author || "").toLowerCase().includes(query), + ); + return contents.includes(query) || author.includes(query) || replyMatch; + }); + if (matching.length > 0) { + result[Number(pageStr)] = matching; + } + } + return result; + }, [byPage, searchTerm]); + const pageNumbers = useMemo( + () => + Object.keys(filteredByPage) + .map(Number) + .sort((a, b) => a - b), + [filteredByPage], + ); + + const totalCount = useMemo( () => Object.keys(byPage) .map(Number) - .sort((a, b) => a - b), + .reduce((sum, p) => sum + (byPage[p]?.length ?? 0), 0), [byPage], ); - const totalCount = useMemo( - () => pageNumbers.reduce((sum, p) => sum + (byPage[p]?.length ?? 0), 0), - [pageNumbers, byPage], + + const totalFilteredCount = useMemo( + () => + pageNumbers.reduce((sum, p) => sum + (filteredByPage[p]?.length ?? 0), 0), + [pageNumbers, filteredByPage], ); + const isSearchActive = searchTerm.trim().length > 0; + const showSearchEmpty = + isSearchActive && totalCount > 0 && totalFilteredCount === 0; + const handleContentsChange = useCallback( (pageIndex: number, annotationId: string, value: string) => { setDraftContents((prev) => ({ @@ -670,189 +710,165 @@ export function CommentsSidebar({ if (!visible) return null; - return ( - -
- - - {t("viewer.comments.title", "Comments")} - - {totalCount > 0 && ( - - - - - - - - - - - - - - - - } - color="red" - onClick={() => setClearAllModalOpen(true)} - > - {t("viewer.comments.clearAll", "Clear all comments")} - - - - - )} - {toggleCommentsSidebar && ( + const commentsHeaderActions = + totalCount > 0 ? ( + + - + + + + + + + + + + + + } + color="red" + onClick={() => setClearAllModalOpen(true)} + > + {t("viewer.comments.clearAll", "Clear all comments")} + + + + + ) : null; + + return ( + <> + } + rightOffset={rightOffset} + visible={visible} + onClose={toggleCommentsSidebar} + closeLabel={t("viewer.comments.closeSidebar", "Close comments sidebar")} + headerActions={commentsHeaderActions} + searchTerm={searchTerm} + searchPlaceholder={t( + "viewer.comments.searchPlaceholder", + "Search comments", )} -
- - - {totalCount === 0 ? ( - - - + onSearchChange={setSearchTerm} + viewportRef={scrollViewportRef} + > + {totalCount === 0 ? ( + + + + {t( + "viewer.comments.hint", + "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.", + )} + + {isPlacingComment ? ( + + ) : ( + + )} + + ) : ( + <> + {isPlacingComment ? ( + + ) : ( + + )} + {showSearchEmpty ? ( +
+ {t( - "viewer.comments.placingHint", - "Click a page to place… (cancel)", + "viewer.comments.noMatch", + "No comments match your search", )} - - ) : ( - - )} - - ) : ( - <> - {isPlacingComment ? ( - - ) : ( - - )} - {pageNumbers.map((pageIndex) => { - const entries = byPage[pageIndex] ?? []; + +
+ ) : ( + pageNumbers.map((pageIndex) => { + const entries = filteredByPage[pageIndex] ?? []; const pageNum = pageIndex + 1; return ( @@ -1139,7 +1155,10 @@ export function CommentsSidebar({ ); }} > - + {t( "annotation.editText", "Edit", @@ -1291,11 +1310,11 @@ export function CommentsSidebar({
); - })} - - )} -
- + }) + )} + + )} +
- + ); } diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 39d751790f..187a48ad16 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -1132,7 +1132,7 @@ const EmbedPdfViewerContent = ({ ]); const sidebarWidthRem = 15; - const commentsSidebarWidthRem = 18; + const commentsSidebarWidthRem = 15; const totalRightMargin = (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + @@ -1174,7 +1174,7 @@ const EmbedPdfViewerContent = ({ {!effectiveFile ? (
- + {t( "viewer.error.noFileProvided", "Error: No file provided to viewer", diff --git a/frontend/editor/src/core/components/viewer/LayerSidebar.tsx b/frontend/editor/src/core/components/viewer/LayerSidebar.tsx index 533bb7959b..4f76d0eebc 100644 --- a/frontend/editor/src/core/components/viewer/LayerSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/LayerSidebar.tsx @@ -1,21 +1,12 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { - Box, - ScrollArea, - Text, - Checkbox, - Stack, - Loader, - Tooltip, -} from "@mantine/core"; +import { Text, Checkbox, Stack, Loader, Tooltip } from "@mantine/core"; import LayersIcon from "@mui/icons-material/Layers"; import { ActionIcon } from "@app/ui/ActionIcon"; import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; -import LocalIcon from "@app/components/shared/LocalIcon"; import { useTranslation } from "react-i18next"; import { useViewer } from "@app/contexts/ViewerContext"; -import "@app/components/viewer/SidebarBase.css"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; import "@app/components/viewer/LayerSidebar.css"; import { readPdfLayers, @@ -40,8 +31,6 @@ interface LayerSidebarProps { onLayersDetected?: (hasLayers: boolean) => void; } -const SIDEBAR_WIDTH = "15rem"; - type LoadStatus = "idle" | "loading" | "ready" | "no-layers" | "error"; export function LayerSidebar({ @@ -318,116 +307,91 @@ export function LayerSidebar({ const allVisible = leafIds.every((id) => visibility[id] !== false); const allHidden = leafIds.every((id) => visibility[id] === false); - return ( - - {/* Header */} -
-
- - - - - Layers - - {isApplying && } -
-
- {status === "ready" && leafIds.length > 0 && ( - <> - - - - - - - - )} + const layerHeaderActions = ( + <> + {isApplying && } + {status === "ready" && leafIds.length > 0 && ( + <> - + + + + + + )} + + ); + + return ( + } + rightOffset={`${rightOffset}rem`} + visible={visible} + onClose={toggleLayerSidebar} + closeLabel={t("viewer.layers.closeSidebar", "Close layers sidebar")} + headerActions={layerHeaderActions} + > + {status === "idle" && ( +
+ + Open a PDF to view its layers. +
-
+ )} - {/* Content */} - - - {status === "idle" && ( -
- - Open a PDF to view its layers. - -
- )} + {status === "loading" && ( + + + + Loading layers... + + + )} - {status === "loading" && ( - - - - Loading layers... - - - )} + {status === "error" && ( +
+ + {loadError ?? "Failed to load layers."} + +
+ )} - {status === "error" && ( -
- - {loadError ?? "Failed to load layers."} - -
- )} + {status === "no-layers" && ( +
+ + This document has no layers. + +
+ )} - {status === "no-layers" && ( -
- - This document has no layers. - -
- )} - - {status === "ready" && layers.length > 0 && ( -
- {layers.map((layer) => renderLayer({ ...layer, depth: 0 }))} -
- )} -
-
- + {status === "ready" && layers.length > 0 && ( +
+ {layers.map((layer) => renderLayer({ ...layer, depth: 0 }))} +
+ )} + ); } diff --git a/frontend/editor/src/core/components/viewer/LinkLayer.tsx b/frontend/editor/src/core/components/viewer/LinkLayer.tsx index e1a2aeb8f0..e68c541fd8 100644 --- a/frontend/editor/src/core/components/viewer/LinkLayer.tsx +++ b/frontend/editor/src/core/components/viewer/LinkLayer.tsx @@ -19,6 +19,9 @@ import { import { Z_INDEX_VIEWER_FLOATING_MENU } from "@app/styles/zIndex"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; +import { openExternalTab } from "@app/platform/openExternalTab"; +import { getExternalHref } from "@app/utils/externalUrl"; + // --------------------------------------------------------------------------- // Inline SVG icons (thin-stroke, modern) // --------------------------------------------------------------------------- @@ -401,19 +404,11 @@ export const LinkLayer: React.FC = ({ behavior: "smooth", }); } else if (action.type === PdfActionType.URI) { - const uri = action.uri; - try { - const url = new URL(uri, window.location.href); - if (["http:", "https:", "mailto:"].includes(url.protocol)) { - window.open(uri, "_blank", "noopener,noreferrer"); - } else { - console.warn( - "[LinkLayer] Blocked unsafe URL protocol:", - url.protocol, - ); - } - } catch { - window.open(uri, "_blank", "noopener,noreferrer"); + const href = getExternalHref(action.uri); + if (href) { + void openExternalTab(href); + } else { + console.warn("[LinkLayer] Blocked unsafe URL:", action.uri); } } } @@ -513,6 +508,11 @@ export const LinkLayer: React.FC = ({ const top = annotationLink.rect.origin.y * scale; const width = annotationLink.rect.size.width * scale; const height = annotationLink.rect.size.height * scale; + const externalHref = + annotationLink.target?.type === "action" && + annotationLink.target.action.type === PdfActionType.URI + ? getExternalHref(annotationLink.target.action.uri) + : null; return ( = ({ linkElementRefs.current.delete(annotationLink.id); } }} - href="#" + href={externalHref ?? "#"} + target={externalHref ? "_blank" : undefined} + rel={externalHref ? "noopener noreferrer" : undefined} onClick={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index 91c0dcf6a8..e1084e2772 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -486,7 +486,11 @@ export function LocalEmbedPDF({
āŒ
- + Error loading PDF engine: {error.message}
diff --git a/frontend/editor/src/core/components/viewer/SidebarBase.css b/frontend/editor/src/core/components/viewer/SidebarBase.css index 8e7771dead..fec3dbc63d 100644 --- a/frontend/editor/src/core/components/viewer/SidebarBase.css +++ b/frontend/editor/src/core/components/viewer/SidebarBase.css @@ -40,7 +40,7 @@ display: flex; align-items: center; justify-content: center; - color: var(--mantine-color-blue-6); + color: var(--c-accent-text); font-size: 1.1rem; } diff --git a/frontend/editor/src/core/components/viewer/SidebarBase.tsx b/frontend/editor/src/core/components/viewer/SidebarBase.tsx new file mode 100644 index 0000000000..7cdcc34d97 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SidebarBase.tsx @@ -0,0 +1,121 @@ +import type { ReactNode } from "react"; +import { Box, ScrollArea, Text, TextInput } from "@mantine/core"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import "@app/components/viewer/SidebarBase.css"; + +export const SIDEBAR_WIDTH = "15rem"; + +export interface SidebarBaseProps { + /** Sidebar title string or React element. */ + title: ReactNode; + /** Header icon (ReactNode or string icon name for LocalIcon). */ + icon: ReactNode; + /** Right offset position string (e.g. "15rem" or "0rem"). */ + rightOffset?: string; + /** Sidebar visibility flag. */ + visible?: boolean; + /** Additional CSS class names. */ + className?: string; + /** Callback fired when user clicks the header close button. */ + onClose?: () => void; + /** Accessible label for the close button. */ + closeLabel?: string; + /** Extra buttons/elements to render in the header right actions area. */ + headerActions?: ReactNode; + /** Current search input term. */ + searchTerm?: string; + /** Search input placeholder text. */ + searchPlaceholder?: string; + /** Callback fired when search query changes. */ + onSearchChange?: (value: string) => void; + /** Optional ref for the ScrollArea viewport element. */ + viewportRef?: React.Ref; + /** Sidebar content children. */ + children: ReactNode; +} + +export function SidebarBase({ + title, + icon, + rightOffset = "0rem", + visible = true, + className = "", + onClose, + closeLabel = "Close sidebar", + headerActions, + searchTerm, + searchPlaceholder, + onSearchChange, + viewportRef, + children, +}: SidebarBaseProps) { + if (!visible) { + return null; + } + + const renderIcon = + typeof icon === "string" ? ( + + ) : ( + icon + ); + + return ( + +
+
+ {renderIcon} + + {title} + +
+ + {headerActions} + {onClose && ( + + + + )} + +
+ + {onSearchChange !== undefined && ( + + onSearchChange(e.currentTarget.value)} + leftSection={ + + } + size="xs" + /> + + )} + + + + {children} + + +
+ ); +} diff --git a/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.tsx b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.tsx index 87551b48eb..d03412877e 100644 --- a/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.tsx +++ b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.tsx @@ -47,7 +47,7 @@ export function JsonViewer({ file }: JsonViewerProps) { flexShrink: 0, }} > - + {t("viewer.nonPdf.invalidJson")} diff --git a/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.tsx b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.tsx index 869d09f9e7..fa8af50748 100644 --- a/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.tsx +++ b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.tsx @@ -93,6 +93,10 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) { margin: "0 auto", padding: "20px 28px", background: "#ffffff", + // The rendered page is a white sheet in either scheme, so its + // copy takes a fixed dark ink rather than inheriting the theme's, + // which would be near-white here. + color: "var(--c-text-on-light)", borderRadius: 6, }} > @@ -122,7 +126,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) { paddingRight: 16, paddingLeft: 4, textAlign: "right", - color: "var(--mantine-color-gray-5)", + color: "var(--c-text-muted)", userSelect: "none", borderRight: "1px solid var(--mantine-color-gray-2)", minWidth: `${String(lines.length).length + 1}ch`, diff --git a/frontend/editor/src/core/components/viewer/useAnnotationMenuHandlers.ts b/frontend/editor/src/core/components/viewer/useAnnotationMenuHandlers.ts index bf14b559f4..3e9e048aaf 100644 --- a/frontend/editor/src/core/components/viewer/useAnnotationMenuHandlers.ts +++ b/frontend/editor/src/core/components/viewer/useAnnotationMenuHandlers.ts @@ -14,6 +14,8 @@ import type { AnnotationPatch, } from "@app/components/viewer/viewerTypes"; import type { ScrollActions } from "@app/contexts/viewer/viewerActions"; +import { openExternalTab } from "@app/platform/openExternalTab"; +import { getExternalHref } from "@app/utils/externalUrl"; export type AnnotationType = | "textMarkup" @@ -370,7 +372,15 @@ export function useAnnotationMenuHandlers({ const onGoToLink = useCallback(() => { if (!firstLinkTarget) return; if (firstLinkTarget.type === "uri") { - window.open(firstLinkTarget.uri, "_blank", "noopener,noreferrer"); + const href = getExternalHref(firstLinkTarget.uri); + if (href) { + void openExternalTab(href); + } else { + console.warn( + "[useAnnotationMenuHandlers] Blocked unsafe URL:", + firstLinkTarget.uri, + ); + } } else { scrollActions.scrollToPage(firstLinkTarget.pageIndex + 1); } diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index d5d39c4c0c..3aada126ea 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -209,7 +209,11 @@ export function useViewerWorkbenchBarButtons( aria-label={searchLabel} onClick={viewer.searchInterfaceActions.toggle} > - +
diff --git a/frontend/editor/src/core/constants/automation.ts b/frontend/editor/src/core/constants/automation.ts index 56fc7ee35f..a3fa1f5a82 100644 --- a/frontend/editor/src/core/constants/automation.ts +++ b/frontend/editor/src/core/constants/automation.ts @@ -3,9 +3,6 @@ */ export const AUTOMATION_CONSTANTS = { - // Timeouts - OPERATION_TIMEOUT: 300000, // 5 minutes in milliseconds - // Default values DEFAULT_TOOL_COUNT: 2, MIN_TOOL_COUNT: 2, diff --git a/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx b/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx index 5fd1b3f4c6..ff9b8b7037 100644 --- a/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx +++ b/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useCallback, useRef } from "react"; import { useNavigate, useLocation } from "react-router-dom"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; interface AdminTourOrchestrationContextType { // State management @@ -40,8 +41,8 @@ export const AdminTourOrchestrationProvider: React.FC<{ savedLocationRef.current, ); - // Navigate back to saved location or home - const targetPath = savedLocationRef.current || "/"; + // Navigate back to saved location or the editor + const targetPath = savedLocationRef.current || EDITOR_BASENAME; navigate(targetPath, { replace: true }); savedLocationRef.current = ""; @@ -53,8 +54,8 @@ export const AdminTourOrchestrationProvider: React.FC<{ }, [navigate]); const closeConfigModal = useCallback(() => { - // Navigate back to home to close the modal - navigate("/", { replace: true }); + // Navigate back to the editor to close the modal + navigate(EDITOR_BASENAME, { replace: true }); }, [navigate]); const navigateToSection = useCallback( diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index a982c347ca..634562375c 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -58,6 +58,7 @@ import { IndexedDBProvider, useIndexedDB, } from "@app/contexts/IndexedDBContext"; +import { onRecordUnreadable } from "@app/services/fileStorage"; import { useZipConfirmation } from "@app/hooks/useZipConfirmation"; import ZipWarningModal from "@app/components/shared/ZipWarningModal"; import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; @@ -66,6 +67,7 @@ import { alert } from "@app/components/toast"; import { buildRemovePasswordFormData } from "@app/hooks/tools/removePassword/buildRemovePasswordFormData"; import type { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters"; import apiClient from "@app/services/apiClient"; +import { reportFilesRemoved } from "@app/services/failureReporting"; import { processResponse } from "@app/utils/toolResponseProcessor"; import { ToolOperation } from "@app/types/file"; import { handlePasswordError } from "@app/utils/toolErrorHandler"; @@ -186,6 +188,21 @@ function FileContextInner({ setUnlockError(null); }, [activeEncryptedFileId]); + // Storage proved a file's bytes unreadable (WebKit losing a blob's backing + // store). Drop it: the viewer would otherwise spin on a document that can + // never load. The record stays, so a reload re-tests it. + useEffect( + () => + onRecordUnreadable((fileId) => { + if (!stateRef.current.files.byId[fileId]) return; + console.error( + `[FileContext] dropping ${fileId} from the workbench: its stored bytes are unreadable`, + ); + lifecycleManager.removeFiles([fileId], stateRef); + }), + [lifecycleManager], + ); + const handleUnlockSkip = useCallback(() => { if (activeEncryptedFileId) { dismissedEncryptedFilesRef.current.add(activeEncryptedFileId); @@ -594,6 +611,10 @@ function FileContextInner({ // Remove from memory and cleanup resources lifecycleManager.removeFiles(fileIds, stateRef); + // Any failure recorded against these stops needing attention: the document is gone. + // Fire-and-forget, so a server that cannot be told never blocks the delete. + void reportFilesRemoved(fileIds); + // Remove from IndexedDB if enabled if (indexedDB && enablePersistence && deleteFromStorage !== false) { try { diff --git a/frontend/editor/src/core/contexts/FilesModalContext.tsx b/frontend/editor/src/core/contexts/FilesModalContext.tsx index 73d1b0477f..17585ae7e8 100644 --- a/frontend/editor/src/core/contexts/FilesModalContext.tsx +++ b/frontend/editor/src/core/contexts/FilesModalContext.tsx @@ -41,7 +41,9 @@ interface FilesModalContextType { setOnModalClose: (callback: () => void) => void; } -const FilesModalContext = createContext(null); +export const FilesModalContext = createContext( + null, +); export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children, diff --git a/frontend/editor/src/core/contexts/FilesPageContext.tsx b/frontend/editor/src/core/contexts/FilesPageContext.tsx index 6774798aae..b8cf4ccf29 100644 --- a/frontend/editor/src/core/contexts/FilesPageContext.tsx +++ b/frontend/editor/src/core/contexts/FilesPageContext.tsx @@ -455,7 +455,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) { }) .map((s) => s.id); if (localIds.length > 0) { - await fileActions.removeFiles(localIds, true); + // Take the superseded versions with it, or their bytes sit in storage + // forever - invisible, because listings only show leaves. + const orphans = await fileStorage.orphanedAncestorIds(localIds); + await fileActions.removeFiles([...localIds, ...orphans], true); } } diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts index 523ef2e358..bde6a17a02 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.test.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -222,3 +222,40 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", expect(next.ui.selectedFileIds).toEqual(["b2"]); }); }); + +describe("fileContextReducer — REMOVE_FILES", () => { + /** Deleting from the library dispatches this for files that were never in the + * workbench; reallocating then re-renders every consumer for nothing. */ + it("is a true no-op when none of the ids are in the workbench", () => { + const state = stateWith([stub("a")]); + const next = fileContextReducer(state, { + type: "REMOVE_FILES", + payload: { fileIds: ["gone" as FileId] }, + }); + expect(next).toBe(state); + }); + + it("still removes the ids it does hold", () => { + const state = stateWith([stub("a"), stub("b")]); + const next = fileContextReducer(state, { + type: "REMOVE_FILES", + payload: { fileIds: ["a" as FileId, "gone" as FileId] }, + }); + expect(next.files.ids).toEqual(["b"]); + expect(next.files.byId["a" as FileId]).toBeUndefined(); + }); + + it("keeps the files slice when only a selection is cleared", () => { + const base = stateWith([stub("a")]); + const state: FileContextState = { + ...base, + ui: { ...base.ui, selectedFileIds: ["gone" as FileId] }, + }; + const next = fileContextReducer(state, { + type: "REMOVE_FILES", + payload: { fileIds: ["gone" as FileId] }, + }); + expect(next.files).toBe(state.files); + expect(next.ui.selectedFileIds).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 2697974823..a9dcae7b38 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -183,6 +183,20 @@ export function fileContextReducer( const remainingIds = state.files.ids.filter( (id) => !fileIds.includes(id), ); + // Clear selections that reference removed files + const validSelectedFileIds = state.ui.selectedFileIds.filter( + (id) => !fileIds.includes(id), + ); + + // Deleting a library file that was never in the workbench removes nothing + // here, and must not re-render every file and UI consumer. + const removedFromWorkbench = + remainingIds.length !== state.files.ids.length || + fileIds.some((id) => id in state.files.byId); + const deselected = + validSelectedFileIds.length !== state.ui.selectedFileIds.length; + if (!removedFromWorkbench && !deselected) return state; + const newById = { ...state.files.byId }; // Remove files from state (resource cleanup handled by lifecycle manager) @@ -190,21 +204,14 @@ export function fileContextReducer( delete newById[id]; }); - // Clear selections that reference removed files - const validSelectedFileIds = state.ui.selectedFileIds.filter( - (id) => !fileIds.includes(id), - ); - return { ...state, - files: { - ids: remainingIds, - byId: newById, - }, - ui: { - ...state.ui, - selectedFileIds: validSelectedFileIds, - }, + files: removedFromWorkbench + ? { ids: remainingIds, byId: newById } + : state.files, + ui: deselected + ? { ...state.ui, selectedFileIds: validSelectedFileIds } + : state.ui, }; } diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index a95f42939d..4067a99069 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -26,6 +26,9 @@ import { clearBulkAddProgress, } from "@app/services/bulkAddProgress"; const DEBUG = process.env.NODE_ENV === "development"; +/** How long a file may sit unhydrated before the console says so. Reporting only: + * the read is never abandoned, because large files legitimately take time. */ +const STALLED_LOAD_MS = 8000; const HYDRATION_CONCURRENCY = 2; let activeHydrations = 0; const hydrationQueue: Array<() => Promise> = []; @@ -854,61 +857,78 @@ export async function addStirlingFileStubs( // Load File object and hydrate metadata in background (non-blocking) const fileId = stub.id; - // Load File object from IndexedDB asynchronously - scheduleMetadataHydration(async () => { - const stirlingFile = await fileStorage.getStirlingFile(fileId); + // Regenerate page metadata + thumbnails. Queued, because parsing several + // PDFs at once is what the concurrency limit exists to bound. + const scheduleMetadataFor = (stirlingFile: StirlingFile): void => { + scheduleMetadataHydration(async () => { + const processedFileMetadata = + await generateProcessedFileMetadata(stirlingFile); + if (!processedFileMetadata) return; + + const updates: Partial = { + processedFile: processedFileMetadata, + }; + + // Update thumbnail only if current stub doesn't have one + const currentStub = stateRef.current.files.byId[fileId]; + if ( + !currentStub?.thumbnailUrl && + processedFileMetadata.thumbnailUrl + ) { + updates.thumbnailUrl = processedFileMetadata.thumbnailUrl; + if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) { + lifecycleManager.trackBlobUrl(processedFileMetadata.thumbnailUrl); + } + } + + lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef); + }); + }; + + // Load and publish the File, ahead of any parsing. NOT queued: whether a + // file opens at all must not wait on other files' parses. + void (async () => { + // A storage read that never settles renders as a file that silently won't + // open. Name it in the console rather than leaving the user guessing. + const stall = setTimeout( + () => + console.error( + `[Hydration] ${stub.name} (${fileId}) has been loading for ${STALLED_LOAD_MS / 1000}s - the IndexedDB read has not settled`, + ), + STALLED_LOAD_MS, + ); + const stirlingFile = await fileStorage + .getStirlingFile(fileId) + .finally(() => clearTimeout(stall)); if (!stirlingFile) { + // A row with no bytes renders empty and its clicks look dead, so take it + // back out. Storage keeps the record; fileStorage has said why. + console.error( + `[Hydration] No readable data for ${stub.name} (${fileId}); removing it from the workbench`, + ); + lifecycleManager.removeFiles([fileId], stateRef); return; } - // Store the loaded file in filesRef filesRef.current.set(fileId, stirlingFile); - - // Check if processedFile data needs regeneration - if (stirlingFile.type.startsWith("application/pdf")) { - const needsProcessing = - !stub.processedFile || - !stub.processedFile.pages || - stub.processedFile.pages.length === 0 || - stub.processedFile.totalPages !== stub.processedFile.pages.length; - - if (needsProcessing) { - // Regenerate metadata - const processedFileMetadata = - await generateProcessedFileMetadata(stirlingFile); - - if (processedFileMetadata) { - const updates: Partial = { - processedFile: processedFileMetadata, - }; - - // Update thumbnail only if current stub doesn't have one - const currentStub = stateRef.current.files.byId[fileId]; - if ( - !currentStub?.thumbnailUrl && - processedFileMetadata.thumbnailUrl - ) { - updates.thumbnailUrl = processedFileMetadata.thumbnailUrl; - if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) { - lifecycleManager.trackBlobUrl( - processedFileMetadata.thumbnailUrl, - ); - } - } - - lifecycleManager.updateStirlingFileStub( - fileId, - updates, - stateRef, - ); - return; - } - } - } - - // Stub dispatch triggers re-render so the viewer appears (ADD_FILES alone doesn't update selectors). + // filesRef is a ref, so the selectors gating the workbench only see the + // file once something dispatches. Parsing it can't be a precondition. lifecycleManager.updateStirlingFileStub(fileId, {}, stateRef); - }); + + const needsProcessing = + !stub.processedFile || + !stub.processedFile.pages || + stub.processedFile.pages.length === 0 || + stub.processedFile.totalPages !== stub.processedFile.pages.length; + if ( + stirlingFile.type.startsWith("application/pdf") && + needsProcessing + ) { + scheduleMetadataFor(stirlingFile); + } + })().catch((error) => + console.error(`[Hydration] Failed to load ${fileId}:`, error), + ); } return loadedFiles; diff --git a/frontend/editor/src/core/contexts/file/hydrationPublish.test.ts b/frontend/editor/src/core/contexts/file/hydrationPublish.test.ts new file mode 100644 index 0000000000..c4bdda01c1 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/hydrationPublish.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, vi } from "vitest"; +import type { + FileContextState, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * A clicked file is only visible once hydration DISPATCHES: the workbench reads + * files out of a ref, so `activeFiles` stays empty until then. Parsing must not + * gate that - a PDF engine that stalls used to leave the workbench on its empty + * state with the row showing as open, and clicks doing nothing. + */ + +const getStirlingFile = vi.hoisted(() => vi.fn()); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getStirlingFile }, +})); +/** The stall under test: the page parse never settles. */ +vi.mock("@app/utils/thumbnailUtils", () => ({ + generateThumbnailPairWithMetadata: () => new Promise(() => {}), +})); + +const stub = (id: string): StirlingFileStub => + ({ + id: id as FileId, + name: `${id}.pdf`, + type: "application/pdf", + size: 10, + lastModified: 0, + }) as StirlingFileStub; + +async function harness(ids: string[]) { + vi.resetModules(); + getStirlingFile.mockImplementation( + async (id: FileId) => + new File(["%PDF-1.7"], `${id}.pdf`, { type: "application/pdf" }), + ); + const { addStirlingFileStubs } = + await import("@app/contexts/file/fileActions"); + + const stubs = ids.map(stub); + const state = { + files: { ids: [], byId: {} }, + pinnedFiles: new Set(), + ui: { selectedFileIds: [], selectedPageNumbers: [] }, + } as unknown as FileContextState; + const stateRef = { current: state }; + const filesRef = { current: new Map() }; + const published: FileId[] = []; + const lifecycleManager = { + updateStirlingFileStub: (fileId: FileId) => published.push(fileId), + removeFiles: () => {}, + trackBlobUrl: () => {}, + }; + + await addStirlingFileStubs( + stubs, + {}, + stateRef, + filesRef, + () => {}, + lifecycleManager as never, + ); + return { filesRef, published }; +} + +describe("workbench hydration — a stalled parse can't hide the file", () => { + test("publishes every file's bytes while their parses hang", async () => { + // Three, because the parse queue only runs two at a time: the third proves + // loading isn't queued behind parses that never finish. + const { filesRef, published } = await harness(["a", "b", "c"]); + + await vi.waitFor(() => expect(published).toHaveLength(3)); + expect([...filesRef.current.keys()]).toEqual(["a", "b", "c"]); + }); +}); diff --git a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx index 461c07ebdf..c8942942df 100644 --- a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx +++ b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx @@ -30,4 +30,15 @@ describe("automatable tools", () => { expect(offeredWithoutConfig).toEqual([]); }); + + // Reorganize Pages has an automatable form (organization mode + page-order string) and a + // context-free settings component, but its registry entry once left automationSettings null, + // so both Automate and the pipeline builder showed "no configurable settings". Guard the wiring. + test("Reorganize Pages exposes automation settings so it is configurable, not no-settings", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + expect( + result.current.regularTools.reorganizePages?.automationSettings, + ).toBeTruthy(); + }); }); diff --git a/frontend/editor/src/core/data/ogImageMap.json b/frontend/editor/src/core/data/ogImageMap.json index 0bda8c041a..04d0275a1e 100644 --- a/frontend/editor/src/core/data/ogImageMap.json +++ b/frontend/editor/src/core/data/ogImageMap.json @@ -1,6 +1,7 @@ { "certSign": "certSign", "sign": "sign", + "sharedSign": "shared-sign", "addText": "add-text", "addPassword": "add-password", "removePassword": "remove-password", diff --git a/frontend/editor/src/core/data/processorEntitySearch.ts b/frontend/editor/src/core/data/processorEntitySearch.ts new file mode 100644 index 0000000000..5d7eaa9364 --- /dev/null +++ b/frontend/editor/src/core/data/processorEntitySearch.ts @@ -0,0 +1,21 @@ +import type { TFunction } from "i18next"; +import type { SuperSearchGroup } from "@app/types/superSearch"; + +const NO_GROUPS: SuperSearchGroup[] = []; + +/** + * Processor entity results (users, policies, pipelines, sources) for the + * editor's super search. Core and desktop builds ship no portal, so this stub + * returns nothing; the proprietary build shadows it with an implementation + * that lazily loads the portal's entity-search module. + */ +export function useProcessorEntityGroups( + _trimmed: string, + _enabled: boolean, + _t: TFunction, + _navigate: (path: string) => void, + _scopeEnabled?: (scopeId: string) => boolean, + _isAdmin?: boolean, +): SuperSearchGroup[] { + return NO_GROUPS; +} diff --git a/frontend/editor/src/core/data/processorSearchIndex.ts b/frontend/editor/src/core/data/processorSearchIndex.ts new file mode 100644 index 0000000000..ef308cbee2 --- /dev/null +++ b/frontend/editor/src/core/data/processorSearchIndex.ts @@ -0,0 +1,30 @@ +/** + * Search index for the admin portal ("Processor") destinations offered by the + * global super search. Core and desktop builds ship no portal, so the index is + * empty and the Processor group never renders; the proprietary build (which + * mounts the portal as a route-set) shadows this with the real view list. + */ +export interface ProcessorSearchEntry { + /** Portal view id — stable key for the result row. */ + id: string; + /** i18n key for the view's display name (shared with the portal sidebar). */ + labelKey: string; + labelFallback: string; + /** In-app path to navigate to. Empty when externalUrl is set. */ + path: string; + /** Opens in a new tab instead of navigating (e.g. hosted docs). */ + externalUrl?: string; + /** Extra fuzzy-match terms beyond the label. */ + keywords?: string[]; +} + +export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = []; + +/** No portal → no entity scopes; trivially accessible. Real logic lives in + * the proprietary shadow, keyed on the flavor's users capabilities. */ +export function isPortalEntityScopeAccessible( + _scopeId: string, + _isAdmin: boolean, +): boolean { + return true; +} diff --git a/frontend/editor/src/core/data/settingsContentSearch.ts b/frontend/editor/src/core/data/settingsContentSearch.ts new file mode 100644 index 0000000000..7cee4d6056 --- /dev/null +++ b/frontend/editor/src/core/data/settingsContentSearch.ts @@ -0,0 +1,174 @@ +import i18n from "i18next"; +import type { TFunction } from "i18next"; + +/** + * Content-level settings search: matches a query against every translation + * string a settings section renders, so a term that appears anywhere on a + * settings page ("SMTP", "OCR", a field label…) finds that section without a + * curated keyword. + * + * Component-free (translation subtrees only) so the always-mounted super + * search can use it without pulling the lazy settings modal into the main + * bundle. + */ + +/** + * Translation subtrees whose strings appear on each settings section, for the + * sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing + * here fall back to the inferred `settings.` / `admin.settings.` + * prefix. + */ +const SECTION_TRANSLATION_PREFIXES: Partial> = { + general: ["settings.general"], + hotkeys: ["settings.hotkeys"], + account: ["account"], + people: ["settings.workspace"], + teams: ["settings.workspace", "settings.team"], + "api-keys": ["settings.developer"], + connectionMode: ["settings.connection"], + planBilling: ["settings.planBilling"], + adminGeneral: ["admin.settings.general"], + adminFeatures: ["admin.settings.features"], + adminEndpoints: ["admin.settings.endpoints"], + adminDatabase: ["admin.settings.database"], + adminAdvanced: ["admin.settings.advanced"], + adminFolderAccess: ["admin.settings.folderAccess"], + // The four AI tabs all render slices of the one admin.settings.ai subtree. + adminAiGeneral: ["admin.settings.ai"], + adminAiModels: ["admin.settings.ai"], + adminAiDocuments: ["admin.settings.ai"], + adminAiLimits: ["admin.settings.ai"], + adminSecurity: ["admin.settings.security"], + adminMcp: ["admin.settings.mcp"], + adminConnections: [ + "admin.settings.connections", + "admin.settings.mail", + "admin.settings.security", + "admin.settings.telegram", + "admin.settings.premium", + "admin.settings.general", + "settings.securityAuth", + "settings.connection", + ], + adminPlan: [ + "settings.planBilling", + "admin.settings.premium", + "settings.licensingAnalytics", + ], + adminAudit: ["settings.licensingAnalytics"], + adminUsage: ["settings.licensingAnalytics"], + adminLegal: ["admin.settings.legal"], + adminPrivacy: ["admin.settings.privacy"], +}; + +export const getTranslationPrefixesForNavKey = (key: string): string[] => { + const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? []; + + const inferredPrefixes: string[] = []; + + if (key.startsWith("admin")) { + const adminSuffix = key.replace(/^admin/, ""); + const normalizedAdminSuffix = + adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); + inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); + } else { + inferredPrefixes.push(`settings.${key}`); + } + + return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes])); +}; + +export const flattenTranslationStrings = (value: unknown): string[] => { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (Array.isArray(value)) { + return value.flatMap(flattenTranslationStrings); + } + + if (value && typeof value === "object") { + return Object.values(value as Record).flatMap( + flattenTranslationStrings, + ); + } + + return []; +}; + +/** Trims a matched string to a short snippet centred on the query hit. */ +export const buildMatchSnippet = (text: string, query: string): string => { + const normalizedText = text.toLocaleLowerCase(); + const normalizedQuery = query.toLocaleLowerCase(); + const matchIndex = normalizedText.indexOf(normalizedQuery); + + if (matchIndex === -1) { + return text; + } + + // Lowercasing can change string length in some locales (Turkish İ, ß), so + // indices computed on the copy only align with the original when the + // lengths match; otherwise snippet the copy itself. + const source = normalizedText.length === text.length ? text : normalizedText; + + const maxLength = 84; + const contextPadding = 28; + const start = Math.max(0, matchIndex - contextPadding); + const end = Math.min( + source.length, + matchIndex + query.length + contextPadding, + ); + const snippet = source.slice(start, end); + + if (snippet.length <= maxLength) { + return `${start > 0 ? "…" : ""}${snippet}${end < source.length ? "…" : ""}`; + } + + return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < source.length ? "…" : ""}`; +}; + +// Flattening every subtree on each keystroke would be wasteful; sections' +// content is static per language, so cache it and drop the cache on switch. +const contentCache = new Map(); +let contentCacheLanguage: string | undefined; + +// Locale files load over HTTP after boot, so content computed before the +// bundle resolves is empty — without this, an early query would cache empty +// content for the whole session. Cleared whenever a resource bundle lands. +i18n.on("loaded", () => contentCache.clear()); + +export function getSettingsSectionContent(key: string, t: TFunction): string[] { + if (contentCacheLanguage !== i18n.language) { + contentCache.clear(); + contentCacheLanguage = i18n.language; + } + const cached = contentCache.get(key); + if (cached) return cached; + + const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) => + flattenTranslationStrings( + t(prefix, { returnObjects: true, defaultValue: {} }), + ), + ); + contentCache.set(key, content); + return content; +} + +/** + * First content string of the section containing the query + * (case-insensitive), or null. Substring only — fuzzy matching across whole + * paragraphs of copy produces junk hits. + */ +export function findSettingsContentMatch( + key: string, + query: string, + t: TFunction, +): string | null { + const normalizedQuery = query.toLocaleLowerCase(); + return ( + getSettingsSectionContent(key, t).find((text) => + text.toLocaleLowerCase().includes(normalizedQuery), + ) ?? null + ); +} diff --git a/frontend/editor/src/core/data/settingsSearchIndex.ts b/frontend/editor/src/core/data/settingsSearchIndex.ts new file mode 100644 index 0000000000..c130546a8a --- /dev/null +++ b/frontend/editor/src/core/data/settingsSearchIndex.ts @@ -0,0 +1,105 @@ +import { NavKey } from "@app/components/shared/config/types"; + +/** + * A single, searchable setting *row* inside the settings modal. + * + * Section-level content matching (settingsContentSearch) only navigates to a + * whole section; this index lets the global super search deep-link to an + * individual control: navigating to `/settings/{section}?focus={anchor}`, + * where `anchor` is the DOM `id` placed on that control's row (see + * AppConfigModal's focus-scroll effect and the `id=` attributes added to the + * matching section components). + */ +export interface SettingsSearchEntry { + /** Settings section this row lives in (nav key, e.g. "general"). */ + section: NavKey; + /** DOM id on the control's row; used as the `?focus=` anchor. */ + anchor: string; + /** i18n key for the display label. */ + labelKey: string; + /** English fallback / default for the label. */ + labelFallback: string; + /** Extra English terms to match against (synonyms, related words). */ + keywords?: string[]; +} + +/** + * Curated row-level entries for the high-value, user-facing settings sections. + * Section-level results (every other tab) come from the nav sections directly, + * so this list only needs the rows worth jumping straight to. + */ +export const SETTINGS_SEARCH_INDEX: SettingsSearchEntry[] = [ + // --- General > Appearance --- + { + section: "general", + anchor: "setting-theme", + labelKey: "settings.general.theme", + labelFallback: "Theme", + keywords: ["dark", "light", "mode", "appearance", "colour", "color"], + }, + { + section: "general", + anchor: "setting-language", + labelKey: "settings.general.language", + labelFallback: "Language", + keywords: ["locale", "translation", "i18n"], + }, + // --- General > Behaviour --- + { + section: "general", + anchor: "setting-tool-picker-mode", + labelKey: "settings.general.defaultToolPickerMode", + labelFallback: "Default tool picker mode", + keywords: ["sidebar", "fullscreen", "tools", "panel"], + }, + { + section: "general", + anchor: "setting-startup-view", + labelKey: "settings.general.defaultStartupView", + labelFallback: "Default view on launch", + keywords: ["startup", "launch", "home", "reader", "automate"], + }, + { + section: "general", + anchor: "setting-reader-zoom", + labelKey: "settings.general.defaultViewerZoom", + labelFallback: "Default reader zoom", + keywords: ["zoom", "viewer", "fit width", "fit page", "magnification"], + }, + { + section: "general", + anchor: "setting-hide-unavailable-tools", + labelKey: "settings.general.hideUnavailableTools", + labelFallback: "Hide unavailable tools", + keywords: ["disabled", "greyed", "tools"], + }, + { + section: "general", + anchor: "setting-hide-unavailable-conversions", + labelKey: "settings.general.hideUnavailableConversions", + labelFallback: "Hide unavailable conversions", + keywords: ["disabled", "convert", "conversions"], + }, + { + section: "general", + anchor: "setting-auto-unzip", + labelKey: "settings.general.autoUnzip", + labelFallback: "Auto-unzip API responses", + keywords: ["zip", "extract", "unzip", "archive"], + }, + { + section: "general", + anchor: "setting-auto-unzip-file-limit", + labelKey: "settings.general.autoUnzipFileLimit", + labelFallback: "Auto-unzip file limit", + keywords: ["zip", "limit", "files", "extract"], + }, + // --- Keyboard Shortcuts --- + { + section: "hotkeys", + anchor: "setting-hotkeys-search", + labelKey: "settings.hotkeys.title", + labelFallback: "Keyboard Shortcuts", + keywords: ["hotkey", "shortcut", "keybinding", "keyboard"], + }, +]; diff --git a/frontend/editor/src/core/data/settingsSectionRegistry.ts b/frontend/editor/src/core/data/settingsSectionRegistry.ts new file mode 100644 index 0000000000..e2222d4daf --- /dev/null +++ b/frontend/editor/src/core/data/settingsSectionRegistry.ts @@ -0,0 +1,88 @@ +import { NavKey } from "@app/components/shared/config/types"; + +/** + * A whole settings section (nav tab), described as pure data so the global + * super search can offer section-level results. + * + * This is the single source of truth for *which settings sections are + * searchable* in a given build. It is intentionally **component-free**: the + * always-mounted top bar imports it (via `@app/data/settingsSectionRegistry`) + * to feed the super search, and pulling the heavy settings component tree in + * here would defeat the lazy-loaded settings modal (AppConfigModalLazy). + * + * Layering mirrors the nav builders (`configNavSections`): core lists the + * always-present sections; higher layers shadow this module to add their own + * (proprietary admin sections, saas cloud sections, …). A section only belongs + * in a layer's registry if that layer's settings modal can actually render it — + * otherwise search would deep-link to a tab that doesn't exist. + * + * Per-user visibility (admin / login) is expressed by the flags below and + * applied by the super search at query time, mirroring the modal's own gating. + */ +export interface SettingsSectionEntry { + /** Nav key; used for the `/settings/{key}` deep link. */ + key: NavKey; + /** i18n key for the display label. */ + labelKey: string; + /** English fallback / default for the label. */ + labelFallback: string; + /** + * The modal nav group this section sits under ("Preferences", + * "Configuration", …) — shown as result context ("Group Ā· match"). + * Same keys the nav builder uses. + */ + groupLabelKey?: string; + groupLabelFallback?: string; + /** Extra English terms to match against (synonyms, related words). */ + keywords?: string[]; + /** Surface only when login mode is on (e.g. account, API keys). */ + requiresLogin?: boolean; + /** + * Admin-area section: the self-hosted modal surfaces it when the user is an + * admin OR login mode is off (single-user self-host). Mirrors the proprietary + * nav builder's `isAdmin || !loginEnabled` gate. + */ + adminArea?: boolean; + /** + * Section only mounts for a signed-in (non-anonymous) account — mirrors the + * SaaS nav builder's `!isAnonymous` gate. Distinct from `requiresLogin`, + * which keys off the local backend's login mode rather than auth state. + */ + requiresAccount?: boolean; +} + +/** Core (OSS) sections — always present in every build. */ +export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [ + { + key: "general", + labelKey: "settings.general.title", + labelFallback: "General", + keywords: ["theme", "language", "appearance", "preferences", "startup"], + groupLabelKey: "settings.preferences.title", + groupLabelFallback: "Preferences", + }, + { + key: "hotkeys", + labelKey: "settings.hotkeys.title", + labelFallback: "Keyboard Shortcuts", + keywords: ["hotkey", "shortcut", "keybinding", "keyboard"], + groupLabelKey: "settings.preferences.title", + groupLabelFallback: "Preferences", + }, + { + key: "help", + labelKey: "settings.help.label", + labelFallback: "Tours", + keywords: ["help", "tour", "guide", "support", "docs"], + groupLabelKey: "settings.help.title", + groupLabelFallback: "Help", + }, + { + key: "legal", + labelKey: "settings.legal.label", + labelFallback: "Legal", + keywords: ["legal", "terms", "privacy", "licenses"], + groupLabelKey: "settings.legal.title", + groupLabelFallback: "Legal", + }, +]; diff --git a/frontend/editor/src/core/data/toolsTaxonomy.ts b/frontend/editor/src/core/data/toolsTaxonomy.ts index fc9b37565c..f9c2dde02f 100644 --- a/frontend/editor/src/core/data/toolsTaxonomy.ts +++ b/frontend/editor/src/core/data/toolsTaxonomy.ts @@ -216,6 +216,17 @@ export const isValidToolId = ( return toolId in registry; }; +/** + * A "coming soon" placeholder: listed in the catalogue but not openable — no + * UI component and no external link. "read" and "multiTool" are exempt as + * workbench-only tools that render without a component. + */ +export const isComingSoonTool = ( + toolId: string, + tool: ToolRegistryEntry, +): boolean => + !tool.component && !tool.link && toolId !== "read" && toolId !== "multiTool"; + /** * Check if a tool supports automation (defaults to true) */ diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 0a39da2fb9..5ae8075d0f 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -700,7 +700,10 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { endpoints: ["rearrange-pages"], operationConfig: asRegistryConfig(reorganizePagesOperationConfig), synonyms: getSynonyms(t, "reorganizePages"), - automationSettings: null, + automationSettings: lazySettings( + () => + import("@app/components/tools/reorganizePages/ReorganizePagesSettings"), + ), }, scalePages: { icon: ( diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts index 1a519d519e..e64c349a4a 100644 --- a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts +++ b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts @@ -19,7 +19,7 @@ describe("useRedactParameters", () => { { paramName: "wholeWordSearch" as const, value: true }, { paramName: "redactColor" as const, value: "#FF0000" }, { paramName: "customPadding" as const, value: 0.5 }, - { paramName: "convertPDFToImage" as const, value: false }, + { paramName: "convertPDFToImage" as const, value: true }, ])("should update parameter $paramName", ({ paramName, value }) => { const { result } = renderHook(() => useRedactParameters()); @@ -138,7 +138,7 @@ describe("useRedactParameters", () => { expect(result.current.parameters.mode).toBe("automatic"); expect(result.current.parameters.useRegex).toBe(false); expect(result.current.parameters.wholeWordSearch).toBe(false); - expect(result.current.parameters.convertPDFToImage).toBe(true); + expect(result.current.parameters.convertPDFToImage).toBe(false); }); test("should handle array parameter updates correctly", () => { diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts index 048b230567..a903908658 100644 --- a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts +++ b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts @@ -25,7 +25,7 @@ export const defaultParameters: RedactParameters = { wholeWordSearch: false, redactColor: "#000000", customPadding: 0.1, - convertPDFToImage: true, + convertPDFToImage: false, }; export type RedactParametersHook = BaseParametersHook; diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts index ba8c0bc2be..f3443dfb51 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts @@ -41,12 +41,13 @@ describe("objectToFormData", () => { }); test("expands arrays into repeated fields", () => { - const request: ToolApiParams["/api/v1/misc/add-attachments"] = { - attachments: ["a.png", "b.png", "c.png"], + const request: ToolApiParams["/api/v1/misc/ocr-pdf"] = { + ocrType: "Normal", + languages: ["eng", "fra", "deu"], }; const formData = objectToFormData(request); - expect(formData.getAll("attachments")).toEqual(["a.png", "b.png", "c.png"]); + expect(formData.getAll("languages")).toEqual(["eng", "fra", "deu"]); }); test("throws on a non-primitive field value rather than dropping it", () => { @@ -70,6 +71,18 @@ describe("objectToFormData", () => { expect(formData.get("optimizeLevel")).toBe("5"); }); + test("sends a File-valued model field as a file part, not stringified", () => { + const stamp = new File(["s"], "stamp.png", { type: "image/png" }); + const request: ToolApiParams["/api/v1/misc/add-stamp"] = { + stampType: "image", + stampImage: stamp, + }; + const formData = objectToFormData(request); + + expect(formData.get("stampImage")).toBe(stamp); + expect(formData.get("stampType")).toBe("image"); + }); + test("appends multiple files under the same field name", () => { const files = [ new File(["1"], "a.pdf", { type: "application/pdf" }), diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts index 9c6f5d9e4b..81663fbf69 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts @@ -47,26 +47,28 @@ function appendPrimitive( formData.append(key, value); } else if (typeof value === "number" || typeof value === "boolean") { formData.append(key, `${value}`); + } else if (typeof Blob !== "undefined" && value instanceof Blob) { + // A File upload (models type binary params as File): send it as the file part, not stringified. + formData.append(key, value); } else { - // A non-primitive here means a mapper produced a value the backend cannot - // receive as a form field. Fail loudly rather than silently drop it: - // structured fields must be JSON-encoded in the mapper, and Files passed via - // the `files` argument. + // Any other non-primitive means a mapper produced a value the backend cannot receive as a form + // field. Fail loudly rather than silently drop it: structured fields must be JSON-encoded first. throw new Error( `objectToFormData: field "${key}" has an unsupported value of type ` + - `"${typeof value}"; expected a string, number, or boolean.`, + `"${typeof value}"; expected a string, number, boolean, or File.`, ); } } /** * Serialize a backend request model (the output of a `toApiParams` function) - * into multipart FormData: primitives become string fields, arrays become - * repeated fields, and `undefined`/`null` are omitted. Files are appended - * separately via `files`, keeping file plumbing out of the parameter mapper. + * into multipart FormData: primitives become string fields, `File` values become + * file parts, arrays become repeated fields, and `undefined`/`null` are omitted. + * Extra files may still be passed via `files` (the primary `fileInput`, or a + * field the mapper doesn't carry). * - * Throws if a field holds a non-primitive value, since that cannot be sent as a - * form field: structured fields must be JSON-encoded by the mapper. + * Throws if a field holds any other non-primitive value, since that cannot be + * sent as a form field: structured fields must be JSON-encoded by the mapper. */ export function objectToFormData( params: ToolApiRequest, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 5be4831907..0f7184f848 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -10,11 +10,14 @@ import { asRegistryConfig, ToolType, } from "@app/hooks/tools/shared/toolOperationTypes"; +import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping"; import { + activeFileFields, deserializeToolStep, + extractStepFiles, getExecutableTools, serializeToolStep, - stepRequiresUpload, + stepNeedsConfiguring, type WorkingToolStep, } from "@app/hooks/tools/shared/toolAutomation"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; @@ -28,6 +31,10 @@ import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddP import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation"; import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation"; import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters"; +import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsOperation"; +import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters"; +import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation"; +import { defaultParameters as certSignDefaults } from "@app/hooks/tools/certSign/useCertSignParameters"; function entry(over: Partial): ToolRegistryEntry { return { @@ -419,18 +426,167 @@ describe("convert (format-routed custom tool)", () => { }); }); -describe("stepRequiresUpload", () => { - const step = (params: Record): WorkingToolStep => ({ - toolId: "compress" as ToolId, - operation: "/api/v1/misc/compress-pdf", - params, +describe("supporting files", () => { + const fileRegistry: Partial = { + overlayPdfs: entry({ + name: "Overlay", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(overlayPdfsOperationConfig), + }), + certSign: entry({ + name: "Cert sign", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(certSignOperationConfig), + }), + }; + + const overlayStep = ( + params: Record, + fileParameters?: Record, + ): WorkingToolStep => ({ + toolId: "overlayPdfs" as ToolId, + operation: "/api/v1/general/overlay-pdfs", + params: { ...overlayDefaults, ...params }, support: "editable", + fileParameters, }); - test("detects a File (or list of Files) among the parameters", () => { - const image = new File(["x"], "logo.png", { type: "image/png" }); - expect(stepRequiresUpload(step({ level: 5 }))).toBe(false); - expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true); - expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true); + const certStep = ( + params: Record, + fileParameters?: Record, + ): WorkingToolStep => ({ + toolId: "certSign" as ToolId, + operation: "/api/v1/security/cert-sign", + params: { ...certSignDefaults, signMode: "MANUAL", ...params }, + support: "editable", + fileParameters, + }); + + test("extractStepFiles groups fresh picks by their backend file field", () => { + const a = new File(["1"], "a.pdf", { type: "application/pdf" }); + const b = new File(["2"], "b.pdf", { type: "application/pdf" }); + expect( + extractStepFiles(overlayStep({ overlayFiles: [a, b] }), fileRegistry), + ).toEqual({ overlayFiles: [a, b] }); + }); + + test("extractStepFiles respects a tool's file selection (certSign by certType)", () => { + const p12 = new File(["k"], "key.p12"); + expect( + extractStepFiles( + certStep({ certType: "PKCS12", p12File: p12 }), + fileRegistry, + ), + ).toEqual({ p12File: [p12] }); + }); + + test("serialize/deserialize round-trips fileParameters", () => { + const step = certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }); + const api = serializeToolStep(step, fileRegistry); + expect(api.fileParameters).toEqual({ p12File: "asset:abc" }); + expect(deserializeToolStep(api, fileRegistry).fileParameters).toEqual({ + p12File: "asset:abc", + }); + }); + + test("stepNeedsConfiguring: a stored binding satisfies the file requirement", () => { + expect( + stepNeedsConfiguring( + certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }), + fileRegistry, + ), + ).toBe(false); + // Without the binding the keystore is still owed. + expect( + stepNeedsConfiguring(certStep({ certType: "PKCS12" }), fileRegistry), + ).toBe(true); + }); + + test("activeFileFields drops a stored binding the tool no longer emits", () => { + // Still PKCS12: the p12File binding is what the tool sends. + expect( + activeFileFields( + certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }), + fileRegistry, + ), + ).toEqual(["p12File"]); + // Switched to PEM: certSign wants privateKeyFile/certFile, so the p12File binding is stale. + expect( + activeFileFields( + certStep({ certType: "PEM" }, { p12File: "asset:abc" }), + fileRegistry, + ), + ).toEqual([]); + }); + + test("activeFileFields is null (not empty) when the tool can't be probed", () => { + // A buildFormData that throws can't be probed; returning null (vs []) tells callers to keep the + // step's stored bindings rather than drop them and let the server GC the assets. + const config = asRegistryConfig<{ signingCert?: File }>({ + toolType: ToolType.singleFile, + operationType: "certSign", + endpoint: "/api/v1/security/cert-sign", + defaultParameters: {}, + buildFormData: () => { + throw new Error("cannot build"); + }, + }); + const registry: Partial = { + certSign: entry({ name: "Boom", operationConfig: config }), + }; + const step: WorkingToolStep = { + toolId: "certSign" as ToolId, + operation: "/api/v1/security/cert-sign", + params: {}, + support: "editable", + fileParameters: { certFile: "asset:x" }, + }; + expect(activeFileFields(step, registry)).toBeNull(); + }); + + test("the overlay sentinel is sized to the binding's asset count", () => { + // Two ids -> two files, matching two counts, so FixedRepeat validation passes. + const step = overlayStep( + { overlayMode: "FixedRepeatOverlay", counts: [1, 2] }, + { overlayFiles: "asset:one,two" }, + ); + expect(activeFileFields(step, fileRegistry)).toEqual(["overlayFiles"]); + expect(stepNeedsConfiguring(step, fileRegistry)).toBe(false); + }); + + test("a rename override binds a backend field to a differently-named param", () => { + // The cert-sign endpoint's `certFile` is held by a frontend param named `signingCert`. + const config = asRegistryConfig<{ signingCert?: File }>({ + toolType: ToolType.singleFile, + operationType: "certSign", + endpoint: "/api/v1/security/cert-sign", + defaultParameters: {}, + validateParams: (p) => p.signingCert !== undefined, + // Sends the File under the backend field `certFile`, like real tools do via objectToFormData + // (which sends a param's File or File[] under a named field, iterating arrays). + buildFormData: (p, file) => + objectToFormData({}, { fileInput: file, certFile: p.signingCert }), + fileParamOverrides: [{ field: "certFile", param: "signingCert" }], + }); + const registry: Partial = { + certSign: entry({ name: "Sign", operationConfig: config }), + }; + const step = ( + fileParameters?: Record, + ): WorkingToolStep => ({ + toolId: "certSign" as ToolId, + operation: "/api/v1/security/cert-sign", + params: {}, + support: "editable", + fileParameters, + }); + // The stored binding is keyed by the backend field, but satisfies the frontend param on reload. + expect(stepNeedsConfiguring(step({ certFile: "asset:x" }), registry)).toBe( + false, + ); + expect(stepNeedsConfiguring(step(), registry)).toBe(true); + expect(activeFileFields(step({ certFile: "asset:x" }), registry)).toEqual([ + "certFile", + ]); }); }); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index 0485791f15..a9368ccdb7 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -18,11 +18,13 @@ import { type ToolRegistryEntry, } from "@app/data/toolsTaxonomy"; import { type ToolId } from "@app/types/toolId"; +import { TOOL_FILE_FIELDS } from "@app/types/toolApiTypes"; import { isToolEndpoint, type ToolEndpoint, } from "@app/hooks/tools/shared/toolApiMapping"; import { + ToolType, type ErasedToolParams, type RegistryToolOperationConfig, } from "@app/hooks/tools/shared/toolOperationTypes"; @@ -62,6 +64,12 @@ export interface ExecutableTool { export interface ToolApiStep { operation: string; parameters: Record; + /** + * Supporting-file bindings: a backend file field (e.g. `stampImage`, `overlayFiles`) mapped to + * `asset:[,]` (stored supporting files) or a run-supplied key. Absent when the step needs + * no supporting file. Mirrors the wire {@code PipelineStep.fileParameters}. + */ + fileParameters?: SupportingFileBindings; } /** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */ @@ -70,6 +78,12 @@ export interface KnownToolStep { operation: ToolEndpoint; params: ErasedToolParams; support: ToolStepSupport; + /** + * Stored supporting-file bindings carried from a saved step (field -> `asset:`), so an edit + * round-trips them without the user re-picking. A field the user re-picks lands in `params` as a + * File and takes precedence on save. + */ + fileParameters?: SupportingFileBindings; } /** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */ @@ -78,6 +92,8 @@ export interface UnknownToolStep { operation: string; params: ErasedToolParams; support: "unknown"; + /** Supporting-file bindings preserved verbatim, so an unknown step's files round-trip untouched. */ + fileParameters?: SupportingFileBindings; } /** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */ @@ -135,12 +151,176 @@ function isFileValue(value: unknown): boolean { } /** - * True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be - * saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later - * (e.g. scheduled) run would have nothing to send for that named file field. + * A stored supporting-file id, as returned by the asset store. */ -export function stepRequiresUpload(step: WorkingToolStep): boolean { - return Object.values(step.params).some(isFileValue); +declare const ASSET_ID_BRAND: unique symbol; +export type AssetId = string & { readonly [ASSET_ID_BRAND]: never }; + +/** + * A step's supporting-file bindings: each backend file field (e.g. `stampImage`) mapped to its file. + * A value of `asset:[,]` names stored assets loaded at run time; any other value is a key for + * a file supplied with the run itself. + */ +export type SupportingFileBindings = Record; + +/** + * The `fileParameters` binding format shared with the backend (see PolicyAssetRefs). This module owns + * the frontend side of the step contract, so the format lives here and the builder/settings reuse it. + */ +export const ASSET_REF_PREFIX = "asset:"; + +/** A `fileParameters` value binding one tool file field to the given stored asset ids. */ +export function assetRef(ids: readonly AssetId[]): string { + return ASSET_REF_PREFIX + ids.join(","); +} + +/** The stored asset ids inside a binding value, or none when it isn't an `asset:` ref. */ +export function assetRefIds(binding: string): AssetId[] { + if (!binding.startsWith(ASSET_REF_PREFIX)) return []; + return binding + .slice(ASSET_REF_PREFIX.length) + .split(",") + .map((id) => id.trim()) + .filter(Boolean) as AssetId[]; +} + +/** A throwaway primary document for probing a tool's buildFormData; never sent anywhere. */ +function dummyPrimaryFile(): File { + return new File([], "input.pdf", { type: "application/pdf" }); +} + +/** + * Run a tool's buildFormData so we can read the request it would produce. + * Returns null when File is unavailable or buildFormData throws. + */ +function probeFormData( + config: RegistryToolOperationConfig, + params: ErasedToolParams, +): FormData | null { + if (typeof File === "undefined") return null; + const dummy = dummyPrimaryFile(); + try { + switch (config.toolType) { + case ToolType.singleFile: + return config.buildFormData(params, dummy); + case ToolType.multiFile: + return config.buildFormData(params, [dummy]); + default: + return null; + } + } catch { + return null; + } +} + +/** Defaults merged under the step's params - the shape a tool's mappers and buildFormData expect. */ +function mergedStepParams( + step: WorkingToolStep, + config: RegistryToolOperationConfig, +): ErasedToolParams { + return { ...(config.defaultParameters ?? {}), ...step.params }; +} + +/** The backend file fields an endpoint accepts, from the generated spec-sourced table. */ +function backendFileFields(operation: string): readonly string[] { + return ( + (TOOL_FILE_FIELDS as Partial>)[ + operation + ] ?? [] + ); +} + +/** + * Each backend file field the step's endpoint accepts (from {@link TOOL_FILE_FIELDS}), mapped to the + * tool param that holds it - the same name unless the tool declared a rename override. + */ +function fileFieldMappings( + operation: string, + config: RegistryToolOperationConfig, +): { field: string; param: string }[] { + // The override's erased type collapses `param` to `never`; restore the real runtime shape. + const overrides = (config.fileParamOverrides ?? []) as readonly { + field: string; + param: string; + }[]; + const paramByField = new Map(overrides.map((o) => [o.field, o.param])); + return backendFileFields(operation).map((field) => ({ + field, + param: paramByField.get(field) ?? field, + })); +} + +/** + * The step's params with a stand-in File array injected for each stored binding whose param has no + * fresh pick, so a tool's buildFormData/validateParams sees the supporting file as present. Stored + * bindings are keyed by the backend field (from {@link TOOL_FILE_FIELDS}), so each field finds its + * binding and the sentinel lands on its param - the two coincide unless the tool declared a rename + * override. The array is sized to the binding's asset count (overlay validates count == file count). + * Sentinels are empty and live only in this local object - never written back to step.params, so they + * can never be uploaded. + */ +function withStoredFileSentinels( + step: WorkingToolStep, + config: RegistryToolOperationConfig, +): ErasedToolParams { + const merged = mergedStepParams(step, config); + const bindings = step.fileParameters; + if (!bindings || typeof File === "undefined") return merged; + for (const { param, field } of fileFieldMappings(step.operation, config)) { + const binding = bindings[field]; + if (binding == null || isFileValue(merged[param])) continue; // unbound, or a fresh pick stands in + const count = Math.max(1, assetRefIds(binding).length); + merged[param] = Array.from({ length: count }, () => new File([], "stored")); + } + return merged; +} + +/** + * The fresh File picks on a step, grouped by the backend file field its buildFormData sends them + * under (excluding the primary `fileInput`). buildFormData is the source of truth for the field name + * and for tool-specific selection (certSign picks files by certType), so probing it - rather than + * scanning params - keeps the field mapping correct. These are the files to upload on save. + */ +export function extractStepFiles( + step: WorkingToolStep, + registry: Partial, +): Record { + if (step.toolId === null) return {}; + const config = registry[step.toolId]?.operationConfig; + if (!config) return {}; + const formData = probeFormData(config, mergedStepParams(step, config)); + if (!formData) return {}; + const files: Record = {}; + formData.forEach((value, key) => { + if (key !== "fileInput" && value instanceof File) { + (files[key] ??= []).push(value); + } + }); + return files; +} + +/** + * The backend file fields this step actually uses right now, per its own buildFormData: fresh picks + * plus any stored binding the tool still emits (a stale one - e.g. a PKCS12 keystore after switching + * to PEM - is dropped, because buildFormData no longer sends it). Drives the stored-file chips, the + * save-time binding set, and the test run. + */ +export function activeFileFields( + step: WorkingToolStep, + registry: Partial, +): string[] | null { + if (step.toolId === null) { + return step.fileParameters ? Object.keys(step.fileParameters) : []; + } + const config = registry[step.toolId]?.operationConfig; + if (!config) return null; + const formData = probeFormData(config, withStoredFileSentinels(step, config)); + if (!formData) return null; + const fields = new Set(); + formData.forEach((value, key) => { + if (key !== "fileInput" && value instanceof File) fields.add(key); + }); + return [...fields]; } /** @@ -158,9 +338,10 @@ export function stepNeedsConfiguring( ): boolean { if (step.toolId === null) return false; const config = registry[step.toolId]?.operationConfig; - if (!config?.validateParams) return false; - const merged = { ...(config.defaultParameters ?? {}), ...step.params }; - return !config.validateParams(merged); + if (!config || !config.validateParams) return false; + // Stored supporting files satisfy their field just as a fresh pick would, so validate against the + // sentinel-injected params rather than the bare ones (which drop the file on reload). + return !config.validateParams(withStoredFileSentinels(step, config)); } /** @@ -240,14 +421,27 @@ export function serializeToolStep( step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined; if (!config) { // Unmapped step (unknown endpoint on edit): round-trip it unchanged. - return { operation: step.operation, parameters: step.params }; + return withFileParameters( + { operation: step.operation, parameters: step.params }, + step, + ); } const merged = { ...(config.defaultParameters ?? {}), ...step.params }; const operation = resolveEndpoint(config, merged) ?? step.operation; const parameters = config.toApiParams ? (config.toApiParams(merged) as Record) : {}; - return { operation, parameters }; + return withFileParameters({ operation, parameters }, step); +} + +/** Attach the step's supporting-file bindings to a serialized step, omitting the field when empty. */ +function withFileParameters( + serialized: ToolApiStep, + step: WorkingToolStep, +): ToolApiStep { + const bindings = step.fileParameters; + if (!bindings || Object.keys(bindings).length === 0) return serialized; + return { ...serialized, fileParameters: bindings }; } /** @@ -308,6 +502,7 @@ function unmappedStep(step: ToolApiStep): UnknownToolStep { operation: step.operation, params: { ...step.parameters }, support: "unknown", + fileParameters: step.fileParameters, }; } @@ -345,5 +540,11 @@ export function deserializeToolStep( resolveEndpoint(config, params) ?? (isToolEndpoint(step.operation) ? step.operation : undefined); if (operation === undefined) return unmappedStep(step); - return { toolId, operation, params, support: classifyToolStepSupport(entry) }; + return { + toolId, + operation, + params, + support: classifyToolStepSupport(entry), + fileParameters: step.fileParameters, + }; } diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts index d6dbf3b355..d01d897002 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -3,7 +3,11 @@ import { StirlingFile } from "@app/types/fileContext"; import type { ResponseHandler } from "@app/utils/toolResponseProcessor"; import { ToolId } from "@app/types/toolId"; import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState"; -import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes"; +import { + TOOL_FILE_FIELDS, + type ToolApiParams, + type ToolEndpoint, +} from "@app/types/toolApiTypes"; export type { ProcessingProgress, ResponseHandler }; @@ -45,6 +49,39 @@ export interface CustomProcessorResult { consumedAllInputs?: boolean; } +/** + * The parameter keys that carry a supporting file - a `File` or `File[]` value the tool sends + * beyond its primary document. Derived from the tool's own parameter type, so a file field can only + * ever be declared against a param that genuinely holds a file. + */ +export type FileParamKey = { + [K in keyof TParams]-?: NonNullable extends File | File[] + ? K + : never; +}[keyof TParams] & + string; + +/** + * The backend multipart file fields an endpoint accepts, from the generated {@link TOOL_FILE_FIELDS} + * (which the spec derives from the Java MultipartFile params). `never` for an endpoint that takes no + * supporting files. This is what makes a rename override's `field` a checked name, not a free string. + */ +export type BackendFileField = + TEndpoint extends keyof typeof TOOL_FILE_FIELDS + ? (typeof TOOL_FILE_FIELDS)[TEndpoint][number] + : never; + +/** + * A remap for the rare case where a tool's frontend file param has a different name from the backend + * field it is sent under. Both sides are checked: `field` must be one of the endpoint's generated + * backend file fields, and `param` a real file param of the tool. Same-name fields need no entry - + * they are derived from {@link TOOL_FILE_FIELDS} directly. + */ +export interface FileParamOverride { + field: BackendFileField; + param: FileParamKey; +} + /** * Configuration for tool operations defining processing behavior and API integration. * @@ -79,6 +116,14 @@ interface BaseToolOperationConfig { /** Default parameter values for automation */ defaultParameters?: TParams; + /** + * Rename overrides for supporting-file params. The set of a tool's file fields is derived from the + * generated {@link TOOL_FILE_FIELDS} (spec-sourced), keyed by the backend field name; declare an + * override only when a backend field maps to a differently-named frontend param, so a step composer + * can bind the stored file to the right param. Omitted by the common case where field == param. + */ + fileParamOverrides?: readonly FileParamOverride[]; + /** * Whether these parameters are complete enough to run. The same predicate a tool gives * `useBaseParameters` as its `validateFn`, so the Run button in the editor and anything composing diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index f01c8770ab..fe1ddc7bc7 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -21,6 +21,7 @@ import { StirlingFileStub, } from "@app/types/fileContext"; import { FILE_EVENTS } from "@app/services/errorUtils"; +import { reportToolFailure } from "@app/services/failureReporting"; import { zipFileService } from "@app/services/zipFileService"; import { getFilenameWithoutExtension } from "@app/utils/fileUtils"; import { @@ -603,6 +604,14 @@ export const useToolOperation = ( void _e; } + // Report it so a leader sees the failure too, then carry on with the user's + // own error handling. Fire-and-forget: the reporter swallows its own errors. + void reportToolFailure({ + operation: config.operationType, + error, + fileIds: validFiles.map((file) => file.fileId), + }); + const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); actions.setError(errorMessage); diff --git a/frontend/editor/src/core/hooks/useAccountIdentity.ts b/frontend/editor/src/core/hooks/useAccountIdentity.ts new file mode 100644 index 0000000000..026ac08dab --- /dev/null +++ b/frontend/editor/src/core/hooks/useAccountIdentity.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@app/auth/UseSession"; +import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { accountService } from "@app/services/accountService"; + +export interface AccountIdentity { + /** Never empty — falls back to a generic "User" so a row is never blank. */ + displayName: string; + profilePictureUrl: string | null; + isAnonymous: boolean; +} + +/** + * The signed-in identity as the UI should draw it: one name and one picture, + * resolved the same way everywhere. Every surface that shows "who am I" (the + * editor and processor sidebar footers, the account settings page) reads this, + * so a user can't see one initial in the sidebar and a different one in + * settings. + * + * Resolution order for the name: the auth layer's own displayName (each layer + * derives it from its native user shape), then the proprietary REST endpoint, + * then a generic last resort. + */ +export function useAccountIdentity(): AccountIdentity { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const { displayName: authDisplayName, isAnonymous } = useAuth(); + const profilePictureUrl = useProfilePictureUrl(); + const [accountUsername, setAccountUsername] = useState(null); + + 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]); + + return { + displayName: + authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"), + profilePictureUrl, + isAnonymous, + }; +} diff --git a/frontend/editor/src/core/hooks/useFileManager.ts b/frontend/editor/src/core/hooks/useFileManager.ts index 442027364e..4393a22d61 100644 --- a/frontend/editor/src/core/hooks/useFileManager.ts +++ b/frontend/editor/src/core/hooks/useFileManager.ts @@ -389,6 +389,18 @@ export const useFileManager = () => { // Optimistic update — remove from UI immediately, delete IDB in background setFiles(files.filter((_, i) => i !== index)); onRemovedFromWorkbench?.(file.id); + // Superseded versions go with it (see orphanedAncestorIds); best-effort, + // because failing to tidy history must not fail the delete itself. + void fileStorage + .orphanedAncestorIds([file.id]) + .then((orphans) => + orphans.length > 0 + ? fileStorage.deleteMultipleStirlingFiles(orphans) + : undefined, + ) + .catch((error) => + console.warn("Failed to remove superseded versions:", error), + ); indexedDB.deleteFile(file.id).catch((error) => { console.error("Failed to remove file from IndexedDB:", error); // Restore consistency — file is still in IDB so refresh brings it back diff --git a/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..52702a3f74 --- /dev/null +++ b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,12 @@ +import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow"; + +/** + * Free credits left on this team's allowance, for the sidebar footer meter. + * Null hides the meter entirely. + * + * Core has no wallet — self-hosted installs aren't metered — so there is + * nothing to show. Cloud builds override this with the live wallet figure. + */ +export function useFreeCreditsSummary(): NavFooterCredits | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useMobileTransferSession.ts b/frontend/editor/src/core/hooks/useMobileTransferSession.ts new file mode 100644 index 0000000000..12da03af6f --- /dev/null +++ b/frontend/editor/src/core/hooks/useMobileTransferSession.ts @@ -0,0 +1,270 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { BASE_PATH } from "@app/constants/app"; +import { buildMobileRouteUrl } from "@app/utils/mobileScannerUrl"; +import apiClient from "@app/services/apiClient"; + +/** + * Session lifecycle for phone-to-desktop transfer over the mobile-scanner + * backend (`/api/v1/mobile-scanner/*`): one temporary session, a QR-encodable + * URL for the phone, polling for uploads, and cleanup. + * + * Consumers decide what a received file means — the scanner converts images to + * PDF, the signature flow treats them as ink — so files are delivered raw, one + * at a time, via `onFileReceived`. + */ + +// Generate a cryptographically secure UUID v4-like session ID +function generateSessionId(): string { + // Use Web Crypto API for cryptographically secure random values + const cryptoObj = + typeof crypto !== "undefined" ? crypto : (window as any).crypto; + + if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { + const bytes = new Uint8Array(16); + cryptoObj.getRandomValues(bytes); + + // Set version (4) and variant bits per RFC 4122 + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + + // Convert bytes to hex string in UUID format + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); + } + + // If Web Crypto is not available, fail fast rather than using insecure randomness + throw new Error( + "Web Crypto API not available. Cannot generate secure session ID.", + ); +} + +export interface MobileTransferSessionInfo { + sessionId: string; + createdAt: number; + expiresAt: number; + timeoutMs: number; +} + +interface UseMobileTransferSessionParams { + /** Session exists and polling runs only while true (modal open). */ + active: boolean; + /** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; + /** Called once per newly uploaded file, in upload order. */ + onFileReceived: (file: File) => void | Promise; + /** Message shown when the backend refuses to create a session. */ + sessionCreateErrorMessage: string; + /** Message shown when polling for uploads fails. */ + pollingErrorMessage: string; + /** Host the phone should reach, when configured (server_url / frontendUrl). */ + configuredUrl?: string; +} + +export function useMobileTransferSession({ + active, + routePath, + onFileReceived, + sessionCreateErrorMessage, + pollingErrorMessage, + configuredUrl, +}: UseMobileTransferSessionParams) { + const [sessionId, setSessionId] = useState(() => generateSessionId()); + const [sessionInfo, setSessionInfo] = + useState(null); + const [filesReceived, setFilesReceived] = useState(0); + const [error, setError] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(null); + const [showExpiryWarning, setShowExpiryWarning] = useState(false); + const pollIntervalRef = useRef(null); + const timerIntervalRef = useRef(null); + const processedFiles = useRef>(new Set()); + + // The QR-code URL the phone opens. It must land on the public route under + // the app's base path, otherwise the phone hits the auth-gated catch-all + // route and is bounced to the login page. + const mobileUrl = buildMobileRouteUrl({ + configuredUrl: configuredUrl ?? "", + sessionId, + origin: window.location.origin, + basePath: BASE_PATH, + routePath, + }); + + const createSession = useCallback( + async (newSessionId: string) => { + try { + const response = await apiClient.post( + `/api/v1/mobile-scanner/create-session/${newSessionId}`, + undefined, + { responseType: "json" }, + ); + + if (!response.status || response.status !== 200) { + throw new Error("Failed to create session"); + } + + setSessionInfo(response.data); + setError(null); + } catch (err) { + console.error("[useMobileTransferSession] create failed:", err); + setError(sessionCreateErrorMessage); + } + }, + [sessionCreateErrorMessage], + ); + + // Regenerate session (when expired or warned) + const regenerateSession = useCallback(() => { + const newSessionId = generateSessionId(); + setSessionId(newSessionId); + setShowExpiryWarning(false); + setFilesReceived(0); + processedFiles.current.clear(); + createSession(newSessionId); + }, [createSession]); + + const pollForFiles = useCallback(async () => { + if (!active) return; + + try { + const response = await apiClient.get( + `/api/v1/mobile-scanner/files/${sessionId}`, + ); + if (!response.status || response.status !== 200) { + throw new Error("Failed to check for files"); + } + + const files = response.data.files || []; + + // Download only files we haven't processed yet + const newFiles = files.filter( + (f: any) => !processedFiles.current.has(f.filename), + ); + if (newFiles.length === 0) return; + + for (const fileMetadata of newFiles) { + try { + const downloadResponse = await apiClient.get( + `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, + { responseType: "blob" }, + ); + + if (downloadResponse.status === 200) { + const file = new File( + [downloadResponse.data], + fileMetadata.filename, + { type: fileMetadata.contentType || "image/jpeg" }, + ); + processedFiles.current.add(fileMetadata.filename); + setFilesReceived((prev) => prev + 1); + await onFileReceived(file); + } + } catch (err) { + console.error( + "[useMobileTransferSession] download failed:", + fileMetadata.filename, + err, + ); + } + } + + // Delete the entire session immediately after downloading, so uploads + // sit on the server only for the seconds between polls. + try { + await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); + } catch (cleanupErr) { + console.warn( + "[useMobileTransferSession] post-download cleanup failed:", + cleanupErr, + ); + } + } catch (err) { + console.error("[useMobileTransferSession] polling failed:", err); + setError(pollingErrorMessage); + } + }, [active, sessionId, onFileReceived, pollingErrorMessage]); + + // Create the session while active; delete it when deactivated/unmounted. + useEffect(() => { + if (!active) return; + + createSession(sessionId); + setFilesReceived(0); + setError(null); + setShowExpiryWarning(false); + processedFiles.current.clear(); + + return () => { + apiClient + .delete(`/api/v1/mobile-scanner/session/${sessionId}`) + .catch((err) => + console.warn("[useMobileTransferSession] cleanup failed:", err), + ); + }; + }, [active, sessionId, createSession]); + + // Poll for uploads while the session is live + useEffect(() => { + if (active && sessionInfo) { + pollIntervalRef.current = window.setInterval(pollForFiles, 2000); + pollForFiles(); + } else if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [active, sessionInfo, pollForFiles]); + + // Session timeout timer: warn under a minute, regenerate on expiry + useEffect(() => { + if (!active || !sessionInfo) return; + + const updateTimer = () => { + const now = Date.now(); + const remaining = sessionInfo.expiresAt - now; + + if (remaining <= 0) { + setShowExpiryWarning(false); + regenerateSession(); + } else if (remaining <= 60000 && !showExpiryWarning) { + setShowExpiryWarning(true); + } + + setTimeRemaining(Math.max(0, remaining)); + }; + + updateTimer(); + timerIntervalRef.current = window.setInterval(updateTimer, 1000); + + return () => { + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + } + }; + }, [active, sessionInfo, showExpiryWarning, regenerateSession]); + + return { + /** URL to encode in the QR code. */ + mobileUrl, + sessionInfo, + /** Count of files received this session (resets on regenerate). */ + filesReceived, + error, + /** Milliseconds until the session expires, once known. */ + timeRemaining, + /** True inside the final minute before expiry. */ + showExpiryWarning, + regenerateSession, + }; +} diff --git a/frontend/editor/src/core/hooks/useOpenPlan.ts b/frontend/editor/src/core/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..da6fe2207e --- /dev/null +++ b/frontend/editor/src/core/hooks/useOpenPlan.ts @@ -0,0 +1,10 @@ +/** + * Opens the plan surface behind the sidebar footer's free-credits row, or null + * when this build has none (the row is then inert text rather than a button). + * + * Core ships no wallet and no plan section, so there is nothing to open. Builds + * that meter usage override this with their own surface. + */ +export function useOpenPlan(): (() => void) | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useOtherAppSwitch.ts b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..612589899b --- /dev/null +++ b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts @@ -0,0 +1,12 @@ +import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; + +/** + * The sibling app this build can switch to (editor ⇄ processor), or null when + * there is none. The single gate behind both the brand switcher and the + * sidebar footer's "Open ..." row, so the two can never disagree about access. + * + * Core ships no processor, so there is nothing to switch to. + */ +export function useOtherAppSwitch(): NavFooterAppLink | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useScopedFetchCache.test.ts b/frontend/editor/src/core/hooks/useScopedFetchCache.test.ts new file mode 100644 index 0000000000..2965fc2898 --- /dev/null +++ b/frontend/editor/src/core/hooks/useScopedFetchCache.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useScopedFetchCache } from "@app/hooks/useScopedFetchCache"; + +const TTL = 30_000; + +describe("useScopedFetchCache", () => { + it("exposes fetched values and clears loading", async () => { + const fetcher = vi.fn(async (key: string) => `value-${key}`); + const { result, rerender } = renderHook( + ({ keys }: { keys: readonly string[] }) => + useScopedFetchCache(keys, fetcher, TTL), + { initialProps: { keys: ["a", "b"] as readonly string[] } }, + ); + + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.values).toEqual({ a: "value-a", b: "value-b" }); + expect(fetcher).toHaveBeenCalledTimes(2); + + // Fresh keys within the TTL are served from cache, not refetched. + rerender({ keys: ["a", "b"] }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("stamps failed keys so they are not retried within the TTL", async () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + const fetcher = vi.fn(async () => { + throw new Error("source unavailable"); + }); + const { result, rerender } = renderHook( + ({ keys }: { keys: readonly string[] }) => + useScopedFetchCache(keys, fetcher, TTL), + { initialProps: { keys: ["a"] as readonly string[] } }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.values).toEqual({}); + expect(fetcher).toHaveBeenCalledTimes(1); + + // Re-requesting the failed key (a keystroke re-render) must not refire. + rerender({ keys: ["a"] }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetcher).toHaveBeenCalledTimes(1); + debug.mockRestore(); + }); + + it("keeps successes when a sibling key fails", async () => { + const fetcher = vi.fn(async (key: string) => { + if (key === "bad") throw new Error("nope"); + return `value-${key}`; + }); + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + const { result } = renderHook(() => + useScopedFetchCache(["good", "bad"], fetcher, TTL), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.values).toEqual({ good: "value-good" }); + debug.mockRestore(); + }); +}); diff --git a/frontend/editor/src/core/hooks/useScopedFetchCache.ts b/frontend/editor/src/core/hooks/useScopedFetchCache.ts new file mode 100644 index 0000000000..2b965f556b --- /dev/null +++ b/frontend/editor/src/core/hooks/useScopedFetchCache.ts @@ -0,0 +1,131 @@ +import { useEffect, useRef, useState } from "react"; + +interface FetchSuccess { + key: K; + status: "fulfilled"; + value: V; +} +interface FetchFailure { + key: K; + status: "rejected"; + reason: unknown; +} +type FetchOutcome = FetchSuccess | FetchFailure; + +export interface ScopedFetchCache { + /** Latest successfully fetched value per key; absent until first success. */ + values: Partial>; + /** True while any requested key is being (re)fetched. */ + loading: boolean; +} + +/** + * A keyed async cache for search sources: each requested key is fetched at + * most once per TTL window, concurrent requests for the same key share one + * in-flight promise, and results from a superseded request generation are + * dropped (though their in-flight promises are still awaited by the next + * generation rather than re-fired). + * + * Failed keys are stamped like successes — a deployment without that endpoint + * answers the same way on every keystroke, so hammering it per keypress buys + * nothing. The failure is logged at debug level and retried after the TTL. + * + * `fetchKey` identity is the cache's world-view: when it changes (e.g. a tier + * change producing a different fetcher), every key is considered stale. + */ +export function useScopedFetchCache( + requestedKeys: readonly K[], + fetchKey: (key: K) => Promise, + ttlMs: number, +): ScopedFetchCache { + const [values, setValues] = useState>>({}); + const [loading, setLoading] = useState(false); + const fetchedAtRef = useRef(new Map()); + const inFlightRef = useRef(new Map>>()); + const requestIdRef = useRef(0); + const fetcherRef = useRef(fetchKey); + + useEffect(() => { + if (fetcherRef.current !== fetchKey) { + fetcherRef.current = fetchKey; + fetchedAtRef.current.clear(); + inFlightRef.current.clear(); + // Values from the old world-view must not surface under the new one + // (e.g. a tier change altering what a payload contains). + setValues({}); + } + + if (requestedKeys.length === 0) { + requestIdRef.current += 1; + setLoading(false); + return; + } + + const now = Date.now(); + const staleKeys = requestedKeys.filter( + (key) => now - (fetchedAtRef.current.get(key) ?? 0) >= ttlMs, + ); + if (staleKeys.length === 0) { + setLoading(false); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setLoading(true); + + void Promise.all( + staleKeys.map((key) => { + const existing = inFlightRef.current.get(key); + if (existing) return existing; + + const request = fetchKey(key) + .then((value) => ({ key, status: "fulfilled" as const, value })) + .catch((reason) => ({ key, status: "rejected" as const, reason })) + .finally(() => { + if (inFlightRef.current.get(key) === request) { + inFlightRef.current.delete(key); + } + }); + inFlightRef.current.set(key, request); + return request; + }), + ).then((results) => { + if (requestIdRef.current !== requestId) return; + + const fetchedAt = Date.now(); + for (const result of results) { + // Stamp failures too — see the hook doc. + fetchedAtRef.current.set(result.key, fetchedAt); + if (result.status === "rejected") { + console.debug( + "[useScopedFetchCache] source unavailable:", + result.key, + result.reason, + ); + } + } + + const fulfilled = results.filter( + (result): result is FetchSuccess => result.status === "fulfilled", + ); + if (fulfilled.length > 0) { + setValues((current) => { + const next = { ...current }; + for (const result of fulfilled) next[result.key] = result.value; + return next; + }); + } + + setLoading(false); + }); + + return () => { + if (requestIdRef.current === requestId) { + requestIdRef.current += 1; + } + }; + }, [requestedKeys, fetchKey, ttlMs]); + + return { values, loading }; +} diff --git a/frontend/editor/src/core/hooks/useSuperSearch.test.ts b/frontend/editor/src/core/hooks/useSuperSearch.test.ts new file mode 100644 index 0000000000..0f1f573e8b --- /dev/null +++ b/frontend/editor/src/core/hooks/useSuperSearch.test.ts @@ -0,0 +1,286 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("react-router-dom", () => ({ + useNavigate: vi.fn(), +})); + +vi.mock("@app/contexts/ToolWorkflowContext", () => ({ + useToolWorkflow: vi.fn(), +})); + +vi.mock("@app/contexts/NavigationContext", () => ({ + useNavigationActions: vi.fn(), +})); + +vi.mock("@app/contexts/ViewerContext", () => ({ + ViewerContext: React.createContext(null), +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: vi.fn(), +})); + +vi.mock("@app/contexts/file/fileHooks", () => ({ + useFileActions: vi.fn(), +})); + +vi.mock("@app/auth/UseSession", () => ({ + useAuth: vi.fn(() => ({ + portalAccess: false, + isAdmin: false, + role: null, + })), +})); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getLeafStirlingFileStubs: vi.fn(), + }, +})); + +vi.mock("@app/data/settingsSearchIndex", () => ({ + SETTINGS_SEARCH_INDEX: [ + { + section: "email", + anchor: "smtp-host", + labelKey: "settings.email.smtpHost", + labelFallback: "SMTP host", + keywords: ["smtp"], + }, + ], +})); + +vi.mock("@app/data/settingsSectionRegistry", () => ({ + SETTINGS_SECTION_REGISTRY: [ + { + key: "general", + labelKey: "settings.general.title", + labelFallback: "General", + keywords: ["general"], + }, + { + key: "email", + labelKey: "settings.email.title", + labelFallback: "Email", + keywords: ["mail"], + requiresLogin: true, + }, + { + key: "admin", + labelKey: "settings.admin.title", + labelFallback: "Admin", + keywords: ["admin"], + adminArea: true, + }, + { + key: "teams", + labelKey: "settings.teams.title", + labelFallback: "Team", + keywords: ["team"], + requiresAccount: true, + }, + ], +})); + +vi.mock("@app/data/settingsContentSearch", () => ({ + findSettingsContentMatch: vi.fn((section: string, query: string) => { + if (query === "smtp" && (section === "general" || section === "email")) { + return { section, query }; + } + return null; + }), + buildMatchSnippet: vi.fn( + (_match: unknown, query: string) => `Match: ${query}`, + ), +})); + +vi.mock("@app/data/processorSearchIndex", () => ({ + PROCESSOR_SEARCH_INDEX: [ + { + id: "users", + labelKey: "superSearch.processor.users", + labelFallback: "Users", + path: "/users", + keywords: ["members"], + }, + { + id: "docs", + labelKey: "superSearch.processor.docs", + labelFallback: "Docs", + path: "", + externalUrl: "https://example.com/docs", + keywords: ["manual"], + }, + ], +})); + +import type { TFunction } from "i18next"; +import { + assembleSuperSearchGroups, + rankProcessorResults, + rankSettingsResults, +} from "@app/hooks/useSuperSearch"; + +const t = ((key: string, fallback?: string) => + fallback ?? key) as unknown as TFunction; + +describe("useSuperSearch helpers", () => { + it("drops empty groups and preserves the requested group order", () => { + const groups = assembleSuperSearchGroups( + { + tools: [ + { + key: "tool:rotate", + group: "tools", + title: "Rotate", + score: 60, + onSelect: vi.fn(), + }, + ], + processor: [ + { + key: "processor:users", + group: "processor", + title: "Users", + score: 70, + onSelect: vi.fn(), + }, + ], + }, + t, + ["processor", "settings", "tools", "files"], + ); + + expect(groups.map((group) => group.id)).toEqual(["processor", "tools"]); + expect(groups.map((group) => group.label)).toEqual(["Processor", "Tools"]); + }); + + it("prefers row-level setting hits and skips duplicate content matches", () => { + const openSettings = vi.fn(); + + const results = rankSettingsResults( + "smtp", + t, + { + isAdmin: false, + loginEnabled: true, + }, + openSettings, + ); + + expect(results.map((result) => result.key)).toEqual([ + "setting:email:smtp-host", + "setting-content:general", + ]); + + void results[0]?.onSelect(); + expect(openSettings).toHaveBeenCalledWith("email", "smtp-host"); + }); + + it("keeps gated settings hidden while app config is unresolved", () => { + const results = rankSettingsResults("admin", t, null, vi.fn()); + + expect(results).toEqual([]); + expect(rankSettingsResults("team", t, null, vi.fn())).toEqual([]); + }); + + it("respects showSettingsWhenNoLogin for the no-login admin preview", () => { + const noLoginGates = { isAdmin: false, loginEnabled: false }; + + // Default (flag unset / true): no-login mode keeps the admin preview. + const shown = rankSettingsResults("admin", t, noLoginGates, vi.fn()); + expect(shown.map((result) => result.key)).toEqual([ + "setting-section:admin", + ]); + + // Flag off: the modal hides admin sections, so search must too. + const hidden = rankSettingsResults( + "admin", + t, + { ...noLoginGates, showSettingsWhenNoLogin: false }, + vi.fn(), + ); + expect(hidden).toEqual([]); + + // Admins keep admin sections regardless of the flag. + const admin = rankSettingsResults( + "admin", + t, + { isAdmin: true, loginEnabled: true, showSettingsWhenNoLogin: false }, + vi.fn(), + ); + expect(admin.map((result) => result.key)).toEqual([ + "setting-section:admin", + ]); + }); + + it("hides account-bound settings from anonymous sessions", () => { + const anonymous = rankSettingsResults( + "team", + t, + { isAdmin: false, loginEnabled: true, isAnonymous: true }, + vi.fn(), + ); + expect(anonymous).toEqual([]); + + const signedIn = rankSettingsResults( + "team", + t, + { isAdmin: false, loginEnabled: true, isAnonymous: false }, + vi.fn(), + ); + expect(signedIn.map((result) => result.key)).toEqual([ + "setting-section:teams", + ]); + + // Hosts that omit the flag (portal, always signed-in) keep the section. + const flagless = rankSettingsResults( + "team", + t, + { isAdmin: false, loginEnabled: true }, + vi.fn(), + ); + expect(flagless.map((result) => result.key)).toEqual([ + "setting-section:teams", + ]); + }); + + it("keeps the Processor group closed until gates resolve, then opens it for single-user mode", () => { + const selectEntry = vi.fn(); + + expect(rankProcessorResults("members", t, null, selectEntry)).toEqual([]); + + const results = rankProcessorResults( + "members", + t, + { + isAdmin: false, + loginEnabled: false, + }, + selectEntry, + ); + + expect(results.map((result) => result.key)).toEqual(["processor:users"]); + + void results[0]?.onSelect(); + expect(selectEntry).toHaveBeenCalledWith( + expect.objectContaining({ id: "users", path: "/users" }), + ); + }); + + it("opens the Processor group for a user with explicit portal access", () => { + const results = rankProcessorResults( + "members", + t, + { + isAdmin: false, + loginEnabled: true, + portalAccessible: true, + }, + vi.fn(), + ); + + expect(results.map((result) => result.key)).toEqual(["processor:users"]); + }); +}); diff --git a/frontend/editor/src/core/hooks/useSuperSearch.ts b/frontend/editor/src/core/hooks/useSuperSearch.ts new file mode 100644 index 0000000000..9f6a16f2f4 --- /dev/null +++ b/frontend/editor/src/core/hooks/useSuperSearch.ts @@ -0,0 +1,663 @@ +import { + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useAuth } from "@app/auth/UseSession"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; +import { ViewerContext } from "@app/contexts/ViewerContext"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useFileActions } from "@app/contexts/file/fileHooks"; +import { fileStorage } from "@app/services/fileStorage"; +import { FileDocIcon } from "@app/components/shared/FileDocIcon"; +import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon"; +import { detectFileExtension } from "@app/utils/fileUtils"; +import { openExternalUrl } from "@app/utils/safeNavigation"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { + rankByFuzzy, + idToWords, + FUZZY_MIN_SCORE, +} from "@app/utils/fuzzySearch"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { ToolId } from "@app/types/toolId"; +import { isComingSoonTool, type ToolRegistry } from "@app/data/toolsTaxonomy"; +import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex"; +import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry"; +import { + buildMatchSnippet, + findSettingsContentMatch, +} from "@app/data/settingsContentSearch"; +import { + PROCESSOR_SEARCH_INDEX, + isPortalEntityScopeAccessible, + type ProcessorSearchEntry, +} from "@app/data/processorSearchIndex"; +import { useProcessorEntityGroups } from "@app/data/processorEntitySearch"; +import { + PORTAL_ENTITY_SCOPE_DEFS, + PORTAL_DOCS_SCOPE_ID, + type SuperSearchGates, + type SuperSearchGroup, + type SuperSearchGroupId, + type SuperSearchQueryOptions, + type SuperSearchResult, + type SuperSearchScope, + type UseSuperSearchResult, +} from "@app/types/superSearch"; + +// Re-exported so existing consumers keep one import site; the definitions +// live in the types leaf (see that module for why). +export type { + SuperSearchGates, + SuperSearchGroup, + SuperSearchGroupId, + SuperSearchQueryOptions, + SuperSearchResult, + SuperSearchScope, + UseSuperSearchResult, +}; + +/** + * How many results a ranker computes per group. This is the ceiling the + * dropdown can reveal via "show more" — the component shows a small initial + * slice per group and expands to the rest on demand. + */ +export const GROUP_RESULT_CEILING = 24; +/** Group display order in the dropdown. */ +const GROUP_ORDER: SuperSearchGroupId[] = [ + "files", + "tools", + "settings", + "processor", +]; + +/** + * Whether the current user can enter the Processor at all: explicit portal + * access, admin, or single-user mode with login disabled. Null gates (config + * still loading) stay closed. + */ +export function isProcessorGateOpen(gates: SuperSearchGates | null): boolean { + return ( + !!gates && + (gates.portalAccessible === true || gates.isAdmin || !gates.loginEnabled) + ); +} + +/** The editor's visibility gates, from app config + the session's flags. */ +export function useSuperSearchGates(): SuperSearchGates | null { + const authState = useAuth(); + const { config } = useAppConfig(); + return useMemo( + () => + config + ? { + isAdmin: authState.isAdmin ?? config.isAdmin ?? false, + loginEnabled: config.enableLogin ?? false, + portalAccessible: authState.portalAccess ?? false, + isAnonymous: authState.isAnonymous, + showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true, + } + : null, + [authState.isAdmin, authState.isAnonymous, authState.portalAccess, config], + ); +} + +/** + * The editor bar's filter chips — one per source lane, granular over the + * Processor's contents (pages plus each entity type). Processor lanes only + * show when this build ships the portal and the user can enter it. + */ +export function useEditorSearchScopes(): SuperSearchScope[] { + const { t } = useTranslation(); + const gates = useSuperSearchGates(); + const processorAvailable = + PROCESSOR_SEARCH_INDEX.length > 0 && isProcessorGateOpen(gates); + + return useMemo(() => { + const visibleViewIds = new Set( + PROCESSOR_SEARCH_INDEX.map((entry) => entry.id), + ); + return [ + { + id: "files", + label: t("superSearch.group.files", "Files"), + aliases: ["file", "files"], + }, + { + id: "tools", + label: t("superSearch.group.tools", "Tools"), + aliases: ["tool", "tools"], + }, + { + id: "settings", + label: t("superSearch.group.settings", "Settings"), + aliases: ["setting", "settings"], + }, + ...(processorAvailable + ? [ + { + id: "processor", + label: t("superSearch.group.pages", "Pages"), + aliases: ["page", "pages", "processor", "portal"], + }, + ...PORTAL_ENTITY_SCOPE_DEFS.filter( + (def) => + visibleViewIds.has(def.viewId) && + isPortalEntityScopeAccessible(def.id, gates?.isAdmin ?? false), + ).map((def) => ({ + id: def.id, + label: t(def.labelKey, def.labelFallback), + aliases: [...def.aliases], + })), + ...(visibleViewIds.has("docs") + ? [ + { + id: PORTAL_DOCS_SCOPE_ID, + label: t("superSearch.group.docs", "Docs"), + aliases: ["doc", "docs", "documentation"], + }, + ] + : []), + ] + : []), + ]; + }, [t, processorAvailable, gates?.isAdmin]); +} + +/** + * Shared scope handling for hosts that accept SuperSearchQueryOptions: + * which source lanes are enabled for the current chip/prefix selection. + */ +export function useSearchScopeFilter(options?: SuperSearchQueryOptions): { + scopeEnabled: (scopeId: string) => boolean; +} { + const scopedIds = useMemo( + () => new Set(options?.scopeIds ?? []), + [options?.scopeIds], + ); + const hasScopedSearch = scopedIds.size > 0; + const scopeEnabled = useCallback( + (scopeId: string) => !hasScopedSearch || scopedIds.has(scopeId), + [hasScopedSearch, scopedIds], + ); + return { scopeEnabled }; +} + +// --------------------------------------------------------------------------- +// Shared sources. Every host bar (editor workbench, portal shell) builds its +// results from these, so a query ranks identically everywhere — only the +// select actions differ (in-app contexts vs cross-app navigation). +// --------------------------------------------------------------------------- + +/** Loads the My Files stubs whenever the search surface is open. */ +export function useMyFilesStubs(active: boolean): { + stubs: StirlingFileStub[]; + loadingFiles: boolean; +} { + const [stubs, setStubs] = useState([]); + const [loadingFiles, setLoadingFiles] = useState(false); + const loadedOnceRef = useRef(false); + + useEffect(() => { + if (!active) return; + // Refresh whenever the surface opens so newly added files appear. + let cancelled = false; + if (!loadedOnceRef.current) setLoadingFiles(true); + fileStorage + .getLeafStirlingFileStubs() + .then((all) => { + if (cancelled) return; + setStubs(all); + loadedOnceRef.current = true; + }) + .catch((err) => { + console.error("[SuperSearch] Failed to load file stubs:", err); + }) + .finally(() => { + if (!cancelled) setLoadingFiles(false); + }); + return () => { + cancelled = true; + }; + }, [active]); + + return { stubs, loadingFiles }; +} + +export function rankFileResults( + stubs: StirlingFileStub[], + trimmed: string, + openFile: (stub: StirlingFileStub) => void | Promise, + limit = GROUP_RESULT_CEILING, +): SuperSearchResult[] { + if (!trimmed) return []; + return rankByFuzzy(stubs, trimmed, [(s) => s.name]) + .slice(0, limit) + .map(({ item, score }) => ({ + key: `file:${item.id}`, + group: "files", + title: item.name, + // The file-type doc icon (PDF/image/doc/…) the sidebar and grid use, + // rather than a flat generic file glyph. Sized by height so the portrait + // doc shape sits level with the square tool/settings icons instead of + // overflowing the row. + icon: createElement(FileDocIcon, { + variant: getFileDocVariant( + detectFileExtension(item.name.toLowerCase()), + (item.type ?? "").toLowerCase(), + ), + style: { height: "1.15rem", width: "auto" }, + }), + score, + onSelect: () => openFile(item), + })); +} + +export function rankToolResults( + registry: Partial, + trimmed: string, + openTool: (id: ToolId) => void, + limit = GROUP_RESULT_CEILING, +): SuperSearchResult[] { + if (!trimmed) return []; + // Coming-soon placeholders are listed in the catalogue but can't open — + // selecting one would land on the "tool not found" panel. + const entries = ( + Object.entries(registry) as [ToolId, ToolRegistry[ToolId] | undefined][] + ).filter(([id, tool]) => tool && !isComingSoonTool(id, tool)); + return rankByFuzzy(entries, trimmed, [ + ([id]) => idToWords(id), + ([, v]) => v?.name ?? "", + ([, v]) => v?.description ?? "", + ([, v]) => v?.synonyms?.join(" ") ?? "", + ]) + .slice(0, limit) + .map(({ item: [id, tool], score }) => ({ + key: `tool:${id}`, + group: "tools", + title: tool?.name ?? id, + subtitle: tool?.description, + icon: tool?.icon, + score, + onSelect: () => openTool(id), + })); +} + +export function rankSettingsResults( + trimmed: string, + t: TFunction, + gates: SuperSearchGates | null, + openSettings: (section: string, anchor?: string) => void, + limit = GROUP_RESULT_CEILING, + /** Sections the host's settings modal refuses to show (e.g. the portal's + * hiddenSectionKeys) — offering them would deep-link into a blank modal. */ + excludeSections?: readonly string[], +): SuperSearchResult[] { + if (!trimmed) return []; + + // Sections gated like the modal nav. The registry resolves per build + // (core / proprietary / saas / desktop), so this only ever sees sections + // the current build's settings modal can actually show. + const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => { + if (excludeSections?.includes(s.key)) return false; + // Null gates (config still loading): hide every gated section. + // requiresLogin keys off the deployment's login *mode*, mirroring the nav + // builder: with login on the editor is login-walled (an unauthenticated + // visitor never reaches this code), and with login off those sections + // (account, API keys) have no meaning. Actual per-session auth state only + // matters on SaaS, where requiresAccount/isAnonymous carries it. + if (s.requiresLogin && !(gates?.loginEnabled ?? false)) return false; + // Admin-area sections mirror the builder's gate: admins always; no-login + // mode only while system.showSettingsWhenNoLogin keeps the admin preview. + const adminGateOpen = + !!gates && + (gates.isAdmin || + (!gates.loginEnabled && (gates.showSettingsWhenNoLogin ?? true))); + if (s.adminArea && !adminGateOpen) return false; + // Account-bound sections mirror the SaaS builder's `!isAnonymous` gate. + if (s.requiresAccount && (gates ? (gates.isAnonymous ?? false) : true)) + return false; + return true; + }); + // Row context: the display label of the section the row lives in. + const sectionLabelFor = new Map( + visibleSections.map((s) => [s.key, t(s.labelKey, s.labelFallback)]), + ); + + // Row-level entries (deep-link with ?focus=) take priority. Rows for + // sections this build/user can't open are dropped with them. + const rowMatches = rankByFuzzy( + SETTINGS_SEARCH_INDEX.filter((e) => sectionLabelFor.has(e.section)), + trimmed, + [ + (e) => t(e.labelKey, e.labelFallback), + (e) => e.labelFallback, + (e) => e.keywords?.join(" ") ?? "", + ], + ); + const rows = rowMatches.map(({ item, score }) => ({ + key: `setting:${item.section}:${item.anchor}`, + group: "settings", + title: t(item.labelKey, item.labelFallback), + subtitle: sectionLabelFor.get(item.section), + iconName: "settings-rounded", + score: score + 1, // nudge rows above bare section matches + onSelect: () => openSettings(item.section, item.anchor), + })); + // The nav group a section lives under, shown as result context (joined to a + // content-match snippet with " Ā· "). + const groupTitle = (s: (typeof SETTINGS_SECTION_REGISTRY)[number]) => + s.groupLabelKey + ? t(s.groupLabelKey, s.groupLabelFallback ?? "") + : undefined; + + const sectionMatches = rankByFuzzy(visibleSections, trimmed, [ + (s) => t(s.labelKey, s.labelFallback), + (s) => s.labelFallback, + (s) => s.keywords?.join(" ") ?? "", + ]); + const sections = sectionMatches.map(({ item, score }) => ({ + key: `setting-section:${item.key}`, + group: "settings", + title: t(item.labelKey, item.labelFallback), + subtitle: groupTitle(item), + iconName: "settings-rounded", + score, + onSelect: () => openSettings(item.key), + })); + + // Content matches: sections whose rendered copy contains the query, so terms + // with no curated keyword ("SMTP", a field label) still find their section. + // Ranked below every label/keyword match; 3+ chars so a single letter doesn't + // match half the modal. Sections already surfaced by a label match — their + // own or a row's — are skipped so the same hit isn't listed twice. + const labelMatchedKeys = new Set([ + ...sectionMatches.map(({ item }) => item.key), + ...rowMatches.map(({ item }) => item.section), + ]); + const contentMatches = + trimmed.length < 3 + ? [] + : visibleSections + .filter((s) => !labelMatchedKeys.has(s.key)) + .flatMap((s) => { + const match = findSettingsContentMatch(s.key, trimmed, t); + if (!match) return []; + const snippet = buildMatchSnippet(match, trimmed); + const group = groupTitle(s); + return [ + { + key: `setting-content:${s.key}`, + group: "settings", + title: t(s.labelKey, s.labelFallback), + subtitle: group ? `${group} Ā· ${snippet}` : snippet, + iconName: "settings-rounded", + // Always below the weakest possible label/keyword match. + score: FUZZY_MIN_SCORE - 10, + onSelect: () => openSettings(s.key), + }, + ]; + }); + + return [...rows, ...sections, ...contentMatches] + .sort((a, b) => b.score - a.score) + .slice(0, limit); +} + +export function rankProcessorResults( + trimmed: string, + t: TFunction, + gates: SuperSearchGates | null, + selectEntry: (entry: ProcessorSearchEntry) => void, + limit = GROUP_RESULT_CEILING, +): SuperSearchResult[] { + if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return []; + // Only offer Processor pages to users who can actually enter that app. + if (!isProcessorGateOpen(gates)) return []; + return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [ + (e) => t(e.labelKey, e.labelFallback), + (e) => e.labelFallback, + (e) => e.keywords?.join(" ") ?? "", + ]) + .slice(0, limit) + .map(({ item, score }) => ({ + key: `processor:${item.id}`, + group: "processor", + title: t(item.labelKey, item.labelFallback), + // Must exist in the bundled Material Symbols set (LocalIcon falls back + // to a network fetch for unknown names — blank when self-hosted offline). + iconName: "grid-view", + score, + onSelect: () => selectEntry(item), + })); +} + +/** + * Orders the sources into the shared group layout, dropping empties. Hosts + * pass their own order so local results lead (the editor puts its own + * files/tools first and Processor pages last; the portal the reverse). + */ +export function assembleSuperSearchGroups( + byId: Partial>, + t: TFunction, + order: SuperSearchGroupId[] = GROUP_ORDER, +): SuperSearchGroup[] { + const labels: Record = { + files: t("superSearch.group.files", "Files"), + tools: t("superSearch.group.tools", "Tools"), + settings: t("superSearch.group.settings", "Settings"), + processor: t("superSearch.group.processor", "Processor"), + }; + return order + .map((id) => ({ + id, + label: labels[id], + results: byId[id] ?? [], + })) + .filter((g) => g.results.length > 0); +} + +/** + * The editor's results provider: the shared sources wired to in-app select + * actions (open file → viewer, select tool in the workbench, deep-link into + * the settings modal, route into the Processor). + * + * @param query current search text + * @param active whether the search surface is open; gates the My Files load + */ +export function useSuperSearch( + query: string, + active: boolean, + options?: SuperSearchQueryOptions, +): UseSuperSearchResult { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { + toolRegistry, + handleToolSelect, + handleToolSelectForced, + toolAvailability, + } = useToolWorkflow(); + const { actions: navActions } = useNavigationActions(); + const { actions: fileActions } = useFileActions(); + // ViewerContext is only present once the viewer subtree mounts; treat as + // optional. Read through a ref: the provider value is rebuilt every viewer + // render, and a direct dependency would re-rank every result lane on each + // page turn — openFile only needs the value at click time. + const viewer = useContext(ViewerContext); + const viewerRef = useRef(viewer); + useEffect(() => { + viewerRef.current = viewer; + }, [viewer]); + + const trimmed = query.trim(); + const { stubs, loadingFiles } = useMyFilesStubs(active); + const { scopeEnabled } = useSearchScopeFilter(options); + const { pathname } = useLocation(); + + // Workbench-bound selections must leave the file manager through the router. + // Tool/file selection pins its URL via raw history.pushState, which the + // router never observes — so on /files the route keeps re-asserting the + // "myFiles" workbench and the selection appears to do nothing. Exit to the + // editor's home path: on processor-shipping builds "/" is a role router, + // not the editor. + const leaveFileManager = useCallback(() => { + if (pathname.startsWith("/files")) { + navigate(EDITOR_BASENAME); + } + }, [pathname, navigate]); + + // --- Actions ----------------------------------------------------------- + const openFile = useCallback( + async (stub: StirlingFileStub) => { + try { + // The file already lives in storage — load it as a stub so its id and + // metadata are preserved (addFiles would persist a duplicate record). + await fileActions.addStirlingFileStubs([stub], { selectFiles: true }); + navActions.setWorkbench("viewer"); + viewerRef.current?.setActiveFileId?.(stub.id); + leaveFileManager(); + } catch (err) { + console.error("[SuperSearch] Failed to open file:", stub.name, err); + } + }, + [fileActions, navActions, leaveFileManager], + ); + + const openTool = useCallback( + (id: ToolId) => { + // Link tools have no in-editor UI — selecting one shows a "tool not + // found" panel. Open their destination directly, matching how the + // editor's tool lists treat them. + const link = toolRegistry[id]?.link; + if (link) { + openExternalUrl(link); + return; + } + // Tools whose backend endpoint isn't served in this environment are + // flagged unavailable; handleToolSelect silently no-ops them. For a + // search ("take me to Repair") we still want the click to open the + // tool's UI, so fall back to the forced path for those. Available tools + // keep the normal path so the unsaved-changes guard still applies. + const available = toolAvailability[id]?.available !== false; + if (available) { + handleToolSelect(id); + } else { + handleToolSelectForced(id); + } + leaveFileManager(); + }, + [ + handleToolSelect, + handleToolSelectForced, + toolAvailability, + toolRegistry, + leaveFileManager, + ], + ); + + const openSettings = useCallback( + (section: string, anchor?: string) => { + const path = anchor + ? `/settings/${section}?focus=${encodeURIComponent(anchor)}` + : `/settings/${section}`; + navigate(path); + }, + [navigate], + ); + + const selectProcessorEntry = useCallback( + (item: ProcessorSearchEntry) => { + if (item.externalUrl) { + openExternalUrl(item.externalUrl); + } else { + navigate(item.path); + } + }, + [navigate], + ); + + // --- Assemble ---------------------------------------------------------- + const gates = useSuperSearchGates(); + + // Processor entities (users, policies, pipelines, sources) join the pages + // under the Processor section — same access gate as the pages group, each + // entity type filterable by its own scope. + const entityGroups = useProcessorEntityGroups( + trimmed, + active && trimmed.length > 0 && isProcessorGateOpen(gates), + t, + navigate, + scopeEnabled, + gates?.isAdmin ?? false, + ); + + const groups = useMemo(() => { + const assembledGroups = assembleSuperSearchGroups( + { + files: scopeEnabled("files") + ? rankFileResults(stubs, trimmed, openFile) + : [], + tools: scopeEnabled("tools") + ? rankToolResults(toolRegistry, trimmed, openTool) + : [], + settings: scopeEnabled("settings") + ? rankSettingsResults(trimmed, t, gates, openSettings) + : [], + processor: scopeEnabled("processor") + ? rankProcessorResults(trimmed, t, gates, selectProcessorEntry) + : [], + }, + t, + ); + + // Section order: Editor first, Settings second, Processor last. + const processorSection = t("superSearch.group.processor", "Processor"); + const sectionFor = (groupId: string): string => { + if (groupId === "processor") return processorSection; + if (groupId === "settings") + return t("superSearch.group.settings", "Settings"); + return t("portal.nav.editor", "Editor"); + }; + return [ + ...assembledGroups.map((group) => ({ + ...group, + label: + group.id === "processor" + ? t("superSearch.group.pages", "Pages") + : group.label, + sectionLabel: sectionFor(group.id), + })), + ...entityGroups.map((group) => ({ + ...group, + sectionLabel: processorSection, + })), + ]; + }, [ + stubs, + trimmed, + openFile, + toolRegistry, + openTool, + gates, + openSettings, + selectProcessorEntry, + scopeEnabled, + entityGroups, + t, + ]); + + const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]); + + return { groups, flatResults, loadingFiles }; +} diff --git a/frontend/editor/src/core/hooks/useToolManagement.tsx b/frontend/editor/src/core/hooks/useToolManagement.tsx index 87f9b2f889..951ead5f22 100644 --- a/frontend/editor/src/core/hooks/useToolManagement.tsx +++ b/frontend/editor/src/core/hooks/useToolManagement.tsx @@ -3,6 +3,7 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { getAllEndpoints, + isComingSoonTool, type ToolRegistryEntry, type ToolRegistry, } from "@app/data/toolsTaxonomy"; @@ -167,12 +168,7 @@ export const useToolManagement = (): ToolManagementResult => { ? availabilityInfo.available !== false : true; - // Check if tool is "coming soon" (has no component and no link) - const isComingSoon = - !baseTool.component && - !baseTool.link && - toolKey !== "read" && - toolKey !== "multiTool"; + const isComingSoon = isComingSoonTool(toolKey, baseTool); if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) { return; diff --git a/frontend/editor/src/core/hooks/useUrlSync.ts b/frontend/editor/src/core/hooks/useUrlSync.ts index ec4ede495d..5fad71ba29 100644 --- a/frontend/editor/src/core/hooks/useUrlSync.ts +++ b/frontend/editor/src/core/hooks/useUrlSync.ts @@ -12,6 +12,7 @@ import { import { ToolRegistry } from "@app/data/toolsTaxonomy"; import { firePixel } from "@app/utils/scarfTracking"; import { withBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { useAppConfig } from "@app/contexts/AppConfigContext"; /** @@ -35,7 +36,7 @@ export function useNavigationUrlSync( const tool = registry[toolId]; if (tool?.requiresPremium === true && premiumEnabled !== true) { // Premium tool accessed without premium - redirect to home - const homePath = withBasePath("/"); + const homePath = withBasePath(EDITOR_BASENAME); if (window.location.pathname !== homePath) { clearToolRoute(true); // Use replaceState to avoid adding to history window.location.href = homePath; @@ -81,7 +82,7 @@ export function useNavigationUrlSync( } else if (prevSelectedTool.current !== null) { // Only clear URL if we had a tool before (user navigated away) // Don't clear on initial load when both current and previous are null - const homePath = withBasePath("/"); + const homePath = withBasePath(EDITOR_BASENAME); if (window.location.pathname !== homePath) { clearToolRoute(false); // Use pushState for user navigation } diff --git a/frontend/editor/src/core/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts index 2a9bd33d6d..9dfbc5b21b 100644 --- a/frontend/editor/src/core/i18n/translationAudit.ts +++ b/frontend/editor/src/core/i18n/translationAudit.ts @@ -79,7 +79,8 @@ export const I18N_PROJECTS: TranslationProject[] = [ // SignSettings / SavedSignaturesSection resolve every key as // t(`${scope}.${key}`); scope and leaf only ever exist as separate literals. /^(sign|addText|addImage)\./, - // SettingsSearchBar indexes whole subtrees via t(prefix, { returnObjects }). + // Super search's settings content matching (settingsContentSearch) + // indexes whole subtrees via t(prefix, { returnObjects }). /^admin\.settings\./, /^settings\./, /^account\./, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 983d5213ed..b1d8708649 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -28,6 +28,7 @@ import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; import { FilesPageProvider, @@ -116,7 +117,7 @@ export default function HomePage() { const handleCloseConfig = useCallback(() => { setConfigModalOpen(false); if (location.pathname.startsWith("/settings")) { - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } }, [location.pathname, navigate]); @@ -523,7 +524,7 @@ export default function HomePage() { } onToggleCollapse={() => { if (navigationState.workbench === "myFiles") { - navigate("/"); + navigate(EDITOR_BASENAME); return; } setFileSidebarCollapsed((c) => { @@ -598,11 +599,6 @@ const MyFilesSidebarOverrides = forwardRef( { - // Just focus the central search field; don't toggle collapse - // (which on /files navigates back home). - window.dispatchEvent(new Event("files-page:focus-search")); - }} onUploadFiles={handleUpload} onPickGoogleDriveFiles={handleUpload} extraAction={{ diff --git a/frontend/editor/src/core/pages/MobileScannerPage.tsx b/frontend/editor/src/core/pages/MobileScannerPage.tsx index bce84cf371..3dfce5509b 100644 --- a/frontend/editor/src/core/pages/MobileScannerPage.tsx +++ b/frontend/editor/src/core/pages/MobileScannerPage.tsx @@ -26,6 +26,7 @@ import { type JscanifyScanner, } from "@app/utils/loadJscanify"; import apiClient from "@app/services/apiClient"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; // Use the configured API base (e.g. api.stirling.com), not the page origin. const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); @@ -890,7 +891,7 @@ export default function MobileScannerPage() { window.close(); // Fallback if window.close() doesn't work (some browsers block it) if (!window.closed) { - navigate("/"); + navigate(EDITOR_BASENAME); } }, 1500); } catch (err) { @@ -1107,7 +1108,7 @@ export default function MobileScannerPage() { diff --git a/frontend/editor/src/core/pages/MobileSignPage.test.tsx b/frontend/editor/src/core/pages/MobileSignPage.test.tsx new file mode 100644 index 0000000000..84bcf1b736 --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.test.tsx @@ -0,0 +1,100 @@ +/** + * Session-state contract for the phone-side signature page: a missing or + * expired session shows one clear error instead of a canvas whose Send would + * fail; a valid session shows the draw/type/photo tabs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; +import MobileSignPage from "@app/pages/MobileSignPage"; + +vi.mock("@app/services/apiClient", () => ({ + default: { defaults: { baseURL: "http://localhost:8080" } }, +})); + +// Render the English fallbacks the assertions read (the test i18n instance +// has no loaded locale, so bare t() would render raw keys). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +// Branding components pull theme preferences from providers this page doesn't +// need for its session-state contract. +vi.mock("@app/components/shared/LogoIcon", () => ({ + LogoIcon: () => , +})); +vi.mock("@app/components/shared/Wordmark", () => ({ + Wordmark: () => , +})); + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +describe("MobileSignPage", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + // jsdom has no ResizeObserver; the draw canvas sizes itself with one. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom's canvas has no real 2d context (and logs an error when asked); + // the draw canvas tolerates a null context. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the expired-session error when the URL has no session", async () => { + renderAt("/mobile-sign"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("shows the expired-session error when the backend rejects the session", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + json: async () => ({ valid: false }), + } as Response); + + renderAt("/mobile-sign?session=stale-session"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + }); + + it("shows the signature tabs once the session validates", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ valid: true }), + } as Response); + + renderAt("/mobile-sign?session=good-session"); + + await waitFor(() => expect(screen.getByText("Draw")).toBeInTheDocument()); + expect(screen.getByText("Type")).toBeInTheDocument(); + expect(screen.getByText("Photo")).toBeInTheDocument(); + expect(screen.getByText(/Send to computer/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/core/pages/MobileSignPage.tsx b/frontend/editor/src/core/pages/MobileSignPage.tsx new file mode 100644 index 0000000000..fd4034e0ba --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.tsx @@ -0,0 +1,554 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + Alert, + Box, + Card, + Group, + Image, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { Button as DSButton } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { useTranslation } from "react-i18next"; +import { LogoIcon } from "@app/components/shared/LogoIcon"; +import { Wordmark } from "@app/components/shared/Wordmark"; +import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import UndoRoundedIcon from "@mui/icons-material/UndoRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; +import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; +import { + MobileDrawCanvas, + type MobileDrawCanvasHandle, +} from "@app/components/mobileSign/MobileDrawCanvas"; +import apiClient from "@app/services/apiClient"; + +// Use the configured API base (e.g. api.stirling.com), not the page origin. +const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); + +type SignatureTab = "draw" | "type" | "photo"; + +// Ink pigments, not UI theme colours: they are baked into the exported PNG +// and transferred to the desktop, so they must be fixed literals. +const INK_COLORS = [ + { value: "#101010", label: "black" }, // theme-allow-color ink pigment, serialized into the signature + { value: "#1d4ed8", label: "blue" }, // theme-allow-color ink pigment, serialized into the signature +]; + +const PEN_SIZES = [ + { value: 2, label: "S" }, + { value: 3.5, label: "M" }, + { value: 6, label: "L" }, +]; + +/** + * The sign tool's own text-mode fonts, so a typed signature transfers as + * data and stays editable there. `css` approximates each for the on-phone + * preview; `value` is what the desktop's font parameter understands. + */ +const TYPE_FONTS = [ + { + value: "Helvetica", + css: "Helvetica, Arial, sans-serif", + label: "Helvetica", + }, + { + value: "Times-Roman", + css: "'Times New Roman', Times, serif", + label: "Times", + }, + { + value: "Courier", + css: "'Courier New', Courier, monospace", + label: "Courier", + }, + { value: "Arial", css: "Arial, sans-serif", label: "Arial" }, + { value: "Georgia", css: "Georgia, serif", label: "Georgia" }, +]; + +async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + return response.blob(); +} + +/** + * MobileSignPage + * + * Phone-side page for sending a signature to the desktop: draw one (the main + * path), type one, or photograph one. Reached by scanning the QR code shown in + * the editor's Sign tool; the session comes from the QR URL and rides the same + * transfer backend as the mobile scanner. + */ +export default function MobileSignPage() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const sessionId = searchParams.get("session"); + // Landscape phones (not tablets — hence the height cap) get a compact + // layout: branding hidden, tighter padding, shorter pad, so the canvas and + // the Send button fit on screen together. + const compactLandscape = + useMediaQuery("(orientation: landscape) and (max-height: 32rem)") ?? false; + + const [sessionValid, setSessionValid] = useState(null); + const [tab, setTab] = useState("draw"); + const [hasInk, setHasInk] = useState(false); + const [typedText, setTypedText] = useState(""); + const [typeFont, setTypeFont] = useState(TYPE_FONTS[0].value); + const [inkColor, setInkColor] = useState(INK_COLORS[0].value); + const [penSize, setPenSize] = useState(PEN_SIZES[1].value); + const [photoDataUrl, setPhotoDataUrl] = useState(null); + const [photoError, setPhotoError] = useState(null); + const [isSending, setIsSending] = useState(false); + const [sendError, setSendError] = useState(null); + const [sentCount, setSentCount] = useState(0); + const [justSent, setJustSent] = useState(false); + + const canvasHandle = useRef(null); + const photoInputRef = useRef(null); + const cameraInputRef = useRef(null); + + // Validate the session up front, so a stale QR shows one clear error rather + // than a canvas whose Send fails. + useEffect(() => { + if (!sessionId) { + setSessionValid(false); + return; + } + (async () => { + try { + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`, + ); + const data = response.ok ? await response.json() : null; + setSessionValid(Boolean(data?.valid)); + } catch { + setSessionValid(false); + } + })(); + }, [sessionId]); + + const handlePhotoSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + setPhotoError( + t("mobileSign.photo.invalidType", "Please choose an image file."), + ); + return; + } + setPhotoError(null); + const reader = new FileReader(); + reader.onload = (event) => + setPhotoDataUrl((event.target?.result as string) ?? null); + reader.readAsDataURL(file); + }; + + /** + * What this tab sends: ink and photos as image files, typed signatures as a + * JSON payload (text + font + colour) so the desktop keeps them editable in + * the sign tool's text mode. The filename prefix tells the desktop which + * source the signature belongs to. + */ + const buildUpload = useCallback(async (): Promise<{ + blob: Blob; + filename: string; + } | null> => { + if (tab === "draw") { + const dataUrl = canvasHandle.current?.exportPng(); + if (!dataUrl) return null; + return { + blob: await dataUrlToBlob(dataUrl), + filename: `signature-draw-${Date.now()}.png`, + }; + } + if (tab === "type") { + const text = typedText.trim(); + if (!text) return null; + const payload = JSON.stringify({ + text, + fontFamily: typeFont, + color: inkColor, + }); + return { + blob: new Blob([payload], { type: "application/json" }), + filename: `signature-text-${Date.now()}.json`, + }; + } + if (!photoDataUrl) return null; + const blob = await dataUrlToBlob(photoDataUrl); + const extension = blob.type === "image/jpeg" ? "jpg" : "png"; + return { + blob, + filename: `signature-photo-${Date.now()}.${extension}`, + }; + }, [tab, typedText, typeFont, inkColor, photoDataUrl]); + + const canSend = + (tab === "draw" && hasInk) || + (tab === "type" && typedText.trim().length > 0) || + (tab === "photo" && photoDataUrl !== null); + + const handleSend = async () => { + if (!sessionId) return; + + setIsSending(true); + setSendError(null); + try { + const upload = await buildUpload(); + if (!upload) return; + const formData = new FormData(); + formData.append("files", upload.blob, upload.filename); + + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`, + { method: "POST", body: formData }, + ); + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + setSentCount((count) => count + 1); + setJustSent(true); + // Reset the inputs so "send another" starts clean + canvasHandle.current?.clear(); + setTypedText(""); + setPhotoDataUrl(null); + if (photoInputRef.current) photoInputRef.current.value = ""; + if (cameraInputRef.current) cameraInputRef.current.value = ""; + } catch (err) { + console.error("[MobileSignPage] upload failed:", err); + setSendError( + t( + "mobileSign.sendError", + "Could not send the signature. Check the connection and try again.", + ), + ); + } finally { + setIsSending(false); + } + }; + + const header = ( + + + + + ); + + if (sessionValid === null) { + return ( + + {header} + + {t("mobileSign.validating", "Checking session…")} + + + ); + } + + if (!sessionValid) { + return ( + + {header} + } + color="red" + title={t("mobileSign.invalidSession", "Session expired")} + > + {t( + "mobileSign.invalidSessionMessage", + "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code.", + )} + + + ); + } + + return ( + + {!compactLandscape && header} + + {justSent && ( + } + color="green" + mb="sm" + withCloseButton + onClose={() => setJustSent(false)} + > + {t( + "mobileSign.sentMessage", + "Signature sent to your computer. You can send another or close this page.", + )} + + )} + {sendError && ( + } + color="red" + mb="sm" + > + {sendError} + + )} + + + + fullWidth + value={tab} + onChange={setTab} + ariaLabel={t("mobileSign.tabsLabel", "Signature source")} + options={[ + // Same order as the sign tool's sources: canvas, image, text + { value: "draw", label: t("mobileSign.tab.draw", "Draw") }, + { value: "photo", label: t("mobileSign.tab.photo", "Photo") }, + { value: "type", label: t("mobileSign.tab.type", "Type") }, + ]} + /> + + + {tab === "draw" && ( + + + + + + + + {INK_COLORS.map((color) => ( + setInkColor(color.value)} + aria-label={color.label} + style={{ + width: 32, + height: 32, + borderRadius: "50%", + background: color.value, + cursor: "pointer", + border: + inkColor === color.value + ? "3px solid var(--mantine-color-blue-4)" + : "3px solid transparent", + }} + /> + ))} + setPenSize(Number(value))} + ariaLabel={t("mobileSign.penSizeLabel", "Pen size")} + options={PEN_SIZES.map((size) => ({ + value: String(size.value), + label: size.label, + }))} + /> + + + canvasHandle.current?.undo()} + leftSection={} + > + {t("mobileSign.undo", "Undo")} + + canvasHandle.current?.clear()} + leftSection={ + + } + > + {t("mobileSign.clear", "Clear")} + + + + + )} + + {tab === "type" && ( + + setTypedText(e.target.value)} + placeholder={t("mobileSign.type.placeholder", "Your name")} + autoComplete="name" + /> + + + + )} + + + } + > + {sentCount > 0 + ? t("mobileSign.sendAnother", "Send another signature") + : t("mobileSign.send", "Send to computer")} + + + + ); +} diff --git a/frontend/editor/src/core/platform/openExternalTab.test.ts b/frontend/editor/src/core/platform/openExternalTab.test.ts new file mode 100644 index 0000000000..ccbbe4d818 --- /dev/null +++ b/frontend/editor/src/core/platform/openExternalTab.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { openExternalTab } from "@app/platform/openExternalTab"; +import { expectConsole } from "@app/tests/failOnConsole"; + +describe("openExternalTab (core/web)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Opening in a new tab is the point of this seam: @app/platform/openExternal + // navigates the current tab on saas, which would tear the user out of the PDF. + test("opens alongside the app rather than navigating it away", async () => { + const openSpy = vi + .spyOn(window, "open") + .mockImplementation(() => null as Window | null); + const originalHref = window.location.href; + + await openExternalTab("https://example.com/"); + + expect(openSpy).toHaveBeenCalledWith( + "https://example.com/", + "_blank", + "noopener,noreferrer", + ); + expect(window.location.href).toBe(originalHref); + }); + + // The seam is the sink, so it must not depend on callers having sanitised: + // window.open on a javascript: URL would execute it in our own origin. + test.each([ + "javascript:alert(1)", + " javascript:alert(1)", + "data:text/html,", + "file:///etc/passwd", + ])("refuses to open %s even if a caller skips sanitising", async (url) => { + const openSpy = vi + .spyOn(window, "open") + .mockImplementation(() => null as Window | null); + expectConsole.warn(/Refused to open unsafe URL/); + + await openExternalTab(url); + + expect(openSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/platform/openExternalTab.ts b/frontend/editor/src/core/platform/openExternalTab.ts new file mode 100644 index 0000000000..3a8d21d74a --- /dev/null +++ b/frontend/editor/src/core/platform/openExternalTab.ts @@ -0,0 +1,27 @@ +/** + * core/web implementation of the @app/platform/openExternalTab seam. + * + * Distinct from @app/platform/openExternal: that seam is for "leave and return" + * redirects (Stripe checkout), so its saas impl navigates the CURRENT tab. A PDF + * link must never do that — it would tear the user out of their document — so + * this seam always opens alongside the app. Desktop shadows it to escape the + * Tauri webview; saas/proprietary fall through to this window.open. + * + * Callers are expected to sanitise, but this is the sink that actually hands the + * URL to the browser, so it re-checks rather than trusting them: window.open on + * a `javascript:` URL executes it in our own origin. + */ +import { getExternalHref } from "@app/utils/externalUrl"; + +export type OpenExternalTab = (url: string) => Promise; + +export const openExternalTab: OpenExternalTab = async ( + url: string, +): Promise => { + const safeHref = getExternalHref(url); + if (!safeHref) { + console.warn("[openExternalTab] Refused to open unsafe URL:", url); + return; + } + window.open(safeHref, "_blank", "noopener,noreferrer"); +}; diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index a7a68ea256..5354b56b63 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -6,5 +6,8 @@ export const qk = { ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, + /** Keyed on the asking identity: two users must never share one answer. */ + portalAccess: (userId: string | null) => + ["editor", "portalAccess", userId] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/core/routes/editorBasename.ts b/frontend/editor/src/core/routes/editorBasename.ts new file mode 100644 index 0000000000..a37f8d80ec --- /dev/null +++ b/frontend/editor/src/core/routes/editorBasename.ts @@ -0,0 +1,10 @@ +/** + * Path the editor app calls home - where "back to all tools" and every + * "return to the editor" navigation lands. + * + * Core ships no processor, so there is nothing for "/" to route between and the + * editor simply owns the root. Builds that DO ship a processor override this + * (see the proprietary copy): there "/" is a role-based router and the editor + * moves to its own URL. + */ +export const EDITOR_BASENAME = "/"; diff --git a/frontend/editor/src/core/services/failureReporting.test.ts b/frontend/editor/src/core/services/failureReporting.test.ts new file mode 100644 index 0000000000..2eadc3ae65 --- /dev/null +++ b/frontend/editor/src/core/services/failureReporting.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for the editor's failure reporter. Two properties matter: it never sends a + * document name, and it never lets its own failure reach the tool the user was + * running. + */ + +const post = vi.fn(); + +/** + * Indirection so one test can replace the transport with a plain throwing closure. + * A vi.fn that throws has its error re-reported by vitest even once the code under + * test has caught it, which would fail the very test asserting it was caught. + */ +let transport: (...args: unknown[]) => unknown = (...args) => post(...args); + +vi.mock("@app/services/apiClient", () => ({ + default: { post: (...args: unknown[]) => transport(...args) }, +})); + +const { reportToolFailure, reportFilesRemoved, errorCodeOf } = + await import("@app/services/failureReporting"); + +/** An axios-shaped rejection carrying a Problem Details body. */ +function problemDetail(errorCode: string, extra: Record = {}) { + return { + response: { + status: 400, + data: { type: "/errors/pdf-password", errorCode, ...extra }, + }, + message: "Request failed with status code 400", + }; +} + +describe("errorCodeOf", () => { + it("reads the code out of a Problem Details body", async () => { + await expect(errorCodeOf(problemDetail("E004"))).resolves.toBe("E004"); + }); + + it("reads the code out of a blob body, which is how a download-typed call fails", async () => { + const error = { + response: { + data: { + text: () => Promise.resolve(JSON.stringify({ errorCode: "E001" })), + }, + }, + }; + + await expect(errorCodeOf(error)).resolves.toBe("E001"); + }); + + it("returns null when the body carries no code", async () => { + await expect( + errorCodeOf({ response: { data: { title: "nope" } } }), + ).resolves.toBeNull(); + await expect(errorCodeOf({ message: "network error" })).resolves.toBeNull(); + await expect(errorCodeOf(undefined)).resolves.toBeNull(); + }); + + it("returns null rather than throwing on an unparseable blob", async () => { + const error = { + response: { data: { text: () => Promise.resolve("502") } }, + }; + + await expect(errorCodeOf(error)).resolves.toBeNull(); + }); +}); + +describe("reportToolFailure", () => { + beforeEach(() => { + post.mockReset().mockResolvedValue({ status: 204 }); + transport = (...args) => post(...args); + }); + + it("posts the operation, code and file ids", async () => { + await reportToolFailure({ + operation: "remove-password", + error: problemDetail("E004"), + fileIds: ["f-1", "f-2"], + }); + + expect(post).toHaveBeenCalledTimes(1); + const [path, body] = post.mock.calls[0] as [ + string, + Record, + ]; + expect(path).toBe("/api/v1/file-run-events/reports"); + expect(body).toMatchObject({ + operation: "remove-password", + errorCode: "E004", + fileIds: ["f-1", "f-2"], + }); + }); + + it("ignores names a caller hands it, and identifies files by id", async () => { + // fileNames is accepted and dropped on purpose, so a call site holding names cannot pass + // them somewhere they would be stored as a document reference. + await reportToolFailure({ + operation: "compress", + error: { response: { status: 500, data: {} }, message: "boom" }, + fileIds: ["f-1"], + fileNames: ["Q4 report.pdf"], + }); + + const body = post.mock.calls[0]?.[1] as Record; + expect(body.fileIds).toEqual(["f-1"]); + expect(JSON.stringify(body)).not.toContain("Q4 report.pdf"); + }); + + it("sends the message the user saw, unaltered", async () => { + // Their own error about their own file: trimming it only makes the row harder to act on. + await reportToolFailure({ + operation: "compress", + error: { + response: { status: 500, data: {} }, + message: "Failed on Q4 report.pdf", + }, + fileIds: ["f-1"], + }); + + const body = post.mock.calls[0]?.[1] as { detail: string }; + expect(body.detail).toBe("Failed on Q4 report.pdf"); + }); + + it("sends no team, because the server derives it", async () => { + await reportToolFailure({ + operation: "compress", + error: problemDetail("E004"), + fileIds: ["f-1"], + }); + + expect(JSON.stringify(post.mock.calls[0]?.[1])).not.toMatch(/team/i); + }); + + it("swallows its own failure so the tool's own error handling is unaffected", async () => { + transport = () => { + throw new Error("404 - no such route on a core build"); + }; + + let threw = false; + try { + await reportToolFailure({ + operation: "compress", + error: problemDetail("E004"), + fileIds: ["f-1"], + }); + } catch { + threw = true; + } + + expect(threw).toBe(false); + }); + + it("asks for its own failure not to be shown, since the tool's error is already on screen", async () => { + await reportToolFailure({ + operation: "compress", + error: problemDetail("E004"), + fileIds: ["f-1"], + }); + + expect(post.mock.calls[0]?.[2]).toMatchObject({ suppressErrorToast: true }); + }); + + it("logs a rejected report instead of losing it, and still does not throw", async () => { + // A 400 means this client built a bad report, e.g. one naming more files than a report may + // carry. Nothing else would ever surface it: the call is fire-and-forget and its toast is + // suppressed, so the console line is the only sign a client author gets. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + transport = () => { + throw Object.assign(new Error("Request failed with status code 400"), { + response: { + status: 400, + data: { + detail: + "a report may name at most 200 files, and this one named 5000", + }, + }, + }); + }; + + let threw = false; + try { + await reportToolFailure({ + operation: "compress", + error: problemDetail("E004"), + fileIds: ["f-1"], + }); + } catch { + threw = true; + } + + expect(threw).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("at most 200 files"); + warn.mockRestore(); + }); + + it("stays quiet when the route is simply absent, as on a build without failure tracking", async () => { + // Otherwise every tool failure on such a build would log, which is noise rather than a + // diagnostic. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + transport = () => { + throw Object.assign(new Error("Request failed with status code 404"), { + response: { status: 404, data: {} }, + }); + }; + + await reportToolFailure({ + operation: "compress", + error: problemDetail("E004"), + fileIds: ["f-1"], + }); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("reports a client-side refusal, which is a failure a leader can act on", async () => { + // The same class of problem as the processor rejecting a file type, which is + // already recorded. Unclassified, so the server files it as UNKNOWN. + await reportToolFailure({ + operation: "convert", + error: new Error("Unsupported conversion format"), + fileIds: ["f-1"], + }); + + const body = post.mock.calls[0]?.[1] as Record; + expect(body).toMatchObject({ + operation: "convert", + errorCode: null, + detail: "Unsupported conversion format", + }); + }); + + it("reports a network failure, which got no reply but did leave the browser", async () => { + await reportToolFailure({ + operation: "compress", + error: { request: {}, message: "Network Error" }, + fileIds: ["f-1"], + }); + + expect(post).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["an axios cancellation", { code: "ERR_CANCELED", message: "canceled" }], + [ + "the rethrown wrapper useToolApiCalls builds", + new Error("Operation was cancelled", { + cause: { code: "ERR_CANCELED" }, + }), + ], + ])("ignores %s, because the user chose to stop", async (_label, error) => { + await reportToolFailure({ operation: "compress", error, fileIds: ["f-1"] }); + + expect(post).not.toHaveBeenCalled(); + }); + + it("tells the server when files are deleted, so their failures leave the queue", async () => { + await reportFilesRemoved(["f-1", "f-2"]); + + const [path, body] = post.mock.calls[0] as [ + string, + Record, + ]; + expect(path).toBe("/api/v1/file-run-events/removed-files"); + expect(body).toEqual({ fileIds: ["f-1", "f-2"] }); + }); + + it("says nothing when no real file ids were deleted", async () => { + await reportFilesRemoved([]); + await reportFilesRemoved(["", " "]); + + expect(post).not.toHaveBeenCalled(); + }); + + it("swallows a failed deletion notice, since the file is gone locally either way", async () => { + transport = () => { + throw new Error("404 - no such route on a core build"); + }; + + let threw = false; + try { + await reportFilesRemoved(["f-1"]); + } catch { + threw = true; + } + + expect(threw).toBe(false); + }); + + it("does nothing without an operation to attribute the failure to", async () => { + await reportToolFailure({ + operation: "", + error: { response: { status: 500 } }, + fileIds: ["f-1"], + }); + + expect(post).not.toHaveBeenCalled(); + }); + + it("sends every file id, since trimming would lose failures silently", async () => { + const many = Array.from({ length: 60 }, (_, i) => `f-${i}`); + + await reportToolFailure({ + operation: "compress", + error: problemDetail("E004"), + fileIds: many, + }); + + const body = post.mock.calls[0]?.[1] as { fileIds: string[] }; + expect(body.fileIds).toEqual(many); + }); +}); diff --git a/frontend/editor/src/core/services/failureReporting.ts b/frontend/editor/src/core/services/failureReporting.ts new file mode 100644 index 0000000000..af0765f8bd --- /dev/null +++ b/frontend/editor/src/core/services/failureReporting.ts @@ -0,0 +1,183 @@ +import apiClient from "@app/services/apiClient"; + +/** + * Reports a tool failure the user hit in the editor, so it lands in the same queue + * as one from a folder or bucket. The editor calls tools directly, so nothing + * server-side knows about these unless the client says so. + * + * Best-effort throughout: a build without the failure registry has no such route, + * and a report failing must never disturb the tool's own error handling. + */ + +const REPORT_PATH = "/api/v1/file-run-events/reports"; +const REMOVED_FILES_PATH = "/api/v1/file-run-events/removed-files"; + +interface ToolFailureReport { + /** The tool that failed, e.g. `remove-password`. */ + operation: string; + /** Whatever the tool call rejected with. */ + error: unknown; + /** Opaque file ids from FileContext. Names are deliberately not accepted. */ + fileIds?: string[]; + /** + * Accepted and ignored, so a call site that has names on hand cannot pass them + * somewhere they would be stored. Present to make that explicit rather than to + * be used. + */ + fileNames?: string[]; +} + +/** + * The `errorCode` from a tool's Problem Details response, or null when there is + * none. A download-typed call fails with a Blob body, so that shape is parsed too. + */ +export async function errorCodeOf(error: unknown): Promise { + const data = (error as { response?: { data?: unknown } })?.response?.data; + if (!data) return null; + + const body = await asJson(data); + const code = (body as { errorCode?: unknown })?.errorCode; + return typeof code === "string" && code.trim() !== "" ? code : null; +} + +async function asJson(data: unknown): Promise { + if (typeof data === "object" && data !== null && !isBlobLike(data)) { + return data; + } + try { + const text = isBlobLike(data) + ? await data.text() + : typeof data === "string" + ? data + : ""; + return text ? JSON.parse(text) : null; + } catch { + return null; + } +} + +function isBlobLike(value: unknown): value is { text: () => Promise } { + return ( + typeof value === "object" && + value !== null && + typeof (value as { text?: unknown }).text === "function" + ); +} + +export async function reportToolFailure({ + operation, + error, + fileIds = [], +}: ToolFailureReport): Promise { + if (!operation || operation.trim() === "") return; + if (wasCancelled(error)) return; + + try { + await apiClient.post( + REPORT_PATH, + { + operation, + errorCode: await errorCodeOf(error), + fileIds, + detail: messageOf(error), + }, + // The reporter's own failure must not reach the user: they already have the + // tool's error on screen, and a second toast about the report would be noise + // about something they never asked for. + { suppressErrorToast: true }, + ); + } catch (reportError) { + // Still never rethrown: a core build has no such route, and a member's report can + // also be refused. But a report the server rejected as invalid is logged rather + // than lost, because nothing else would ever surface it. + warnIfRejected(operation, reportError); + } +} + +/** + * A report the server refused as malformed, which means this client built a bad one: worth a + * line in the console for whoever wrote it, since the call is fire-and-forget and its toast is + * suppressed. An absent route (404, a core build) or a session not allowed to report are + * expected, and stay quiet so an ordinary build does not log on every tool failure. + */ +function warnIfRejected(operation: string, error: unknown): void { + const response = (error as { response?: { status?: number; data?: unknown } }) + ?.response; + if (response?.status !== 400) return; + + console.warn( + `Failure report for "${operation}" was rejected by the server: ${reasonOf(response.data)}`, + ); +} + +/** Whatever the server said, out of a Problem Details body. */ +function reasonOf(data: unknown): string { + const body = data as { detail?: unknown; message?: unknown }; + const stated = + typeof body?.detail === "string" + ? body.detail + : typeof body?.message === "string" + ? body.message + : ""; + return stated.trim() === "" ? "no reason given" : stated; +} + +/** + * Tell the server a user deleted these files, so any failure recorded against them stops asking + * for attention. The rows stay for audit; they just leave the queue. + * + *

Best-effort like the reporter: a build without the failure registry has no such route, and + * deleting a file must not fail because the server could not be told. + */ +export async function reportFilesRemoved(fileIds: string[]): Promise { + const named = fileIds.filter( + (id) => typeof id === "string" && id.trim() !== "", + ); + if (named.length === 0) return; + + try { + // Toast suppressed for the same reason as a report: the user deleted a file and is not + // waiting to hear whether the server was told. + await apiClient.post( + REMOVED_FILES_PATH, + { fileIds: named }, + { suppressErrorToast: true }, + ); + } catch { + // The file is gone locally either way. A row left open is retention's problem. + } +} + +/** + * The message the user saw, sent as-is. It is their own error about their own file, so hiding + * parts of it would only make the row harder to act on. + */ +function messageOf(error: unknown): string { + const candidate = error as { message?: unknown }; + return typeof candidate?.message === "string" ? candidate.message : ""; +} + +/** + * A user cancelling is the one failure worth dropping: nothing went wrong and there + * is nothing for a reviewer to do. `useToolApiCalls` rethrows an axios cancellation + * as a plain Error with the original as its cause, so both shapes are checked. + * + *

Everything else is reported, client-side refusals included: an unsupported input + * format is the same class of problem as the processor rejecting a file type, which + * is already recorded. + */ +function wasCancelled(error: unknown): boolean { + const candidate = error as { + code?: unknown; + name?: unknown; + message?: unknown; + cause?: { code?: unknown; name?: unknown }; + }; + return ( + candidate?.code === "ERR_CANCELED" || + candidate?.cause?.code === "ERR_CANCELED" || + candidate?.name === "CanceledError" || + candidate?.cause?.name === "CanceledError" || + candidate?.message === "Operation was cancelled" + ); +} diff --git a/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts index d8530af026..d6f1c4b89d 100644 --- a/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts +++ b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts @@ -3,23 +3,26 @@ import "fake-indexeddb/auto"; import { expectConsole } from "@app/tests/failOnConsole"; /** - * Regression test for the WebKit nightly breakage introduced with the - * large-file OOM fix (#7175): `storeStirlingFile` began putting the `File` - * itself into IndexedDB (persisted by reference, so multi-GB uploads never - * materialize in JS memory). WebKit refuses blob values whenever it can't write - * the blob's backing file and rejects the request with `UnknownError: Error - * preparing Blob/File data to be stored in object store`, so on WebKit every - * upload silently failed to persist: files vanished on navigation, Compare - * slots never filled, and the classification backfill had no bytes to read. + * WebKit refuses blob values when it can't write the blob's backing file, so + * every upload silently failed to persist after #7175. Retried as a copy now. * - * The service now retries such a rejection with an ArrayBuffer copy and stops - * offering blobs for the rest of the session. + * It can also accept one and then lose the backing store. fake-indexeddb returns no + * Blob, so that loss is injected at the read; real round-trips: the e2e spec. */ -const nativeAdd = IDBObjectStore.prototype.add; +const alertMock = vi.hoisted(() => vi.fn()); +vi.mock("@app/components/toast", () => ({ + alert: (options: unknown) => alertMock(options), +})); -/** What each `add` attempt carried in `data` — the blob path or the copy path. */ +const nativeAdd = IDBObjectStore.prototype.add; +const nativePut = IDBObjectStore.prototype.put; +const nativeGet = IDBObjectStore.prototype.get; + +/** What each `add` attempt carried in `data`: blob path or copy path. */ let attempts: Array<"blob" | "copy"> = []; +/** The same, for `put` - the rewrite path a lost backing store recovers through. */ +let putAttempts: Array<"blob" | "copy"> = []; /** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */ class FailingRequest extends EventTarget { @@ -32,10 +35,7 @@ class FailingRequest extends EventTarget { } } -/** - * Record every add attempt, optionally failing the blob-valued ones the way an - * engine without blob storage does. - */ +/** Record every add, optionally failing the blob-valued ones. */ function instrumentAdd(options: { rejectBlobs: boolean }) { IDBObjectStore.prototype.add = function ( this: IDBObjectStore, @@ -58,11 +58,72 @@ function instrumentAdd(options: { rejectBlobs: boolean }) { } as typeof IDBObjectStore.prototype.add; } -/** - * A fresh service per test: whether the engine accepts blobs is remembered for - * the process lifetime by design, so tests must not inherit that decision from - * each other. - */ +/** Record every put, so the copy-rewrite recovery can be observed. */ +function instrumentPut() { + IDBObjectStore.prototype.put = function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey, + ) { + putAttempts.push( + (value as { data?: unknown } | null)?.data instanceof Blob + ? "blob" + : "copy", + ); + return key === undefined + ? nativePut.call(this, value) + : nativePut.call(this, value, key); + } as typeof IDBObjectStore.prototype.put; +} + +/** A stored blob whose backing store the engine has lost: it still reports a name, + * type and size, and every read of its bytes fails the way WebKit's does. */ +function blobWithLostBackingStore(): Blob { + const lost = () => { + throw new DOMException( + "The object can not be found here.", + "NotFoundError", + ); + }; + return Object.assign( + new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }), + { slice: lost, arrayBuffer: lost, text: lost, stream: lost }, + ); +} + +/** The next `deadReads` reads come back with a lost backing store, later ones + * untouched - so a repaired record can still be read normally. */ +function loseBackingStoreOnRead(deadReads: number) { + let remaining = deadReads; + IDBObjectStore.prototype.get = function ( + this: IDBObjectStore, + key: IDBValidKey | IDBKeyRange, + ) { + const request = nativeGet.call(this, key as IDBValidKey); + // One substitution per request, however often `result` is read. + let injected = false; + return new Proxy(request, { + get(target, prop) { + // Receiver must be the real request: IDBRequest's accessors are branded. + const value = Reflect.get(target, prop, target); + if (prop !== "result") { + return typeof value === "function" ? value.bind(target) : value; + } + if (!value || injected || remaining === 0) return value; + injected = true; + remaining--; + return { ...(value as object), data: blobWithLostBackingStore() }; + }, + set(target, prop, value) { + Reflect.set(target, prop, value, target); + return true; + }, + }); + } as typeof IDBObjectStore.prototype.get; +} + +/** A fresh service per test: the blob decision is remembered by design, so + * tests must not inherit it from each other. */ async function freshFileStorage() { vi.resetModules(); const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] = @@ -86,10 +147,58 @@ async function freshFileStorage() { beforeEach(() => { attempts = []; + putAttempts = []; + alertMock.mockClear(); + // The blob verdict is deliberately durable, so each test must start undecided. + localStorage.clear(); }); afterEach(() => { IDBObjectStore.prototype.add = nativeAdd; + IDBObjectStore.prototype.put = nativePut; + IDBObjectStore.prototype.get = nativeGet; +}); + +/** Abort the transaction the moment a write is issued over it. */ +function abortOnPut() { + IDBObjectStore.prototype.put = function (this: IDBObjectStore) { + const request = new FailingRequest( + new DOMException("transaction aborted", "AbortError"), + ) as unknown as IDBRequest; + this.transaction.abort(); + return request; + } as typeof IDBObjectStore.prototype.put; +} + +describe("read-modify-write — a refused rewrite must not hang or vanish", () => { + /** The abort guard used to sit on the read promise, leaving the write with a + * dead reject - and `.catch` can't rescue a promise that never settles. */ + test("settles instead of hanging when the write transaction aborts", async () => { + expectConsole.error(/Failed to mark file as processed/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("aborts.pdf"); + + abortOnPut(); + + // Before the fix this never settled and the test timed out. + await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(false); + }); + + /** The copy-and-retry recovery can't be exercised here: it needs a record that + * reads back as a Blob, which fake-indexeddb never returns. */ + test("a metadata rewrite still commits, and reports commit not put", async () => { + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("rewrite.pdf"); + + await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(true); + // Missing record: `false`, not a throw and not a claim of success. + await expect( + fileStorage.markFileAsProcessed("nope" as never), + ).resolves.toBe(false); + expect((await fileStorage.getStirlingFile(id))?.name).toBe("rewrite.pdf"); + }); }); describe("storeStirlingFile — blob-value fallback", () => { @@ -113,8 +222,7 @@ describe("storeStirlingFile — blob-value fallback", () => { const id = await store("webkit.pdf"); expect(attempts).toEqual(["blob", "copy"]); - // Readable back is what every downstream consumer depends on: rehydration - // after navigation, thumbnails, the classification backfill. + // Readable back is what rehydration, thumbnails and backfill depend on. expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf"); }); @@ -127,12 +235,32 @@ describe("storeStirlingFile — blob-value fallback", () => { attempts = []; const id = await store("second.pdf"); - // Straight to the copy path — no repeated blob probe, and only the single - // warning expected above. + // Straight to the copy path, and only the one warning expected above. expect(attempts).toEqual(["copy"]); expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf"); }); + /** Committing is not evidence the bytes survived, and by the next reload the + * source File is gone: without this the upload looks fine and the file is dead. */ + test("repairs a record whose stored blob loses its backing store", async () => { + expectConsole.warn(/could not read its bytes back/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + instrumentPut(); + loseBackingStoreOnRead(1); // only the store's own read-back is dead + + const id = await store("dead-on-arrival.pdf"); + + // Accepted as a blob, then rewritten from the file still in hand. + expect(attempts).toEqual(["blob"]); + expect(putAttempts).toEqual(["copy"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe( + "dead-on-arrival.pdf", + ); + // Self-healed, so nothing to tell the user about. + expect(alertMock).not.toHaveBeenCalled(); + }); + test("does not retry a failure a copy can't fix (quota)", async () => { const { store } = await freshFileStorage(); IDBObjectStore.prototype.add = function (this: IDBObjectStore) { @@ -144,3 +272,246 @@ describe("storeStirlingFile — blob-value fallback", () => { expect(attempts).toEqual(["blob"]); }); }); + +/** An earlier session's record can't be repaired, and the user has to be told - but + * the telling must never gate the open. Awaiting the probe stalled every file in + * Safari, where the probe read of a lost backing store never settles. */ +describe("reads — a stored blob whose bytes are gone", () => { + test("hands the file over and reports the loss out of band", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("lost.pdf"); + + loseBackingStoreOnRead(5); // every read from here on + + // Not null, and not awaited on the probe: the caller is never blocked. + expect((await fileStorage.getStirlingFile(id))?.name).toBe("lost.pdf"); + await new Promise((resolve) => setTimeout(resolve)); + + // Told once, not once per reader: every consumer of the file hits this record. + expect(alertMock).toHaveBeenCalledTimes(1); + expect(alertMock.mock.calls[0][0]).toMatchObject({ + alertType: "warning", + body: expect.stringContaining("lost.pdf"), + }); + }); + + /** The loop this closes: a reload re-decides optimistically, writes blobs the + * engine loses again, and the browser never settles on a shape that works. */ + test("remembers across reloads that this browser loses blob values", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const first = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await first.store("lost.pdf"); + loseBackingStoreOnRead(1); + expect(await first.fileStorage.getStirlingFile(id)).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve)); + + // A new page load: a fresh service, same browser profile. + const next = await freshFileStorage(); + attempts = []; + const later = await next.store("after-reload.pdf"); + + expect(attempts).toEqual(["copy"]); + expect((await next.fileStorage.getStirlingFile(later))?.name).toBe( + "after-reload.pdf", + ); + }); + + test("stops offering blob values for the rest of the session", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const first = await store("lost.pdf"); + + loseBackingStoreOnRead(1); + expect(await fileStorage.getStirlingFile(first)).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve)); + + // An engine that loses a blob it accepted can't be trusted with the next one, + // so the read failure degrades writes too. + attempts = []; + const second = await store("later.pdf"); + expect(attempts).toEqual(["copy"]); + expect((await fileStorage.getStirlingFile(second))?.name).toBe("later.pdf"); + }); +}); + +/** Deleting a file used to leave its superseded versions in storage forever, + * invisible (listings filter on isLeaf) and still holding their full bytes. */ +describe("orphanedAncestorIds", () => { + const store = async ( + fileStorage: { storeStirlingFile: (f: never, s: never) => Promise }, + id: string, + parentFileId: string | undefined, + isLeaf: boolean, + ) => { + const { createStirlingFile, createNewStirlingFileStub } = + await import("@app/types/fileContext"); + const file = new File(["%PDF-1.7"], `${id}.pdf`, { + type: "application/pdf", + }); + const base = createNewStirlingFileStub(file); + await fileStorage.storeStirlingFile( + createStirlingFile(file, id as never) as never, + { ...base, id, isLeaf, parentFileId, originalFileId: "v1" } as never, + ); + }; + + test("takes the superseded versions with the leaf", async () => { + const { fileStorage } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + await store(fileStorage as never, "v1", undefined, false); + await store(fileStorage as never, "v2", "v1", true); + + expect(await fileStorage.orphanedAncestorIds(["v2" as never])).toEqual([ + "v1", + ]); + }); + + test("leaves a split sibling's history alone", async () => { + const { fileStorage } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + // Distinct ids: the fake database outlives the module reset between tests. + await store(fileStorage as never, "split-root", undefined, false); + await store(fileStorage as never, "split-a", "split-root", true); + await store(fileStorage as never, "split-b", "split-root", true); + + // `split-b` still descends from the root, so deleting `split-a` can't strip it. + expect(await fileStorage.orphanedAncestorIds(["split-a" as never])).toEqual( + [], + ); + // Once both leaves go, the shared ancestor is genuinely unreachable. + expect( + await fileStorage.orphanedAncestorIds([ + "split-a" as never, + "split-b" as never, + ]), + ).toEqual(["split-root"]); + }); +}); + +/** Handing dead bytes over is only safe if whoever holds them is told to let go - + * otherwise the viewer renders a document that never loads (an endless spinner). */ +describe("confirmed-unreadable records", () => { + test("notifies listeners and refuses to hand the same file out twice", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + const { onRecordUnreadable } = await import("@app/services/fileStorage"); + instrumentAdd({ rejectBlobs: false }); + const id = await store("doomed.pdf"); + + const dropped: string[] = []; + const unsubscribe = onRecordUnreadable((fileId) => dropped.push(fileId)); + + loseBackingStoreOnRead(5); + // First read still hands the file over: the probe is out of band. + expect(await fileStorage.getStirlingFile(id)).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve)); + + // The holder is told, so the workbench can drop it instead of spinning. + expect(dropped).toEqual([id]); + // And a second consumer never gets the same dead bytes. + expect(await fileStorage.getStirlingFile(id)).toBeNull(); + + unsubscribe(); + }); +}); + +/** A readable-blob substitute, for the rescue path: fake-indexeddb never returns + * Blob values, so a healthy legacy blob record is injected the same way a dead + * one is. */ +function substituteHealthyBlobOnRead(reads: number) { + let remaining = reads; + IDBObjectStore.prototype.get = function ( + this: IDBObjectStore, + key: IDBValidKey | IDBKeyRange, + ) { + const request = nativeGet.call(this, key as IDBValidKey); + let injected = false; + return new Proxy(request, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (prop !== "result") { + return typeof value === "function" ? value.bind(target) : value; + } + if (!value || injected || remaining === 0) return value; + injected = true; + remaining--; + return { + ...(value as object), + data: new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }), + }; + }, + set(target, prop, value) { + Reflect.set(target, prop, value, target); + return true; + }, + }); + } as typeof IDBObjectStore.prototype.get; +} + +/** The library must tell the truth per row: a record whose bytes are gone lists + * as data-lost instead of a file that pretends to open. */ +describe("stub listings — data-lost auditing", () => { + test("flags a dead record on the stub once the audit lands", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("husk.pdf"); + + loseBackingStoreOnRead(1); + // First read schedules the out-of-band audit; unknown is not yet flagged. + expect( + (await fileStorage.getStirlingFileStub(id))?.dataUnavailable, + ).toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve)); + + expect((await fileStorage.getStirlingFileStub(id))?.dataUnavailable).toBe( + true, + ); + }); + + test("rescues a still-readable legacy blob to a copy on a no-blob browser", async () => { + // The durable verdict says this browser loses blob values... + localStorage.setItem("stirling.indexeddb.blobValuesUnsupported", "true"); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + instrumentPut(); + const id = await store("legacy.pdf"); + + // ...and a legacy record still holds a READABLE blob: save it while we can. + substituteHealthyBlobOnRead(5); + await fileStorage.getStirlingFileStub(id); + await vi.waitFor(() => expect(putAttempts).toContain("copy")); + // Rescued, not condemned: the stub stays openable. + expect( + (await fileStorage.getStirlingFileStub(id))?.dataUnavailable, + ).toBeUndefined(); + }); +}); + +/** One hung request inside the TTL bump's readwrite transaction wedged the whole + * store: every later read and write queued behind it forever - the infinite + * "Loading files..." after a Safari reload. Maintenance must not touch + * blob-bodied records on a browser that can't rewrite them anyway. */ +describe("maintenanceMayRewrite", () => { + test("keeps maintenance away from blob records on a no-blob browser", async () => { + const { maintenanceMayRewrite } = await import("@app/services/fileStorage"); + const blobRecord = { data: new Blob(["x"]) }; + const copyRecord = { data: new ArrayBuffer(1) }; + + expect(maintenanceMayRewrite(blobRecord, false)).toBe(false); + // Copies never hang and their rewrite is accepted - always safe. + expect(maintenanceMayRewrite(copyRecord, false)).toBe(true); + // On engines that genuinely support blobs (Chrome), nothing changes. + expect(maintenanceMayRewrite(blobRecord, true)).toBe(true); + expect(maintenanceMayRewrite(copyRecord, true)).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 40f62642f0..27b8746604 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -15,6 +15,7 @@ import { indexedDBManager, DATABASE_CONFIGS, } from "@app/services/indexedDBManager"; +import { alert } from "@app/components/toast"; /** * Storage record - single source of truth @@ -75,15 +76,135 @@ function isBlobValueRejection(error: unknown): boolean { return name === "UnknownError" || name === "DataCloneError"; } +/** This engine loses Blob values, remembered per browser: session-scoped, each + * reload re-decides optimistically and writes more files it will lose. */ +const BLOB_VALUES_UNSUPPORTED_KEY = "stirling.indexeddb.blobValuesUnsupported"; + +function readBlobValuesSupported(): boolean { + try { + return localStorage.getItem(BLOB_VALUES_UNSUPPORTED_KEY) !== "true"; + } catch { + // Storage unavailable (private mode): decide fresh each session. + return true; + } +} + +function persistBlobValuesUnsupported(): void { + try { + localStorage.setItem(BLOB_VALUES_UNSUPPORTED_KEY, "true"); + } catch { + // Storage unavailable: the session-scoped flag still degrades this session. + } +} + +/** + * Whether maintenance writes (the thumbnail TTL bump) may re-read/re-write this + * record. In WebKit, a `get` touching a blob-bodied record whose backing store is + * damaged can HANG rather than error - and one pending request wedges the whole + * object store: every later transaction, read or write, queues behind it forever. + * That was the infinite "Loading files..." after a reload: the TTL bump's + * transaction never completed, so nothing else on the store ever ran. On a + * browser whose verdict is "blobs unsupported" the rewrite would be refused + * anyway, so blob-bodied records are not worth the risk of touching at all. + */ +export function maintenanceMayRewrite( + record: { data: ArrayBuffer | Blob }, + blobValuesSupported: boolean, +): boolean { + return !(record.data instanceof Blob) || blobValuesSupported; +} + +/** WebKit loses backing stores for blobs it accepted, and only a real read shows + * it. One byte is enough: what fails is opening the store, not the length. */ +async function blobReadFailure(data: Blob): Promise { + try { + await data.slice(0, 1).arrayBuffer(); + return null; + } catch (error) { + return error ?? new Error("Reading a stored blob's bytes failed"); + } +} + +/** Notified when a record's bytes are proven unreadable, so whoever is holding the + * file can drop it instead of rendering a document that never arrives. */ +const unreadableListeners = new Set<(fileId: FileId) => void>(); + +export function onRecordUnreadable( + listener: (fileId: FileId) => void, +): () => void { + unreadableListeners.add(listener); + return () => unreadableListeners.delete(listener); +} + +/** The probe read itself can hang in WebKit, so anything that awaits it needs a + * deadline. Distinct from a failure: nothing was proven either way. */ +const PROBE_UNANSWERED = { unanswered: true } as const; +const PROBE_DEADLINE_MS = 3000; + +function withProbeDeadline( + probe: Promise, +): Promise { + return Promise.race([ + probe, + new Promise((resolve) => + setTimeout(() => resolve(PROBE_UNANSWERED), PROBE_DEADLINE_MS), + ), + ]); +} + +/** + * The File for a stored record. Re-wrapping a stored blob can cost WebKit the + * backing handle, so hand it back untouched when its identity fields match. + */ +function fileFromRecord(record: StoredStirlingFileRecord): File { + const { data } = record; + if ( + data instanceof File && + data.name === record.name && + data.type === record.type && + data.lastModified === record.lastModified + ) { + return data; + } + return new File([data], record.name, { + type: record.type, + lastModified: record.lastModified, + }); +} + +/** + * Settle on abort, for promises whose settle paths (a cursor tick, a request not + * yet issued) never arrive. Call ONCE per transaction - there is one slot. + */ +function settleOnAbort( + transaction: IDBTransaction, + settle: (reason: Error) => void, +): void { + transaction.onabort = () => + settle( + transaction.error ?? + new Error("IndexedDB transaction aborted before it completed"), + ); +} + class FileStorageService { private readonly dbConfig = DATABASE_CONFIGS.FILES; private readonly storeName = "files"; - /** - * Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the - * blob path avoids copying multi-GB files into JS memory, so we try it and - * remember the answer, rather than pre-emptively degrading everywhere. - */ - private blobValuesSupported = true; + /** Whether this engine takes Blob/File values, which avoid copying multi-GB + * files into JS memory. Optimistic; a No outlives the session (see the key). */ + private blobValuesSupported = readBlobValuesSupported(); + /** Whether a stored blob's bytes have come back yet. Until they have, each + * store proves it: accepting the write is no evidence the bytes survived. */ + private blobReadbackVerified = false; + /** Ids whose TTL write failed. Without this the swallowed failure repeats a + * whole-file rewrite on every listing. Session-scoped on purpose. */ + private readonly unwritableRecords = new Set(); + /** Ids already reported as unreadable, so one dead record is surfaced once + * rather than on every read of it. Session-scoped on purpose. */ + private readonly unreadableRecords = new Set(); + /** Ids whose blob bytes this session has already audited (either way), so + * listings don't re-probe every record on every refresh. */ + private readonly auditedRecords = new Set(); /** * Get database connection using centralized manager @@ -101,7 +222,8 @@ class FileStorageService { /** Fire-and-forget: bump thumbnailStoredAt (or clear expired thumbnail) for a set of ids. */ private async bumpThumbnailTTL(ids: FileId[], clear = false): Promise { - if (ids.length === 0) return; + const targets = ids.filter((id) => !this.unwritableRecords.has(id)); + if (targets.length === 0) return; const db = await this.getDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); @@ -112,7 +234,7 @@ class FileStorageService { // Issue all gets up front - each onsuccess creates a put before the // transaction can auto-commit, keeping it alive until all puts settle. - ids.forEach((id) => { + targets.forEach((id) => { const req = store.get(id); req.onsuccess = () => { const record = req.result as StoredStirlingFileRecord | undefined; @@ -123,7 +245,30 @@ class FileStorageService { } else { record.thumbnailStoredAt = Date.now(); } - store.put(record); + // One unwritable record must not take the batch with it: a rejected + // put aborts the transaction the other queued gets are still using. + try { + const put = store.put(record); + put.onerror = (event) => { + // The write we just swallowed is the one that would have taken + // this record out of the expiring set, so stop retrying it. + this.unwritableRecords.add(id); + this.noteBlobRefusal(put.error); + console.warn( + `[fileStorage] thumbnail TTL bump skipped for ${id}:`, + put.error, + ); + // Swallow it here so the failure doesn't abort the transaction. + event.preventDefault(); + event.stopPropagation(); + }; + } catch (error) { + this.unwritableRecords.add(id); + console.warn( + `[fileStorage] thumbnail TTL bump could not be issued for ${id}:`, + error, + ); + } }; req.onerror = () => reject(req.error); }); @@ -186,18 +331,255 @@ class FileStorageService { } catch (error) { // Recoverable: re-add as a copy, and stop offering blobs this session. // Anything else is the caller's to report. - if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) { + if (!(record.data instanceof Blob) || !this.noteBlobRefusal(error)) { throw error; } - this.blobValuesSupported = false; - console.warn( - "IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " + - "Very large files may now exhaust renderer memory.", - error, - ); record.data = await record.data.arrayBuffer(); await this.addFileRecord(db, record); + return; } + + // Committed is not retrievable. Prove the round-trip while the source File is + // still in hand; after a reload there is nothing left to repair from. + if (record.data instanceof Blob && !this.blobReadbackVerified) { + await this.verifyStoredBlobReadable(db, record, stirlingFile); + } + } + + /** Read one stored blob back, rewriting the record from {@code source} if its + * bytes don't come with it. Runs until one round-trip succeeds. */ + private async verifyStoredBlobReadable( + db: IDBDatabase, + record: StoredStirlingFileRecord, + source: File, + ): Promise { + // A record we can't read back at all is the caller's problem, not the probe's. + const stored = await this.readRecord(db, record.id).catch(() => undefined); + if (!(stored?.data instanceof Blob)) return; + + const failure = await withProbeDeadline(blobReadFailure(stored.data)); + if (!failure) { + this.blobReadbackVerified = true; + return; + } + if (failure === PROBE_UNANSWERED) { + // Nothing proven, and an upload must never wait on a probe. Leave the record + // as written; the read path reports it if the bytes really are gone. + console.warn( + `[fileStorage] readability probe for ${record.id} did not answer in ${PROBE_DEADLINE_MS}ms`, + ); + return; + } + + this.noteBlobUnreadable(failure); + try { + record.data = await source.arrayBuffer(); + await this.putRecord(db, record); + } catch (error) { + // The record is unusable either way, and the read path reports that to the + // user. Don't turn a write that already committed into a failure. + console.warn( + `[fileStorage] could not rewrite ${record.id} as an in-memory copy:`, + error, + ); + } + } + + /** Refused a Blob value? Stop offering blobs on this browser. Any write can flip + * this: WebKit refuses per-operation, not per-engine. */ + private noteBlobRefusal(error: unknown): boolean { + if (!isBlobValueRejection(error)) return false; + this.disableBlobValues( + "IndexedDB rejected a Blob value; falling back to in-memory copies on this browser. " + + "Very large files may now exhaust renderer memory.", + error, + ); + return true; + } + + /** A stored blob whose bytes won't read back means blob values can't be trusted + * on this engine either, even though it accepted the write. */ + private noteBlobUnreadable(error: unknown): void { + this.disableBlobValues( + "IndexedDB accepted a Blob value but could not read its bytes back; " + + "falling back to in-memory copies on this browser. " + + "Very large files may now exhaust renderer memory.", + error, + ); + } + + private disableBlobValues(message: string, error: unknown): void { + if (!this.blobValuesSupported) return; + this.blobValuesSupported = false; + persistBlobValuesUnsupported(); + console.warn(message, error); + } + + /** + * Audit a blob-backed record's bytes WITHOUT gating anything on the answer. + * Awaiting this was a mistake: in Safari the probe read of a lost backing store + * can stay pending forever, so it stalled every file open instead of the one + * consumer that would have failed anyway. + * + * Two outcomes, both out of band: + * - Bytes gone: mark + report, so the library shows "data lost" instead of a + * file that pretends to open. + * - Bytes readable on a browser whose verdict is "blobs unsupported": RESCUE the + * record to an ArrayBuffer copy now, while the bytes still exist. Legacy blob + * records on WebKit are one engine hiccup away from being lost for good. + */ + private reportIfUnreadable(record: StoredStirlingFileRecord): void { + if (!(record.data instanceof Blob)) return; + if (this.auditedRecords.has(record.id)) return; + this.auditedRecords.add(record.id); + void blobReadFailure(record.data).then((failure) => { + if (!failure) { + this.blobReadbackVerified = true; + if (!this.blobValuesSupported) void this.rescueBlobRecord(record.id); + return; + } + this.noteBlobUnreadable(failure); + this.reportUnreadableRecord(record, failure); + }); + } + + /** + * Rewrite one still-readable legacy blob record as an ArrayBuffer copy. Reads + * the FULL bytes (the audit only proved the first one) and goes through + * {@link updateRecord}'s read-modify-write so a concurrent metadata update + * isn't clobbered by a stale snapshot. + */ + private async rescueBlobRecord(fileId: FileId): Promise { + try { + const db = await this.getDatabase(); + const record = await this.readRecord(db, fileId); + if (!(record?.data instanceof Blob)) return; + const bytes = await withProbeDeadline(record.data.arrayBuffer()); + if (bytes === PROBE_UNANSWERED || !(bytes instanceof ArrayBuffer)) return; + record.data = bytes; + await this.putRecord(db, record); + console.info( + `[fileStorage] rescued "${record.name}" (${fileId}) to an in-memory copy before this browser could lose its blob`, + ); + } catch (error) { + // Best-effort: a failed rescue leaves the record exactly as it was. + console.warn(`[fileStorage] could not rescue ${fileId}:`, error); + } + } + + /** One console error and one toast per dead record: every consumer of the file + * hits the same record, and the user needs the reason once, not per reader. */ + private reportUnreadableRecord( + record: StoredStirlingFileRecord, + failure: unknown, + ): void { + if (this.unreadableRecords.has(record.id)) return; + this.unreadableRecords.add(record.id); + // Whoever is holding it needs to let go, or the viewer renders a document + // whose bytes never arrive - a spinner with no terminal state. + for (const listener of unreadableListeners) listener(record.id); + console.error( + `[fileStorage] stored data for "${record.name}" (${record.id}) cannot be read; ` + + "the browser no longer has the blob's backing store", + failure, + ); + alert({ + alertType: "warning", + title: "File data is unavailable", + body: + `"${record.name}" is saved in this browser but its contents can no longer be read. ` + + "Upload the file again to keep working on it.", + expandable: false, + durationMs: 8000, + }); + } + + /** Read-modify-write one record in one transaction, resolving on COMMIT. Split + * across two promises, the abort guard covers one and the other hangs. */ + private async updateRecord( + fileId: FileId, + mutate: (record: StoredStirlingFileRecord) => boolean | void, + ): Promise { + const db = await this.getDatabase(); + try { + return await this.readModifyWrite(db, fileId, mutate); + } catch (error) { + // The record we read back still carries its Blob body; retry as a copy. + if (!this.noteBlobRefusal(error)) throw error; + return await this.rewriteRecordAsCopy(db, fileId, mutate); + } + } + + /** {@link updateRecord}'s happy path: one transaction, resolve on commit. */ + private readModifyWrite( + db: IDBDatabase, + fileId: FileId, + mutate: (record: StoredStirlingFileRecord) => boolean | void, + ): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + let written = false; + settleOnAbort(transaction, reject); + transaction.onerror = () => reject(transaction.error); + transaction.oncomplete = () => resolve(written); + + const store = transaction.objectStore(this.storeName); + const getRequest = store.get(fileId); + getRequest.onerror = () => reject(getRequest.error); + getRequest.onsuccess = () => { + const record = getRequest.result as + | StoredStirlingFileRecord + | undefined; + // Nothing to write: let the empty transaction commit and report false. + if (!record || mutate(record) === false) return; + written = true; + store.put(record); + }; + }); + } + + /** Recovery path: two transactions, because materializing the copy is async + * and a transaction cannot survive an await. Last-write-wins either way. */ + private async rewriteRecordAsCopy( + db: IDBDatabase, + fileId: FileId, + mutate: (record: StoredStirlingFileRecord) => boolean | void, + ): Promise { + const record = await this.readRecord(db, fileId); + if (!record || mutate(record) === false) return false; + if (record.data instanceof Blob) { + record.data = await record.data.arrayBuffer(); + } + await this.putRecord(db, record); + return true; + } + + /** One record by id, in its own transaction. */ + private readRecord( + db: IDBDatabase, + fileId: FileId, + ): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); + const request = transaction.objectStore(this.storeName).get(fileId); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + } + + /** One `put`, resolving on commit. */ + private putRecord( + db: IDBDatabase, + record: StoredStirlingFileRecord, + ): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + settleOnAbort(transaction, reject); + transaction.onerror = () => reject(transaction.error); + transaction.oncomplete = () => resolve(); + transaction.objectStore(this.storeName).put(record); + }); } /** Single `add` of a file record. Rejects with the underlying IDB error. */ @@ -215,6 +597,7 @@ class FileStorageService { } const transaction = db.transaction([this.storeName], "readwrite"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.add(record); @@ -227,37 +610,21 @@ class FileStorageService { }); } - /** - * Get StirlingFile with full data - for loading into workbench - */ + /** Get StirlingFile with full data - for loading into workbench. Null covers + * both no such record and bytes gone; neither is a file callers can use. */ async getStirlingFile(id: FileId): Promise { + // Already proven unreadable this session: don't hand the same dead bytes to + // another consumer that will spin on them. Session-scoped, so a reload retries. + if (this.unreadableRecords.has(id)) return null; const db = await this.getDatabase(); + const record = await this.readRecord(db, id); + if (!record) return null; + // Reporting only, and NEVER awaited: WebKit can leave a read of a lost backing + // store pending forever, and this is the path every file open goes through. + this.reportIfUnreadable(record); - return new Promise((resolve, reject) => { - const transaction = db.transaction([this.storeName], "readonly"); - const store = transaction.objectStore(this.storeName); - const request = store.get(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const record = request.result as StoredStirlingFileRecord | undefined; - if (!record) { - resolve(null); - return; - } - - // Create File from stored data - const blob = new Blob([record.data], { type: record.type }); - const file = new File([blob], record.name, { - type: record.type, - lastModified: record.lastModified, - }); - - // Convert to StirlingFile with preserved IDs - const stirlingFile = createStirlingFile(file, record.fileId); - resolve(stirlingFile); - }; - }); + // Convert to StirlingFile with preserved IDs + return createStirlingFile(fileFromRecord(record), record.fileId); } /** @@ -278,6 +645,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.get(id); @@ -295,9 +663,13 @@ class FileStorageService { // We still gate thumbnailUrl on freshness so stale thumbnails // don't leak through this read path. const fresh = this.isThumbnailFresh(record); + // Out-of-band byte audit, so the library reflects lost data (and rescues + // still-readable legacy blobs) instead of listing files that can't open. + this.reportIfUnreadable(record); const stub: StirlingFileStub = { id: record.id, + dataUnavailable: this.unreadableRecords.has(record.id) || undefined, name: record.name, type: record.type, size: record.size, @@ -338,6 +710,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.openCursor(); const stubs: StirlingFileStub[] = []; @@ -352,12 +725,18 @@ class FileStorageService { const record = cursor.value as StoredStirlingFileRecord; if (record && record.name && typeof record.size === "number") { const fresh = this.isThumbnailFresh(record); - if (record.thumbnail) { + if ( + record.thumbnail && + maintenanceMayRewrite(record, this.blobValuesSupported) + ) { if (fresh) tobump.push(record.id); else toexpire.push(record.id); } + this.reportIfUnreadable(record); stubs.push({ id: record.id, + dataUnavailable: + this.unreadableRecords.has(record.id) || undefined, name: record.name, type: record.type, size: record.size, @@ -425,6 +804,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.openCursor(); const leafStubs: StirlingFileStub[] = []; @@ -444,12 +824,18 @@ class FileStorageService { record.isLeaf !== false ) { const fresh = this.isThumbnailFresh(record); - if (record.thumbnail) { + if ( + record.thumbnail && + maintenanceMayRewrite(record, this.blobValuesSupported) + ) { if (fresh) tobump.push(record.id); else toexpire.push(record.id); } + this.reportIfUnreadable(record); leafStubs.push({ id: record.id, + dataUnavailable: + this.unreadableRecords.has(record.id) || undefined, name: record.name, type: record.type, size: record.size, @@ -579,6 +965,46 @@ class FileStorageService { return cleared; } + /** + * Superseded versions that nothing else needs once {@code deleting} goes. + * + * Deleting a file removes one record; its older versions keep their full bytes + * and are invisible (listings filter on isLeaf), so they accumulate forever. + * Only for user-facing "delete this file" - deleting ONE version from the + * history journey must leave the rest of the chain alone. + */ + async orphanedAncestorIds(deleting: FileId[]): Promise { + if (deleting.length === 0) return []; + const stubs = await this.getAllStirlingFileStubs(); + const byId = new Map(stubs.map((s) => [s.id as string, s])); + const doomed = new Set(deleting.map(String)); + + // Anything a surviving record descends from has to stay: split siblings + // share a lineage, so one leaf's delete must not strip another's history. + const keep = new Set(); + for (const stub of stubs) { + if (doomed.has(stub.id as string)) continue; + let cursor = stub.parentFileId as string | undefined; + while (cursor && !keep.has(cursor)) { + keep.add(cursor); + cursor = byId.get(cursor)?.parentFileId as string | undefined; + } + } + + const orphans: FileId[] = []; + for (const id of deleting) { + let cursor = byId.get(String(id))?.parentFileId as string | undefined; + while (cursor) { + if (!keep.has(cursor) && !doomed.has(cursor) && byId.has(cursor)) { + doomed.add(cursor); + orphans.push(cursor as FileId); + } + cursor = byId.get(cursor)?.parentFileId as string | undefined; + } + } + return orphans; + } + /** * Delete StirlingFile - single operation, no sync issues */ @@ -587,11 +1013,12 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - const request = store.delete(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); + // On commit, not on the request: callers refresh their list from storage as + // soon as this resolves, and an aborted delete would put the row back. + settleOnAbort(transaction, reject); + transaction.onerror = () => reject(transaction.error); + transaction.oncomplete = () => resolve(); + transaction.objectStore(this.storeName).delete(id); }); } @@ -617,45 +1044,16 @@ class FileStorageService { * Update thumbnail for existing file */ async updateThumbnail(id: FileId, thumbnail: string): Promise { - const db = await this.getDatabase(); - - return new Promise((resolve, _reject) => { - try { - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - const getRequest = store.get(id); - - getRequest.onsuccess = () => { - const record = getRequest.result as StoredStirlingFileRecord; - if (record) { - record.thumbnail = thumbnail; - record.thumbnailStoredAt = Date.now(); - const updateRequest = store.put(record); - - updateRequest.onsuccess = () => { - resolve(true); - }; - updateRequest.onerror = () => { - console.error("Failed to update thumbnail:", updateRequest.error); - resolve(false); - }; - } else { - resolve(false); - } - }; - - getRequest.onerror = () => { - console.error( - "Failed to get file for thumbnail update:", - getRequest.error, - ); - resolve(false); - }; - } catch (error) { - console.error("Transaction error during thumbnail update:", error); - resolve(false); - } - }); + // Reports failure as `false` rather than rejecting; callers just need an answer. + try { + return await this.updateRecord(id, (record) => { + record.thumbnail = thumbnail; + record.thumbnailStoredAt = Date.now(); + }); + } catch (error) { + console.error("Failed to update thumbnail:", error); + return false; + } } /** @@ -666,6 +1064,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.clear(); @@ -720,24 +1119,16 @@ class FileStorageService { async createBlobUrl(id: FileId): Promise { try { const db = await this.getDatabase(); + const record = await this.readRecord(db, id); + if (!record) return null; - return new Promise((resolve, reject) => { - const transaction = db.transaction([this.storeName], "readonly"); - const store = transaction.objectStore(this.storeName); - const request = store.get(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const record = request.result as StoredStirlingFileRecord | undefined; - if (record) { - const blob = new Blob([record.data], { type: record.type }); - const url = URL.createObjectURL(blob); - resolve(url); - } else { - resolve(null); - } - }; - }); + // Stored blobs are handed straight to createObjectURL — re-wrapping + // one can cost WebKit the backing handle. See fileFromRecord. + const blob = + record.data instanceof Blob + ? record.data + : new Blob([record.data], { type: record.type }); + return URL.createObjectURL(blob); } catch (error) { console.warn(`Failed to create blob URL for ${id}:`, error); return null; @@ -750,32 +1141,9 @@ class FileStorageService { */ async markFileAsProcessed(fileId: FileId): Promise { try { - const db = await this.getDatabase(); - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - - const record = await new Promise( - (resolve, reject) => { - const request = store.get(fileId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }, - ); - - if (!record) { - return false; // File not found - } - - // Update the isLeaf flag to false - record.isLeaf = false; - - await new Promise((resolve, reject) => { - const request = store.put(record); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return await this.updateRecord(fileId, (record) => { + record.isLeaf = false; }); - - return true; } catch (error) { console.error("Failed to mark file as processed:", error); return false; @@ -835,32 +1203,9 @@ class FileStorageService { */ async markFileAsLeaf(fileId: FileId): Promise { try { - const db = await this.getDatabase(); - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - - const record = await new Promise( - (resolve, reject) => { - const request = store.get(fileId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }, - ); - - if (!record) { - return false; // File not found - } - - // Update the isLeaf flag to true - record.isLeaf = true; - - await new Promise((resolve, reject) => { - const request = store.put(record); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return await this.updateRecord(fileId, (record) => { + record.isLeaf = true; }); - - return true; } catch (error) { console.error("Failed to mark file as leaf:", error); return false; @@ -870,41 +1215,16 @@ class FileStorageService { /** * Update metadata fields for a stored file record. * - * Resolves on transaction.oncomplete, NOT on the individual put's onsuccess, - * so callers only receive `true` once the write actually commits. If the - * transaction aborts after put() succeeded but before commit, we return false - * - the previous behavior incorrectly claimed success in that window. + * Returns `true` only once the write commits, never on the put's `onsuccess`. + * {@link updateRecord} owns that guarantee for every write in this class. */ async updateFileMetadata( fileId: FileId, updates: Partial, ): Promise { try { - const db = await this.getDatabase(); - return await new Promise((resolve, reject) => { - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - let recordFound = false; - - const getRequest = store.get(fileId); - getRequest.onsuccess = () => { - const record = getRequest.result as - | StoredStirlingFileRecord - | undefined; - if (!record) { - // Don't commit anything; caller wants false. - return; - } - recordFound = true; - const updatedRecord = { ...record, ...updates }; - store.put(updatedRecord); - }; - getRequest.onerror = () => reject(getRequest.error); - - transaction.oncomplete = () => resolve(recordFound); - transaction.onerror = () => reject(transaction.error); - transaction.onabort = () => - reject(transaction.error ?? new Error("updateFileMetadata aborted")); + return await this.updateRecord(fileId, (record) => { + Object.assign(record, updates); }); } catch (error) { console.error("Failed to update file metadata:", error); diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index ed7108b6a6..55b0a425f9 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -1,4 +1,4 @@ -// frontend/src/services/httpErrorHandler.ts +// frontend/editor/src/core/services/httpErrorHandler.ts import { alert } from "@app/components/toast"; import { broadcastErroredFiles, diff --git a/frontend/editor/src/core/services/indexedDBManager.blocked.test.ts b/frontend/editor/src/core/services/indexedDBManager.blocked.test.ts new file mode 100644 index 0000000000..9031b079bb --- /dev/null +++ b/frontend/editor/src/core/services/indexedDBManager.blocked.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { expectConsole } from "@app/tests/failOnConsole"; +import type { DatabaseConfig } from "@app/services/indexedDBManager"; + +/** + * A blocked open fires `blocked` and then nothing at all - no success, no error - + * until the other connection goes away. Unguarded, the open promise never settles + * and every caller hangs SILENTLY: the file library spun forever with an empty + * console, which is why this kept being reported as unreproducible. + */ + +const config = (name: string, version: number): DatabaseConfig => ({ + name, + version, + stores: [{ name: "things", keyPath: "id" }], +}); + +/** A raw connection on an older version that never yields, i.e. the other tab. */ +function holdOlderVersion(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains("things")) { + request.result.createObjectStore("things", { keyPath: "id" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.resetModules(); +}); + +describe("openDatabase — blocked by another connection", () => { + test("rejects with something actionable instead of hanging", async () => { + expectConsole.warn(/blocked by another connection/); + const { indexedDBManager } = await import("@app/services/indexedDBManager"); + const held = await holdOlderVersion("blocked-db"); + + vi.useFakeTimers(); + const open = indexedDBManager.openDatabase(config("blocked-db", 2)); + const settled = vi.fn(); + void open.then(settled, settled); + + // Still pending before the grace period is up: a tab that yields quickly + // must not be failed prematurely. + await vi.advanceTimersByTimeAsync(4_000); + expect(settled).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2_000); + await expect(open).rejects.toThrow(/blocked by another connection/); + + held.close(); + }); + + test("dedupes concurrent callers onto one connection", async () => { + const { indexedDBManager } = await import("@app/services/indexedDBManager"); + const spy = vi.spyOn(indexedDB, "open"); + + // Racing in the same tick is the case registration-after-await could not + // dedupe, and only the first request would ever receive `blocked`. + const [a, b, c] = await Promise.all([ + indexedDBManager.openDatabase(config("shared-db", 1)), + indexedDBManager.openDatabase(config("shared-db", 1)), + indexedDBManager.openDatabase(config("shared-db", 1)), + ]); + + expect(a).toBe(b); + expect(b).toBe(c); + expect( + spy.mock.calls.filter(([name]) => name === "shared-db"), + ).toHaveLength(1); + spy.mockRestore(); + }); +}); diff --git a/frontend/editor/src/core/services/indexedDBManager.ts b/frontend/editor/src/core/services/indexedDBManager.ts index 043b2e4386..dd823a2bd9 100644 --- a/frontend/editor/src/core/services/indexedDBManager.ts +++ b/frontend/editor/src/core/services/indexedDBManager.ts @@ -19,6 +19,11 @@ export interface DatabaseConfig { }[]; } +/** How long to wait out another connection before failing an open with something + * the user can act on. Rejecting does NOT cancel the request, so a connection + * that arrives later is closed rather than held. */ +const BLOCKED_GRACE_MS = 5000; + class IndexedDBManager { private static instance: IndexedDBManager; private databases = new Map(); @@ -47,6 +52,26 @@ class IndexedDBManager { return existingPromise; } + // Registered BEFORE anything async. A map written after a yield point can't + // dedupe callers racing into it in the same tick, so every context that opened + // this database during boot got its own connection - and per spec only the + // FIRST request ever receives `blocked`, leaving the rest waiting on an event + // that never comes. + const initPromise = this.openWithRecovery(config); + this.initPromises.set(config.name, initPromise); + + try { + const db = await initPromise; + this.databases.set(config.name, db); + return db; + } catch (error) { + this.initPromises.delete(config.name); + throw error; + } + } + + /** The v6/v7 wipe, kept off {@link openDatabase}'s synchronous registration path. */ + private async openWithRecovery(config: DatabaseConfig): Promise { // SaaS lineage shipped a v6 and a v7 of stirling-pdf-files whose // upgrade paths corrupted records (separate cursor walks racing in // one versionchange transaction). The SaaS build wipes those @@ -64,18 +89,7 @@ class IndexedDBManager { await this.deleteDatabase(config.name); } } - - const initPromise = this.performDatabaseInit(config); - this.initPromises.set(config.name, initPromise); - - try { - const db = await initPromise; - this.databases.set(config.name, db); - return db; - } catch (error) { - this.initPromises.delete(config.name); - throw error; - } + return this.performDatabaseInit(config); } private performDatabaseInit(config: DatabaseConfig): Promise { @@ -83,15 +97,60 @@ class IndexedDBManager { console.log(`Opening IndexedDB: ${config.name} v${config.version}`); const request = indexedDB.open(config.name, config.version); + // A blocked upgrade fires `blocked` and then NOTHING - no success, no error - + // until the other connection goes away. Unguarded, the promise never settles + // and every awaiting caller hangs with nothing in the console. + let settled = false; + let blockedTimer: ReturnType | undefined; + + request.onblocked = () => { + console.warn( + `Opening ${config.name} is blocked by another connection (another tab on an older version?). ` + + `Giving up in ${BLOCKED_GRACE_MS}ms if it doesn't yield.`, + ); + blockedTimer = setTimeout(() => { + if (settled) return; + settled = true; + reject( + new Error( + `Opening ${config.name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms. ` + + "Close other tabs of this app and reload.", + ), + ); + }, BLOCKED_GRACE_MS); + }; + request.onerror = () => { + clearTimeout(blockedTimer); + if (settled) return; + settled = true; console.error(`Failed to open ${config.name}:`, request.error); reject(request.error); }; request.onsuccess = () => { + clearTimeout(blockedTimer); const db = request.result; + // We already gave up waiting: close it rather than hold a handle nobody + // awaits, or we become the next tab's blocker. + if (settled) { + db.close(); + return; + } + settled = true; console.log(`Successfully opened ${config.name}`); + // Another tab wants a newer schema. Forget BEFORE closing: a cached but + // closed handle is worse than none, because every transaction on it throws. + db.onversionchange = () => { + console.warn( + `${config.name}: another tab requested a version change; closing this connection`, + ); + this.databases.delete(config.name); + this.initPromises.delete(config.name); + db.close(); + }; + // Set up close handler to clean up our references db.onclose = () => { console.log(`Database ${config.name} closed`); @@ -329,9 +388,36 @@ class IndexedDBManager { return new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(name); + // A delete blocks exactly like an upgrade, and this one is awaited on the + // files open path - so an unguarded block hangs the whole storage layer. + let settled = false; + let blockedTimer: ReturnType | undefined; - deleteRequest.onerror = () => reject(deleteRequest.error); + deleteRequest.onblocked = () => { + console.warn( + `Deleting ${name} is blocked by another connection; giving up in ${BLOCKED_GRACE_MS}ms.`, + ); + blockedTimer = setTimeout(() => { + if (settled) return; + settled = true; + reject( + new Error( + `Deleting ${name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms.`, + ), + ); + }, BLOCKED_GRACE_MS); + }; + + deleteRequest.onerror = () => { + clearTimeout(blockedTimer); + if (settled) return; + settled = true; + reject(deleteRequest.error); + }; deleteRequest.onsuccess = () => { + clearTimeout(blockedTimer); + if (settled) return; + settled = true; console.log(`Deleted database: ${name}`); resolve(); }; @@ -343,17 +429,32 @@ class IndexedDBManager { */ async getDatabaseVersion(name: string): Promise { return new Promise((resolve) => { + // This probe runs BEFORE the guarded open, and a versionless open can be + // delayed indefinitely by another tab mid-versionchange. Unknown after the + // grace period beats hanging every storage consumer: the real open that + // follows has its own blocked guard and a message the user can act on. + const giveUp = setTimeout(() => { + console.warn( + `Version probe for ${name} did not answer in ${BLOCKED_GRACE_MS}ms; proceeding without it.`, + ); + resolve(null); + }, BLOCKED_GRACE_MS); const request = indexedDB.open(name); request.onsuccess = () => { + clearTimeout(giveUp); const db = request.result; const version = db.version; db.close(); resolve(version); }; - request.onerror = () => resolve(null); + request.onerror = () => { + clearTimeout(giveUp); + resolve(null); + }; request.onupgradeneeded = () => { // Cancel the upgrade request.transaction?.abort(); + clearTimeout(giveUp); resolve(null); }; }); diff --git a/frontend/editor/src/core/services/navFooterCache.ts b/frontend/editor/src/core/services/navFooterCache.ts new file mode 100644 index 0000000000..cbde941c81 --- /dev/null +++ b/frontend/editor/src/core/services/navFooterCache.ts @@ -0,0 +1,73 @@ +/** + * Last-known sidebar-footer state, so the rows are correct at first paint + * instead of arriving a request later. + * + * The footer is mounted by both apps, and the editor and processor are separate + * React trees with separate query caches — so without this, every navigation + * between them (and every remount inside them) re-ran the fetches and the rows + * visibly popped in and shoved each other around. Persisting to storage rather + * than to an in-memory cache is what makes it survive that boundary, and a + * reload. + * + * Deliberately stale-then-revalidate: what's stored is only ever what the + * backend last said, every reader refetches immediately and overwrites, and + * nothing is gated on it — the processor enforces its own access server-side, + * and a stale credit figure is replaced within a second of the wallet landing. + */ +const CREDITS_KEY = "stirling.navFooter.credits"; +const OTHER_APP_KEY = "stirling.navFooter.otherApp"; + +/** Figures, or null for a team that sees no meter at all (a paying one). */ +export type CachedCredits = { remaining: number; total: number } | null; + +function read(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + // Private mode / storage disabled — behave as a first-ever load. + return null; + } +} + +function write(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Nothing to do: the cache is an optimisation, never a correctness input. + } +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedCredits(): CachedCredits | undefined { + const raw = read(CREDITS_KEY); + if (raw === null) return undefined; + if (raw === "none") return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as CachedCredits & object).remaining === "number" && + typeof (parsed as CachedCredits & object).total === "number" + ) { + return parsed as CachedCredits; + } + } catch { + // Corrupt entry — fall through and treat it as never-seen. + } + return undefined; +} + +export function writeCachedCredits(credits: CachedCredits): void { + write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits)); +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedOtherApp(): boolean | undefined { + const raw = read(OTHER_APP_KEY); + return raw === null ? undefined : raw === "true"; +} + +export function writeCachedOtherApp(canOpen: boolean): void { + write(OTHER_APP_KEY, String(canOpen)); +} diff --git a/frontend/editor/src/core/services/pdfiumInit.test.ts b/frontend/editor/src/core/services/pdfiumInit.test.ts new file mode 100644 index 0000000000..315c39a662 --- /dev/null +++ b/frontend/editor/src/core/services/pdfiumInit.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi } from "vitest"; + +/** + * A WASM instantiate that fails must reject, not hang. `instantiateWasm` reports + * success by callback, so a swallowed rejection leaves `init()` pending and takes + * every thumbnail, page parse and form read with it - silently. + */ + +const init = vi.hoisted(() => vi.fn()); +vi.mock("@embedpdf/pdfium", () => ({ init })); + +const wasmModule = vi.hoisted(() => ({}) as WebAssembly.Module); +vi.mock("@app/services/wasmPrecompiler", () => ({ + pdfiumWasmModulePromise: Promise.resolve(wasmModule), + startEagerWasmCompilation: () => {}, + pdfiumWasmUrl: "http://localhost/pdfium.wasm", +})); + +/** emscripten's contract: it calls instantiateWasm and waits to be called back. */ +function emscriptenInit( + instantiate: (imports: object, ok: () => void) => void, +) { + return new Promise(() => { + instantiate({}, () => {}); + }); +} + +async function loadService() { + vi.resetModules(); + return await import("@app/services/pdfiumService"); +} + +describe("pdfium bootstrap", () => { + test("rejects when instantiating the pre-compiled module fails", async () => { + const failure = new Error("LinkError: import mismatch"); + vi.spyOn(WebAssembly, "instantiate").mockRejectedValue(failure as never); + init.mockImplementation((overrides: Record) => + emscriptenInit( + overrides.instantiateWasm as unknown as ( + imports: object, + ok: () => void, + ) => void, + ), + ); + + const { getPdfiumModule } = await loadService(); + + // Before the fix this never settled, so the test timed out. + await expect(getPdfiumModule()).rejects.toThrow(/LinkError/); + }); + + test("a failed load isn't cached, so the next call retries", async () => { + const instantiate = vi + .spyOn(WebAssembly, "instantiate") + .mockRejectedValueOnce(new Error("transient") as never) + .mockResolvedValue({} as never); + const ready = { PDFiumExt_Init: () => {} }; + init.mockImplementation( + (overrides: Record) => + new Promise((resolve) => { + const instantiateWasm = overrides.instantiateWasm as unknown as ( + imports: object, + ok: () => void, + ) => void; + instantiateWasm({}, () => resolve(ready)); + }), + ); + + const { getPdfiumModule } = await loadService(); + + await expect(getPdfiumModule()).rejects.toThrow(/transient/); + await expect(getPdfiumModule()).resolves.toBe(ready); + expect(instantiate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/editor/src/core/services/pdfiumService.ts b/frontend/editor/src/core/services/pdfiumService.ts index a9a44c693b..d6cf12a311 100644 --- a/frontend/editor/src/core/services/pdfiumService.ts +++ b/frontend/editor/src/core/services/pdfiumService.ts @@ -80,17 +80,24 @@ function wasmUrl(): string { * This is the low-level PDFium WASM interface with all C functions wrapped. * Prefer `withDocument()` for document-scoped work. */ -export async function getPdfiumModule(): Promise { - if (_module) return _module; - if (!_initPromise) { - // Ensure eager compilation has started if PDF service is requested before idle timeout - startEagerWasmCompilation(); +/** Reuses the WASM pre-compiled at boot. Every failure must reach this promise: + * `instantiateWasm` reports success by callback, so a rejection inside it leaves + * `init()` pending forever - and with it every thumbnail, parse and form read. */ +async function initPdfiumModule(): Promise { + // Ensure eager compilation has started if PDF service is requested before idle timeout + startEagerWasmCompilation(); - const overrides: PdfiumModuleOverrides = { - locateFile: () => wasmUrl(), - }; + const overrides: PdfiumModuleOverrides = { locateFile: () => wasmUrl() }; + const precompiled = await pdfiumWasmModulePromise; - // Eagerly reuse pre-compiled WASM module from app boot if available + let reportFailure: (error: unknown) => void = () => {}; + const instantiateFailed = new Promise((_, reject) => { + reportFailure = reject; + }); + + // No pre-compiled module: leave instantiateWasm alone so emscripten fetches the + // WASM itself and rejects init() on failure, instead of a fallback that can't. + if (precompiled) { overrides.instantiateWasm = ( imports: WebAssembly.Imports, successCallback: ( @@ -98,40 +105,34 @@ export async function getPdfiumModule(): Promise { module: WebAssembly.Module, ) => void, ) => { - pdfiumWasmModulePromise - .then((wasmModule) => { - if (wasmModule) { - return WebAssembly.instantiate(wasmModule, imports).then( - (instance) => { - successCallback(instance, wasmModule); - }, - ); - } else { - throw new Error("No pre-compiled WASM module found"); - } - }) - .catch((err: unknown) => { - console.warn( - "Eager WebAssembly instantiation failed, falling back to streaming compilation:", - err, - ); - WebAssembly.instantiateStreaming(fetch(wasmUrl()), imports).then( - (result) => { - successCallback(result.instance, result.module); - }, - ); - }); + WebAssembly.instantiate(precompiled, imports) + .then((instance) => successCallback(instance, precompiled)) + .catch(reportFailure); }; + } - _initPromise = init(overrides as Partial).then((m) => { - // Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up - try { - m.PDFiumExt_Init(); - } catch { - /* already initialized */ - } - _module = m; - return m; + const m = await Promise.race([ + init(overrides as Partial), + instantiateFailed, + ]); + // Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up + try { + m.PDFiumExt_Init(); + } catch { + /* already initialized */ + } + _module = m; + return m; +} + +export async function getPdfiumModule(): Promise { + if (_module) return _module; + if (!_initPromise) { + _initPromise = initPdfiumModule().catch((error: unknown) => { + // Don't cache the failure: every PDF feature in the app goes through here, + // so a transient WASM fetch would take them all down for the session. + _initPromise = null; + throw error; }); } return _initPromise; diff --git a/frontend/editor/src/core/services/updateService.ts b/frontend/editor/src/core/services/updateService.ts index 043c53bc36..8b23d26deb 100644 --- a/frontend/editor/src/core/services/updateService.ts +++ b/frontend/editor/src/core/services/updateService.ts @@ -185,7 +185,7 @@ export class UpdateService { */ async getCurrentVersionFromGitHub(): Promise { const url = - "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/V2-master/build.gradle"; + "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/release/build.gradle"; try { const response = await fetch(url); diff --git a/frontend/editor/src/core/setupTests.ts b/frontend/editor/src/core/setupTests.ts index 57bcaa76a5..d01c3cb589 100644 --- a/frontend/editor/src/core/setupTests.ts +++ b/frontend/editor/src/core/setupTests.ts @@ -2,6 +2,10 @@ import "@testing-library/jest-dom"; import { vi } from "vitest"; import { installFailOnConsole } from "@app/tests/failOnConsole"; +// jsdom is missing the same APIs WebKit is, so tests must agree with the +// browser. Same module `src/index.tsx` installs. +import "@app/utils/engineShims"; + installFailOnConsole(); // Mock localStorage for tests diff --git a/frontend/editor/src/core/styles/index.css b/frontend/editor/src/core/styles/index.css index 01a5279f31..32b2f2eaad 100644 --- a/frontend/editor/src/core/styles/index.css +++ b/frontend/editor/src/core/styles/index.css @@ -62,7 +62,7 @@ code { } .stirling-link { - color: var(--mantine-color-blue-6); + color: var(--c-accent-text); text-decoration: none; font-weight: 500; transition: color 0.2s ease; diff --git a/frontend/editor/src/core/styles/theme.css b/frontend/editor/src/core/styles/theme.css index 2cfa2d70ab..171a441887 100644 --- a/frontend/editor/src/core/styles/theme.css +++ b/frontend/editor/src/core/styles/theme.css @@ -61,6 +61,8 @@ --gray-100: 243 244 246; --gray-200: 229 231 235; --gray-300: 209 213 219; + /* Channel form of gray-400. Only for decorative fills/borders — as text it + reaches 2.3:1, so label copy uses --gray-600. */ --gray-400: 156 163 175; --gray-500: 107 114 128; --gray-600: 75 85 99; @@ -86,9 +88,9 @@ --color-primary-900: var(--p-blue-700); /* Success (green) */ - --color-green-50: var(--p-green-500); - --color-green-100: var(--p-green-500); - --color-green-200: var(--p-green-500); + --color-green-50: var(--p-green-50); + --color-green-100: var(--p-green-100); + --color-green-200: var(--p-green-200); --color-green-300: var(--p-green-500); --color-green-400: var(--p-green-500); --color-green-500: var(--p-green-500); @@ -98,9 +100,9 @@ --color-green-900: var(--p-green-700); /* Warning (yellow) */ - --color-yellow-50: var(--p-amber-400); - --color-yellow-100: var(--p-amber-400); - --color-yellow-200: var(--p-amber-400); + --color-yellow-50: var(--p-amber-50); + --color-yellow-100: var(--p-amber-100); + --color-yellow-200: var(--p-amber-200); --color-yellow-300: var(--p-amber-400); --color-yellow-400: var(--p-amber-400); @@ -131,9 +133,9 @@ --color-yellow-800: var(--p-amber-600); --color-yellow-900: var(--p-amber-600); - --color-red-50: var(--p-red-400); - --color-red-100: var(--p-red-400); - --color-red-200: var(--p-red-400); + --color-red-50: var(--p-red-50); + --color-red-100: var(--p-red-100); + --color-red-200: var(--p-red-200); --color-red-300: var(--p-red-400); --color-red-400: var(--p-red-400); --color-red-500: var(--p-red-500); @@ -409,10 +411,10 @@ --special-color-recommended: var(--p-blue-500); /* Cyan for recommended */ /* Success (green) - dark */ - --color-green-50: var(--p-green-700); - --color-green-100: var(--p-green-700); - --color-green-200: var(--p-green-700); - --color-green-300: var(--p-green-700); + --color-green-50: var(--p-zinc-775); + --color-green-100: var(--p-zinc-700); + --color-green-200: var(--p-zinc-600); + --color-green-300: var(--p-zinc-500); --color-green-400: var(--p-green-600); --color-green-500: var(--p-green-500); --color-green-600: var(--p-green-600); @@ -421,10 +423,10 @@ --color-green-900: var(--p-green-500); /* Warning (yellow) - dark */ - --color-yellow-50: var(--p-amber-600); - --color-yellow-100: var(--p-amber-600); - --color-yellow-200: var(--p-amber-600); - --color-yellow-300: var(--p-amber-600); + --color-yellow-50: var(--p-zinc-775); + --color-yellow-100: var(--p-zinc-700); + --color-yellow-200: var(--p-zinc-600); + --color-yellow-300: var(--p-zinc-500); --color-yellow-400: var(--p-amber-600); --color-yellow-500: var(--p-amber-500); --color-yellow-600: var(--p-amber-400); @@ -570,13 +572,6 @@ } /* Plan section card borders - only override in dark mode */ -[data-mantine-color-scheme="dark"] .plan-card { -} - -[data-mantine-color-scheme="dark"] .plan-card [data-size="sm"] { - color: var(--p-c-c2c8e0) !important; -} - /* Current plan badge - use light mode green in dark mode */ [data-mantine-color-scheme="dark"] .current-plan-badge { background-color: var(--color-green-300) !important; @@ -584,12 +579,15 @@ /* Plan section button colors */ .plan-button:not(:disabled):not([data-disabled]) { - background-color: var(--p-azure-500) !important; + background-color: var(--p-azure-700) !important; } [data-mantine-color-scheme="dark"] .plan-button:not(:disabled):not([data-disabled]) { background-color: var(--p-royal-700) !important; + /* The fill is set here, so the label has to be set with it — left alone it + keeps the scheme's default ink and reads as dark-on-deep-blue. */ + color: var(--p-white) !important; } /* Lighter grey for disabled plan buttons */ diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index 97da95c730..55ab79c111 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.2", + appVersion: "2.14.3", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx index 06f9198f05..5f40cb87ae 100644 --- a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx @@ -68,6 +68,8 @@ const mockedApiClient = vi.mocked(apiClient); // Mock only essential services that are actually called by the tests vi.mock("../../services/fileStorage", () => ({ + // FileContext subscribes to this to drop files whose bytes are unreadable. + onRecordUnreadable: () => () => {}, fileStorage: { init: vi.fn().mockResolvedValue(undefined), storeFile: vi.fn().mockImplementation((file, thumbnail) => { @@ -567,8 +569,14 @@ describe("Convert Tool Integration Tests", () => { await result.current.executeOperation(parameters, [testFile]); }); - // Verify integration: utils validation prevents API call, hook shows error - expect(mockedApiClient.post).not.toHaveBeenCalled(); + // Verify integration: utils validation prevents the conversion call, hook shows + // error. Failure reporting posts separately and is not a conversion request. + const conversionCalls = vi + .mocked(mockedApiClient.post) + .mock.calls.filter( + ([url]) => !String(url).includes("/file-run-events/"), + ); + expect(conversionCalls).toHaveLength(0); expect(result.current.errorMessage).toContain( "Unsupported conversion format", ); diff --git a/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 1e1e273de4..aec52300ca 100644 --- a/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -66,6 +66,8 @@ const mockedApiClient = vi.mocked(apiClient); // Mock only essential services that are actually called by the tests vi.mock("../../services/fileStorage", () => ({ + // FileContext subscribes to this to drop files whose bytes are unreadable. + onRecordUnreadable: () => () => {}, fileStorage: { init: vi.fn().mockResolvedValue(undefined), storeFile: vi.fn().mockImplementation((file, thumbnail) => { diff --git a/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts b/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts index c7f9bc367a..729a5fc298 100644 --- a/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts +++ b/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts @@ -22,7 +22,7 @@ async function uiLogin(page: import("@playwright/test").Page) { await page.locator("#email").fill(ADMIN); await page.locator("#password").fill(PASSWORD); await page.locator('button[type="submit"]').click(); - await page.waitForURL("/", { timeout: 15_000 }); + await page.waitForURL(/\/(editor|processor)/, { timeout: 15_000 }); await expect( page.locator('[data-testid="config-button"]').first(), ).toBeVisible({ timeout: 15_000 }); diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts index 00d4d98b81..d6ebfbe408 100644 --- a/frontend/editor/src/core/tests/helpers/api-stubs.ts +++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts @@ -126,7 +126,11 @@ export interface MockAppApiOptions { username?: string; email?: string; roles?: string[]; - }; + /** Spring role string (e.g. "ROLE_ADMIN") — drives `isAdmin` in the auth seam. */ + role?: string; + /** Portal (Processor) access flag — gates the super search's Processor lanes. */ + portalAccess?: boolean; + } | null; /** Languages advertised by `/config/app-config`. */ languages?: string[]; /** Default locale. */ diff --git a/frontend/editor/src/core/tests/helpers/login.ts b/frontend/editor/src/core/tests/helpers/login.ts index c397141c9d..9fc60eaaaf 100644 --- a/frontend/editor/src/core/tests/helpers/login.ts +++ b/frontend/editor/src/core/tests/helpers/login.ts @@ -66,8 +66,8 @@ export async function login( // Click Sign In (the submit button inside the auth form) await page.locator('button[type="submit"]').click(); - // Wait for redirect to home - await page.waitForURL("/", { timeout: 15000 }); + // "/" routes by role; a signed-in user lands on the editor or the processor. + await page.waitForURL(/\/(editor|processor)/, { timeout: 15000 }); } /** diff --git a/frontend/editor/src/core/tests/helpers/stub-test-base.ts b/frontend/editor/src/core/tests/helpers/stub-test-base.ts index e6ae98c6f7..295d5b2184 100644 --- a/frontend/editor/src/core/tests/helpers/stub-test-base.ts +++ b/frontend/editor/src/core/tests/helpers/stub-test-base.ts @@ -54,7 +54,10 @@ const STUB_JWT = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdHViLXVzZXIifQ.signature"; export const test = base.extend({ stubOptions: [{}, { option: true }], - autoGoto: ["/", { option: true }], + // The editor's own URL, not "/". "/" is a role-based router that redirects, + // and /editor renders the editor in every flavour, so tests land straight on + // the app instead of racing a redirect on every single test. + autoGoto: ["/editor", { option: true }], seedJwt: [false, { option: true }], page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => { diff --git a/frontend/editor/src/core/tests/live/authentication-login.spec.ts b/frontend/editor/src/core/tests/live/authentication-login.spec.ts index f9d0f6e628..1d3df58fa3 100644 --- a/frontend/editor/src/core/tests/live/authentication-login.spec.ts +++ b/frontend/editor/src/core/tests/live/authentication-login.spec.ts @@ -7,7 +7,7 @@ test.describe("1. Authentication and Login", () => { page, }) => { // Step 1: Verify the browser redirects to /login - await page.goto("/"); + await page.goto("/editor"); await expect(page).toHaveURL(/\/login/); // Step 2: Confirm the login page displays the Stirling PDF logo @@ -46,9 +46,9 @@ test.describe("1. Authentication and Login", () => { // Step 10: Click the "Sign In" button await signInButton.click(); - // Step 11: Verify the user is redirected to the home page at / - await page.waitForURL("/", { timeout: 15000 }); - await expect(page).toHaveURL("/"); + // Step 11: Verify "/" routes the user on to the editor + await page.waitForURL("/editor", { timeout: 15000 }); + await expect(page).toHaveURL("/editor"); // Step 12: Verify the home dashboard loads with tool sidebar and file upload area visible await expect( diff --git a/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts b/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts index 2e308ce3e2..f8021649b4 100644 --- a/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts +++ b/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts @@ -12,10 +12,9 @@ test.describe("20. Edge Cases and Security", () => { test("should prevent XSS via search input", async ({ page }) => { await loginAndSetup(page); - // Step 1: Open the search box (the tool panel header shows a search - // toggle; the field only mounts once it's pressed) and enter the payload - await page.getByRole("button", { name: /search tools/i }).click(); - const searchBox = page.getByPlaceholder(/search|cari/i).first(); + // Step 1: Enter the payload into the always-mounted super search bar + const searchBox = page.locator("#super-search-input"); + await searchBox.click(); await searchBox.fill('">'); // Step 2: Verify no script execution or image error handler fires diff --git a/frontend/editor/src/core/tests/live/watched-folders.spec.ts b/frontend/editor/src/core/tests/live/watched-folders.spec.ts index 6ee7dddd7c..0021a4edf4 100644 --- a/frontend/editor/src/core/tests/live/watched-folders.spec.ts +++ b/frontend/editor/src/core/tests/live/watched-folders.spec.ts @@ -182,7 +182,7 @@ test.describe("Watched Folders — Presets", () => { const count1 = await getIDBFolderCount(page); // Navigate away and back - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await page.waitForSelector('[data-testid="watchedFolders-button"]', { timeout: 15000, }); @@ -421,7 +421,7 @@ test.describe("Watched Folders — Home Page", () => { sessionStorage.removeItem("watchedFolderHowItWorksDismissed"), ); // Re-navigate - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await page.waitForSelector('[data-testid="watchedFolders-button"]', { timeout: 15000, }); diff --git a/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts b/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts index 0e65631f4a..8033765f55 100644 --- a/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts @@ -73,7 +73,7 @@ async function setUpAdminWithAudit( ], }), ); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Audit log UI", () => { diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts index 223a2eae51..856ddd58f5 100644 --- a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -46,7 +46,10 @@ test("a 10-file upload wave classifies every file into its group", async ({ await page.route("**/api/v1/policies/classify/meter", (route) => route.fulfill({ status: 202, body: "" }), ); - await page.goto("/", { waitUntil: "domcontentloaded", timeout: 120_000 }); + await page.goto("/editor", { + waitUntil: "domcontentloaded", + timeout: 120_000, + }); await uploadFiles( page, diff --git a/frontend/editor/src/core/tests/stubbed/compare.spec.ts b/frontend/editor/src/core/tests/stubbed/compare.spec.ts index 6893721465..c3c6a990a1 100644 --- a/frontend/editor/src/core/tests/stubbed/compare.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/compare.spec.ts @@ -230,4 +230,7 @@ test.describe("Compare tool slot selection", () => { page.locator('[data-testid="compare-slot-comparison"]'), ).toHaveAttribute("data-slot-state", "empty"); }); + + // These specs stop at slot state. Actually running a comparison lives in + // `engine-capabilities.spec.ts`, which is cross-browser in PR CI. }); diff --git a/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts b/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts index 975238eba7..0b27171c54 100644 --- a/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts @@ -10,7 +10,7 @@ test.describe("18. Cookie Preferences", () => { await page.route("**/api/v1/ui-data/footer-info", (route) => route.fulfill({ json: { analyticsEnabled: true } }), ); - await page.goto("/"); + await page.goto("/editor"); // Step 1: The "Cookie Preferences" button lives in Settings → Legal await openSettings(page); diff --git a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts new file mode 100644 index 0000000000..6107fb5276 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts @@ -0,0 +1,138 @@ +/** Runs on all three engines in PR CI, asserting on evidence that can only exist + * if the engine did the work. Keep small - it is paid for three times per PR. */ + +import path from "path"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { dismissTourTooltip, uploadFiles } from "@app/tests/helpers/ui-helpers"; + +const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures"); +const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf"); +const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf"); +const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf"); + +/** A missing global or prototype method always surfaces as one of these. + * Matching the shape keeps benign engine noise out (console-clean.spec.ts). */ +const MISSING_API_ERROR = + /is not a function|is not a constructor|undefined is not an object|has no method/i; + +/** Collect the "this engine lacks an API we used" errors seen on the page. */ +function recordMissingApiErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (error: Error) => { + const text = String(error); + if (MISSING_API_ERROR.test(text)) errors.push(text); + }); + return errors; +} + +async function fillCompareSlot( + page: Page, + role: "base" | "comparison", + filePath: string, +) { + await page + .getByTestId(`compare-slot-${role}-add-input`) + .setInputFiles(filePath); + await expect( + page.locator(`[data-testid="compare-slot-${role}"]`), + ).toHaveAttribute("data-slot-state", "filled", { timeout: 20_000 }); + // The upload modal's overlay outlives its close transition and eats clicks. + await page + .locator(".mantine-Modal-overlay") + .waitFor({ state: "detached", timeout: 5_000 }) + .catch(() => { + /* already gone */ + }); +} + +test.describe("engine capabilities", { tag: "@engine-capability" }, () => { + test("extracts PDF text and completes a comparison", async ({ page }) => { + test.setTimeout(120_000); + const missingApis = recordMissingApiErrors(page); + + await page.locator('[data-tour="tool-button-compare"]').first().click(); + await page.waitForSelector('[data-testid="compare-slot-base"]', { + timeout: 20_000, + }); + + await fillCompareSlot(page, "base", PDF_A); + await fillCompareSlot(page, "comparison", PDF_B); + + // By test id: `name` matches as a substring, so "Compare" also hits the + // tool button that opened this panel. + await page.getByTestId("compare-execute").click(); + + // Counted results, not headings: an extraction returning nothing still + // renders empty panes, which is how the WebKit failure looked like success. + const deletions = page.getByText(/Deletions \((\d+)\)/); + const additions = page.getByText(/Additions \((\d+)\)/); + await expect(deletions).toBeVisible({ timeout: 60_000 }); + await expect(additions).toBeVisible(); + expect(await deletions.innerText()).not.toMatch(/\(0\)/); + expect(await additions.innerText()).not.toMatch(/\(0\)/); + + expect(missingApis, "no missing-API errors during comparison").toEqual([]); + }); + + test("rasterises page thumbnails via the PDF engine", async ({ page }) => { + test.setTimeout(120_000); + const missingApis = recordMissingApiErrors(page); + + await uploadFiles(page, SAMPLE_PDF); + await dismissTourTooltip(page); + + // A page thumbnail only exists if the WASM engine loaded, rendered and + // encoded. When it fails the grid still renders, just with no . + await page.getByText("PDF Multi Tool", { exact: true }).first().click(); + + const thumbnail = page + .locator("[data-page-id] img[data-original-rotation]") + .first(); + await expect(thumbnail).toBeVisible({ timeout: 60_000 }); + + // An empty encode still yields a src; require enough payload to be real. + const src = await thumbnail.getAttribute("src"); + expect(src ?? "").toMatch(/^data:image\//); + expect(src?.length ?? 0).toBeGreaterThan(1_000); + + expect(missingApis, "no missing-API errors during thumbnailing").toEqual( + [], + ); + }); + + test("reads a stored file's bytes back after a reload", async ({ page }) => { + test.setTimeout(120_000); + const missingApis = recordMissingApiErrors(page); + + await uploadFiles(page, SAMPLE_PDF); + + // Full reload: FileContext rehydrates from IndexedDB, not from memory. + await page.reload({ waitUntil: "domcontentloaded" }); + + const restored = page.locator(".file-sidebar-file-item").first(); + await expect(restored).toBeVisible({ timeout: 30_000 }); + + // Rendering it is the assertion that matters: the metadata record survives + // even when the bytes were never stored, so a filename proves nothing. + await restored.hover(); + await restored + .locator(".file-sidebar-eye-btn") + .click({ timeout: 15_000, force: true }); + + const firstPage = page.locator('[data-page-index="0"]').first(); + await expect(firstPage).toBeVisible({ timeout: 60_000 }); + + // A tile that decoded has non-zero naturalWidth. A blob stored but not + // readable back resolves to nothing, and renders as an empty page. + const tile = firstPage.locator('img[src^="blob:"]').first(); + await expect(tile).toBeAttached({ timeout: 30_000 }); + await expect + .poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), { + timeout: 30_000, + }) + .toBeGreaterThan(0); + + expect(missingApis, "no missing-API errors after rehydration").toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts index 4c6e714ec0..7c365eb1aa 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -565,25 +565,6 @@ test.describe("Files page", () => { test.describe("Side-rail integration with /files", () => { test.use({ autoGoto: false }); - test("Rail Search focuses the central search field, no navigation", async ({ - page, - }) => { - await stubStorageApis(page); - await seedFiles(page, [ - { id: "alpha", name: "alpha.pdf", remoteStorageId: null }, - ]); - await gotoFilesPage(page); - // Click the search row in the rail. - await page.locator(".file-sidebar-search-row").click(); - // The central search input should be focused. - const focused = await page.evaluate( - () => document.activeElement?.getAttribute("aria-label") ?? "", - ); - expect(focused).toMatch(/Search/i); - // And we must still be on /files (i.e. didn't navigate home). - await expect(page).toHaveURL(/\/files/); - }); - test("Rail New folder button visible on /files", async ({ page }) => { await stubStorageApis(page); await seedFiles(page, [ @@ -647,10 +628,8 @@ test.describe("Files page", () => { await card.getByRole("button", { name: /File actions/i }).click(); await page.getByRole("menuitem", { name: /Add to workspace/i }).click(); // The materializer should have hit the download endpoint and - // routed the user to the viewer (/). - await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, { - timeout: 5_000, - }); + // routed the user to the viewer (the editor). + await expect(page).toHaveURL(/\/editor(\?|$)/, { timeout: 5_000 }); expect(downloadHit).toBe(true); }); @@ -703,9 +682,7 @@ test.describe("Files page", () => { // Open the card and confirm the share-link download endpoint fires. await card.getByRole("button", { name: /File actions/i }).click(); await page.getByRole("menuitem", { name: /Add to workspace/i }).click(); - await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, { - timeout: 5_000, - }); + await expect(page).toHaveURL(/\/editor(\?|$)/, { timeout: 5_000 }); expect(shareDownloadHit).toBe(true); }); diff --git a/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts b/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts index b00c84b364..0d1253694c 100644 --- a/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts @@ -60,7 +60,7 @@ async function setUpFirstLoginPage(page: Page) { test.describe("First-login forced password change modal", () => { test("modal renders with FirstLoginSlide content", async ({ page }) => { await setUpFirstLoginPage(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText(/must change your password|set your password/i).first(), @@ -74,7 +74,7 @@ test.describe("First-login forced password change modal", () => { page, }) => { await setUpFirstLoginPage(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText(/must change your password|set your password/i).first(), ).toBeVisible({ timeout: 15_000 }); @@ -100,7 +100,7 @@ test.describe("First-login forced password change modal", () => { }, ); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText(/must change your password|set your password/i).first(), ).toBeVisible({ timeout: 15_000 }); diff --git a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts index 30ecc8d779..3e9d8a05ae 100644 --- a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts @@ -54,12 +54,11 @@ test.describe("13. Language / Localization", () => { // Step 5: Wait for page reload (language change triggers window.location.reload()) await page.waitForLoadState("domcontentloaded"); - // Step 6: Verify the UI text is in English. The tool search is a - // header toggle, so assert its English label rather than the field, - // which only mounts once the toggle is pressed. - await expect( - page.getByRole("button", { name: /search tools/i }).first(), - ).toBeVisible({ timeout: 10000 }); + // Step 6: Verify the UI text is in English via the always-mounted + // super search bar's placeholder. + await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ + timeout: 10000, + }); } }); }); diff --git a/frontend/editor/src/core/tests/stubbed/license-states.spec.ts b/frontend/editor/src/core/tests/stubbed/license-states.spec.ts index ab9256d50b..e72ab6e99f 100644 --- a/frontend/editor/src/core/tests/stubbed/license-states.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/license-states.spec.ts @@ -49,7 +49,7 @@ async function setUpAdminPage( await page.route("**/api/v1/admin/license-info", (route) => route.fulfill({ json: licenseInfo }), ); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Admin license panel — state matrix", () => { diff --git a/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts index 2dccef4959..00779fe5a8 100644 --- a/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts @@ -53,7 +53,7 @@ test.describe("Login agreement modal", () => { page, }) => { await setUpLoggedIn(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText("Login Agreement", { exact: true }).first(), @@ -69,7 +69,7 @@ test.describe("Login agreement modal", () => { test("Escape does not dismiss the modal (blocking)", async ({ page }) => { await setUpLoggedIn(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByRole("heading", { name: "Test Disclaimer" }), ).toBeVisible({ timeout: 15_000 }); @@ -85,7 +85,7 @@ test.describe("Login agreement modal", () => { page, }) => { await setUpLoggedIn(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByRole("heading", { name: "Test Disclaimer" }), ).toBeVisible({ timeout: 15_000 }); @@ -104,7 +104,7 @@ test.describe("Login agreement modal", () => { test("does not show when the feature is disabled", async ({ page }) => { await setUpLoggedIn(page, { enabled: false, content: "" }); - await page.goto("/"); + await page.goto("/editor"); // App is usable; modal never appears. await page.waitForTimeout(1500); await expect( @@ -117,7 +117,7 @@ test.describe("Login agreement modal", () => { await skipOnboarding(page); await mockAppApis(page, { enableLogin: false }); await stubDisclaimer(page, { showInAnonymousMode: true }); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByRole("heading", { name: "Test Disclaimer" }), @@ -129,7 +129,7 @@ test.describe("Login agreement modal", () => { await skipOnboarding(page); await mockAppApis(page, { enableLogin: false }); await stubDisclaimer(page, { showInAnonymousMode: false }); - await page.goto("/"); + await page.goto("/editor"); await page.waitForTimeout(1500); await expect( diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts index b8efb283fc..5fa9037363 100644 --- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts @@ -3,7 +3,7 @@ import { openSettings } from "@app/tests/helpers/ui-helpers"; test.describe("2. Main Dashboard / Home Page", () => { test.beforeEach(async ({ page }) => { - await page.goto("/"); + await page.goto("/editor"); }); test.describe("2.1 Dashboard Layout and Tool Categories", () => { @@ -17,14 +17,7 @@ test.describe("2. Main Dashboard / Home Page", () => { page.locator('[data-testid="config-button"]').first(), ).toBeVisible(); - // Tool search sits behind a header toggle now, so assert the affordance - // AND that pressing it actually mounts a usable search field — dropping - // the second half would stop covering the input entirely. - const searchToggle = page - .getByRole("button", { name: /search tools/i }) - .first(); - await expect(searchToggle).toBeVisible(); - await searchToggle.click(); + // Tool search lives in the global super search bar, always mounted. await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); await expect( @@ -80,12 +73,10 @@ test.describe("2. Main Dashboard / Home Page", () => { await expect(page).toHaveURL(/\/merge/, { timeout: 10000 }); - await page.goto("/"); + await page.goto("/editor"); - // Tool search is a header toggle; the field mounts only once pressed. - await expect( - page.getByRole("button", { name: /search tools/i }).first(), - ).toBeVisible(); + // Tool search lives in the global super search bar, always mounted. + await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); }); }); diff --git a/frontend/editor/src/core/tests/stubbed/navigation.spec.ts b/frontend/editor/src/core/tests/stubbed/navigation.spec.ts index 4fd6a6e341..0e47849b05 100644 --- a/frontend/editor/src/core/tests/stubbed/navigation.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/navigation.spec.ts @@ -36,18 +36,18 @@ test.describe("Navigation", () => { .getByRole("button", { name: /Back to all tools/i }) .first() .click(); - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); await page.locator('a[href="/split"]').first().click(); await expect(page).toHaveURL(/\/split/); await page.goBack(); - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); await page.goBack(); await expect(page).toHaveURL(/\/merge/); await page.goForward(); - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); }); }); diff --git a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts index f24e13f312..31c3accf47 100644 --- a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts @@ -40,7 +40,10 @@ test.describe("PageEditor (multitool) rotation save", () => { test("rotating a page persists the correct absolute rotation on export", async ({ page, }) => { - await page.goto("/", { waitUntil: "domcontentloaded", timeout: 120_000 }); + await page.goto("/editor", { + waitUntil: "domcontentloaded", + timeout: 120_000, + }); await uploadFiles(page, ROTATED_PDF); // Enter the multitool via in-app navigation, NOT page.goto: a full reload // wipes the in-memory workbench before PageEditorContext's "entering page diff --git a/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts b/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts index 5c2f4a00a9..f9e0a089ed 100644 --- a/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts @@ -21,7 +21,7 @@ async function setUpEndpointAvailability( await seedCookieConsent(page); await bypassOnboarding(page); await mockAppApis(page, { endpointsAvailability: overrides }); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Premium / endpoint gating", () => { @@ -74,7 +74,7 @@ test.describe("Premium / endpoint gating", () => { json: { username: "user", email: "user@example.com", isAdmin: false }, }), ); - await page.goto("/"); + await page.goto("/editor"); const configBtn = page.locator('[data-testid="config-button"]').first(); if (!(await configBtn.isVisible({ timeout: 5_000 }).catch(() => false))) { diff --git a/frontend/editor/src/core/tests/stubbed/seed.spec.ts b/frontend/editor/src/core/tests/stubbed/seed.spec.ts index 6b50e198aa..5cfb46c907 100644 --- a/frontend/editor/src/core/tests/stubbed/seed.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/seed.spec.ts @@ -56,7 +56,7 @@ export const TEST_FILES = { test.describe("Stirling-PDF seed", () => { test("seed - app loads", async ({ page }) => { // Navigate to the Stirling-PDF frontend - await page.goto("/"); + await page.goto("/editor"); // The app may redirect to /login if authentication is enabled. // Wait for the app to be ready: either the dashboard layout or the login page. diff --git a/frontend/editor/src/core/tests/stubbed/settings.spec.ts b/frontend/editor/src/core/tests/stubbed/settings.spec.ts index cb95a1171e..a9b0bbaa17 100644 --- a/frontend/editor/src/core/tests/stubbed/settings.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/settings.spec.ts @@ -156,7 +156,7 @@ test.describe("Settings dialog", () => { }; }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await openSettings(page); const generalNav = page.locator('[data-tour="admin-general-nav"]').first(); @@ -200,7 +200,7 @@ test.describe("Settings dialog", () => { page, }) => { // Land on / first so the originating URL is unambiguous. - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await expect( page.locator('[data-testid="config-button"]').first(), ).toBeVisible({ timeout: 5_000 }); diff --git a/frontend/editor/src/core/tests/stubbed/super-search.spec.ts b/frontend/editor/src/core/tests/stubbed/super-search.spec.ts new file mode 100644 index 0000000000..431a98b421 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/super-search.spec.ts @@ -0,0 +1,443 @@ +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { openSettings } from "@app/tests/helpers/ui-helpers"; +import type { Page } from "@playwright/test"; + +/** + * Super search E2E: the bar itself (open, filter, select, show-more, XSS + * hygiene) plus the access gating — a user without Processor access must see + * no Processor chips, no Processor results, and trigger no entity fetches. + * + * Portal-lane presence differs by build: `vite dev` ships the portal, the CI + * preview build does not. Gate-closed assertions hold in both (closed lanes + * look identical to absent ones); gate-open lane assertions skip themselves + * when the build ships no portal. + */ + +const INPUT = "#super-search-input"; + +/** + * URLs only the search's Processor entity fetch hits. `/api/v1/policies` + * itself is deliberately absent: the editor's policy auto-run also reads it at + * boot for every user, so it can't distinguish a search leak. + */ +const ENTITY_API_PATTERN = + /\/api\/v1\/policies\/overview|\/api\/v1\/sources|\/api\/v1\/proprietary\/ui-data\/admin-settings/; + +async function openSearch(page: Page) { + const input = page.locator(INPUT); + await input.click(); + await expect(input).toHaveAttribute("aria-expanded", "true"); + return input; +} + +test.describe("Super search — bar basics", () => { + test("Ctrl+K opens the bar and results filter as you type", async ({ + page, + }) => { + const input = page.locator(INPUT); + // The shortcut listener mounts with the bar — wait for it before pressing. + await expect(input).toBeVisible(); + await page.keyboard.press("Control+KeyK"); + await expect(input).toBeFocused(); + await expect(input).toHaveAttribute("aria-expanded", "true"); + + await input.fill("merge"); + await expect( + page.getByRole("option", { name: /Merge/ }).first(), + ).toBeVisible(); + + // A different query replaces the results in place. + await input.fill("compress"); + await expect( + page.getByRole("option", { name: /Compress/ }).first(), + ).toBeVisible(); + await expect(page.getByRole("option", { name: /^Merge/ })).toHaveCount(0); + }); + + test("selecting a tool result opens that tool", async ({ page }) => { + const input = await openSearch(page); + await input.fill("merge"); + await page + .getByRole("option", { name: /^Merge/ }) + .first() + .click(); + await page.waitForURL("**/merge**"); + }); + + test("shows the empty state for a query with no matches", async ({ + page, + }) => { + const input = await openSearch(page); + await input.fill("xyznonexistent123"); + await expect(page.getByText("No results found")).toBeVisible(); + }); + + test("treats markup in the query as plain text", async ({ page }) => { + let dialogFired = false; + page.on("dialog", (dialog) => { + dialogFired = true; + void dialog.dismiss(); + }); + + const input = await openSearch(page); + const payload = + ''; + await input.fill(payload); + await expect(input).toHaveValue(payload); + + await expect(page.getByText("No results found")).toBeVisible(); + expect(dialogFired).toBe(false); + expect( + await page.evaluate( + () => (window as unknown as { __xss?: number }).__xss, + ), + ).toBeUndefined(); + }); + + test("show more reveals the rest of a large group and collapses again", async ({ + page, + }) => { + const input = await openSearch(page); + // Broad query — the tools lane alone exceeds the 5-row collapsed slice. + // Assertions scope to the Tools group: other groups (docs, entities) + // pop in asynchronously, so page-wide option counts are racy. + await input.fill("pdf"); + const tools = page.getByRole("group", { name: "Tools" }); + await expect(tools.getByRole("option").first()).toBeVisible(); + + const collapsedCount = await tools.getByRole("option").count(); + const showMore = tools.getByRole("button", { name: /Show \d+ more/ }); + await expect(showMore).toBeVisible(); + const hidden = Number( + (await showMore.innerText()).match(/\d+/)?.[0] ?? "0", + ); + await showMore.click(); + + await expect(tools.getByRole("option")).toHaveCount( + collapsedCount + hidden, + ); + const showLess = tools.getByRole("button", { name: "Show less" }); + await expect(showLess).toBeVisible(); + + await showLess.click(); + await expect(tools.getByRole("option")).toHaveCount(collapsedCount); + }); + + test("selecting a tool from the /files page leaves the file manager", async ({ + page, + }) => { + await page.goto("/files"); + const input = page.locator(INPUT).first(); + await expect(input).toBeVisible({ timeout: 15000 }); + await input.click(); + await input.fill("merge"); + await page + .getByRole("option", { name: /^Merge/ }) + .first() + .click(); + + // The selection pins its tool URL via raw history.pushState, which the + // router never sees — the sink must leave /files through the router or + // the myFiles workbench swallows the selection (and "/" is a role router + // on processor-shipping builds, so the exit must target the editor home). + await expect(page.locator(".files-page-header-search")).not.toBeVisible({ + timeout: 5000, + }); + await expect(page).toHaveURL(/\/merge/); + }); + + test("Ctrl+K inside the settings modal closes it and focuses the bar", async ({ + page, + }) => { + const input = page.locator(INPUT); + await expect(input).toBeVisible(); + await openSettings(page); + + // The modal traps focus, so the bar's own shortcut is inert; the modal + // cedes: it closes itself and hands focus to the bar. + await page.keyboard.press("Control+KeyK"); + await expect(page.locator(".modal-container")).not.toBeVisible(); + await expect(input).toBeFocused(); + await expect(input).toHaveAttribute("aria-expanded", "true"); + + // Full loop: a settings result deep-links straight back into the modal. + await input.fill("general"); + await page + .getByRole("option", { name: /General/ }) + .first() + .click(); + await expect(page.locator(".modal-container")).toBeVisible(); + }); +}); + +test.describe("Super search — user without Processor access", () => { + // Login enabled, signed in as a plain member: no admin role, no portal + // access. The Processor gate must stay closed. + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 33, + username: "bob", + email: "bob@example.com", + role: "ROLE_USER", + portalAccess: false, + }, + }, + seedJwt: true, + }); + + test("sees no Processor chips, results or entity fetches", async ({ + page, + }) => { + const entityRequests: string[] = []; + page.on("request", (request) => { + if (ENTITY_API_PATTERN.test(request.url())) { + entityRequests.push(request.url()); + } + }); + + const input = await openSearch(page); + + // Chip row: the editor lanes only. No Processor lanes of any kind. + await expect(page.getByRole("button", { name: "Tools" })).toBeVisible(); + for (const lane of [ + "Pages", + "Users", + "Policies", + "Pipelines", + "Sources", + "Docs", + ]) { + await expect( + page.getByRole("button", { name: lane, exact: true }), + ).toHaveCount(0); + } + + // A query that would hit policies/docs/admin settings when the gate is + // open must yield no Processor section for this user. + await input.fill("security"); + await expect(page.getByRole("option").first()).toBeVisible(); + await expect( + page.locator(".super-search-section-label", { hasText: "Processor" }), + ).toHaveCount(0); + + // And the search must not have fetched any entity data on their behalf. + await page.waitForTimeout(750); + expect(entityRequests).toEqual([]); + }); + + test("sees login-gated settings but no admin settings", async ({ page }) => { + const input = await openSearch(page); + + // Positive control — requiresLogin sections are visible to a signed-in + // user, proving the settings lane itself works for this account. + await input.fill("account"); + await expect( + page.getByRole("option", { name: /Account Settings/ }).first(), + ).toBeVisible(); + + // Admin-only sections stay hidden (label and content matches alike). + await input.fill("endpoints"); + await expect(page.getByRole("option", { name: /^Endpoints/ })).toHaveCount( + 0, + ); + }); +}); + +test.describe("Super search — portal access without admin", () => { + // Self-hosted grants portal access beyond admins (team owners, ACL + // grantees), but the users roster endpoint is admin-only — the Users lane + // must not be offered to a session the endpoint would always refuse. + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 44, + username: "owner", + email: "owner@example.com", + role: "ROLE_USER", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + test("sees Processor lanes but no Users chip and no roster fetch", async ({ + page, + }) => { + const rosterRequests: string[] = []; + page.on("request", (request) => { + if ( + request.url().includes("/api/v1/proprietary/ui-data/admin-settings") + ) { + rosterRequests.push(request.url()); + } + }); + for (const [pattern, json] of [ + ["**/api/v1/policies", []], + ["**/api/v1/policies/runs", []], + ["**/api/v1/policies/overview", { pipelines: [] }], + ["**/api/v1/sources", { sources: [] }], + ] as const) { + await page.route(pattern, (route) => route.fulfill({ json })); + } + + const input = await openSearch(page); + const portalShips = + (await page.getByRole("button", { name: "Pages", exact: true }).count()) > + 0; + test.skip(!portalShips, "this build ships no portal — no lanes to gate"); + + for (const lane of ["Policies", "Pipelines", "Sources"]) { + await expect( + page.getByRole("button", { name: lane, exact: true }), + ).toBeVisible(); + } + await expect( + page.getByRole("button", { name: "Users", exact: true }), + ).toHaveCount(0); + + // A query that used to fire the doomed roster fetch once per TTL. + await input.fill("admin"); + await expect(page.locator(".super-search-dropdown").first()).toBeVisible(); + await page.waitForTimeout(1500); + expect(rosterRequests).toEqual([]); + }); +}); + +test.describe("Super search — admin with Processor access", () => { + test.use({ + stubOptions: { + enableLogin: true, + isAdmin: true, + user: { + id: 1, + username: "admin", + email: "admin@example.com", + role: "ROLE_ADMIN", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + test("sees Processor lanes and live entity results", async ({ page }) => { + // Entity data the gate-open bar fetches, stubbed with one source row. + await page.route("**/api/v1/policies", (route) => + route.fulfill({ json: [] }), + ); + await page.route("**/api/v1/policies/runs", (route) => + route.fulfill({ json: [] }), + ); + await page.route("**/api/v1/policies/overview", (route) => + route.fulfill({ json: { pipelines: [] } }), + ); + await page.route("**/api/v1/sources", (route) => + route.fulfill({ + json: { + sources: [{ id: "src-1", name: "Contract Intake", type: "email" }], + }, + }), + ); + + await openSearch(page); + + // The Processor lanes only exist in builds that ship the portal (dev, + // VITE_INCLUDE_PORTAL) — the CI preview build has none to show. + const portalShips = + (await page.getByRole("button", { name: "Pages", exact: true }).count()) > + 0; + test.skip(!portalShips, "this build ships no portal — no lanes to gate"); + + for (const lane of ["Users", "Policies", "Pipelines", "Sources"]) { + await expect( + page.getByRole("button", { name: lane, exact: true }), + ).toBeVisible(); + } + + const input = page.locator(INPUT); + await input.fill("contract intake"); + await expect( + page.getByRole("option", { name: /Contract Intake/ }).first(), + ).toBeVisible(); + await expect( + page + .locator(".super-search-section-label", { hasText: "Processor" }) + .first(), + ).toBeVisible(); + }); +}); + +test.describe("Portal bar — tool results hop into the editor", () => { + test.use({ + stubOptions: { + enableLogin: true, + isAdmin: true, + user: { + id: 1, + username: "admin", + email: "admin@example.com", + role: "ROLE_ADMIN", + portalAccess: true, + }, + }, + seedJwt: true, + autoGoto: false, + }); + + test("selecting a tool routes client-side, not via a full page load", async ({ + page, + }) => { + for (const [pattern, json] of [ + ["**/api/v1/policies", []], + ["**/api/v1/policies/runs", []], + ["**/api/v1/policies/overview", { pipelines: [] }], + ["**/api/v1/sources", { sources: [] }], + ["**/api/v1/team/my", []], + ] as const) { + await page.route(pattern, (route) => route.fulfill({ json })); + } + + await page.goto("/processor"); + const input = page.locator("#portal-search-input"); + // The portal only ships in dev / VITE_INCLUDE_PORTAL builds — on the CI + // preview build /processor falls through to the editor and there is no + // portal bar to hop from. + const portalShips = await input + .waitFor({ state: "visible", timeout: 20000 }) + .then(() => true) + .catch(() => false); + test.skip(!portalShips, "this build ships no portal — no bar to hop from"); + + // A full page load would drop this marker — and on bundled deploys it + // would also 401: document GETs carry no Authorization header, so the + // backend bounces them to /login even with a live session. + await page.evaluate(() => { + (window as unknown as { __spaMarker?: boolean }).__spaMarker = true; + }); + + await input.click(); + await input.fill("merge"); + await page + .getByRole("option", { name: /^Merge/ }) + .first() + .click(); + + await expect(page).toHaveURL(/\/merge/); + // Editor mounted: its own search bar replaces the portal's. + await expect(page.locator("#super-search-input")).toBeVisible({ + timeout: 15000, + }); + // The editor's URL-driven tool init must run on this route-swap mount, + // not just on a cold page load: the Merge tool panel actually opens. + await expect( + page.getByRole("button", { name: "Merge", exact: true }), + ).toBeVisible({ timeout: 10000 }); + expect( + await page.evaluate( + () => (window as unknown as { __spaMarker?: boolean }).__spaMarker, + ), + ).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts b/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts index 51ce8e2171..b7f20c9b12 100644 --- a/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts @@ -42,7 +42,7 @@ async function setUpAdminWithTeams( await page.route("**/api/v1/proprietary/ui-data/teams", (route) => route.fulfill({ json: teams }), ); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Teams management UI", () => { diff --git a/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts index ae2e4b4e6d..d50ebd9b74 100644 --- a/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts @@ -44,7 +44,7 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => { await homeLink.click(); // Step 3: Verify navigation back to the home dashboard - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); // Step 4: Use browser back button await page.goBack(); diff --git a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts deleted file mode 100644 index 7dfda74b79..0000000000 --- a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Page } from "@playwright/test"; -import { test, expect } from "@app/tests/helpers/stub-test-base"; - -/** - * The tool panel header shows a search *toggle*; the field only mounts once - * it's pressed. Open it and hand back the focused input. - */ -async function openToolSearch(page: Page) { - await page.getByRole("button", { name: /search tools/i }).click(); - const searchBox = page.getByPlaceholder(/search|cari/i).first(); - await expect(searchBox).toBeVisible({ timeout: 5000 }); - return searchBox; -} - -test.describe("3. Tool Search", () => { - test.describe("3.1 Search - Happy Path", () => { - test("should filter tools in real time based on search input", async ({ - page, - }) => { - // Step 1: Open the search box from the header toggle - const searchBox = await openToolSearch(page); - - // Step 2: Type "merge" - await searchBox.fill("merge"); - - // Step 3: Verify search results filter to show relevant tools - await expect( - page.locator("text=/merge|menggabungkan/i").first(), - ).toBeVisible({ timeout: 5000 }); - - // Step 5: Clear the search field - await searchBox.clear(); - - // Step 6: Verify all tools reappear (check for multiple categories) - await expect( - page.locator("text=/recommended|direkomendasikan/i").first(), - ).toBeVisible({ timeout: 5000 }); - }); - }); - - test.describe("3.2 Search - No Results", () => { - test("should handle queries with no matching tools gracefully", async ({ - page, - }) => { - // Step 1: Open the search box from the header toggle - const searchBox = await openToolSearch(page); - - // Step 2: Type xyznonexistent123 - await searchBox.fill("xyznonexistent123"); - - // Step 3: Verify the search field accepted the input - await expect(searchBox).toHaveValue("xyznonexistent123"); - - // The app uses fuzzy search with a fallback that shows all tools when nothing - // matches, so we verify the search state is active (no "recommended" section) - // and the page remains functional without errors. - await expect( - page.locator("text=/recommended|direkomendasikan/i"), - ).toHaveCount(0, { timeout: 5000 }); - - // Step 4: Clear the search field - await searchBox.clear(); - - // Step 5: Verify all tools reappear (recommended section comes back) - await expect( - page.locator("text=/recommended|direkomendasikan/i").first(), - ).toBeVisible({ timeout: 5000 }); - }); - }); - - test.describe("3.3 Search - Special Characters", () => { - test("should sanitize search input against XSS", async ({ page }) => { - // Step 1: Type XSS payload into the search box - const searchBox = await openToolSearch(page); - await searchBox.fill(""); - - // Step 2: Verify no script execution occurs (no alert dialog) - // If an alert appeared, Playwright would throw an unhandled dialog error - await page.waitForTimeout(1000); - - // Step 3: Verify the search treats the input as plain text - await expect(searchBox).toHaveValue(""); - - // Step 4: Clear the search field - await searchBox.clear(); - }); - }); -}); diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index e79bc64c49..dd3c2aec7c 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -32,10 +32,27 @@ html[data-app-theme="light"] { --c-primary: var(--p-blue-500); --c-primary-hover: var(--p-blue-600); --c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent); + /* Accent used as TEXT (links, toggles): a deeper hue that clears 4.5:1 on + light surfaces, where --c-primary itself does not. */ + --c-accent-text: var(--p-blue-700); --c-success: var(--p-green-600); --c-danger: var(--p-red-600); --c-warning: var(--p-amber-600); + /* Solid fills that carry a white label. Deeper than the --c- values + above, which are picked for surfaces, borders and icons where the 3:1 + non-text floor applies. Scheme-independent: a filled badge reads white on + either ground. */ + --c-success-solid: var(--p-green-700); + --c-danger-solid: var(--p-red-600); + --c-warning-solid: var(--p-amber-700); + --c-neutral-solid: var(--p-gray-600); + --c-accent-solid: var(--p-blue-600); + /* Ink for surfaces that are white in both schemes — a rendered page, a + signature sheet. Scheme-independent for the same reason the fills above + are: the ground does not change, so the ink must not either. */ + --c-text-on-light: var(--p-ink); + --c-accent-on-light: var(--p-blue-700); /* Highlight/flash (search hits, compare jump-to) — same in both themes. */ --c-highlight: var(--p-flash-yellow); /* Stirling brand red (auth CTAs) — a fixed brand colour, same in both themes. */ @@ -54,6 +71,11 @@ html[data-app-theme="light"] { var(--c-success) 10%, var(--c-surface) ); + --c-warning-subtle: color-mix( + in srgb, + var(--c-warning) 10%, + var(--c-surface) + ); /* ── Decorative / brand / categorical palette ────────────────────────── Fixed hues that intentionally do NOT follow the chosen accent: brand @@ -128,8 +150,10 @@ html[data-app-theme="midnight"] { --c-text: var(--p-snow); --c-text-muted: var(--p-zinc-200); - --c-text-subtle: var(--p-zinc-300); + --c-text-subtle: var(--p-zinc-250); --c-text-on-primary: var(--p-white); + /* Accent used as TEXT on dark surfaces: a lighter step for 4.5:1. */ + --c-accent-text: var(--p-blue-400); --c-btn-solid: var(--c-text); --c-btn-inverse: var(--p-ink); --c-btn-secondary: var(--p-c-1a1a1d); @@ -166,6 +190,10 @@ html[data-app-theme="custom"] { --c-primary-subtle: color-mix(in srgb, var(--c-primary) 14%, transparent); --c-text-on-primary: var(--p-white); --c-accent-fg: var(--c-primary); + /* Accent used as TEXT: --c-primary and its 80% hover both land under + 4.5:1 on the accent-tinted surfaces they appear on (chips, links, + outline buttons), so accent copy takes a deeper step of the hue. */ + --c-accent-text: var(--p-blue-700); /* Primary-tinted surfaces (light base). Neutralised by the default override. */ --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-paper)); @@ -194,7 +222,11 @@ html[data-app-theme="custom"] { --color-primary-300: color-mix(in srgb, var(--c-primary) 55%, var(--p-white)); --color-primary-400: color-mix(in srgb, var(--c-primary) 78%, var(--p-white)); --color-primary-500: var(--c-primary); - --color-primary-600: color-mix(in srgb, var(--c-primary) 88%, var(--p-black)); + /* Shade 600 is the ramp's text-capable step — Mantine maps it to shade 6, + which drives link colour and the tuple's default text. At 88% it lands + at 4.2:1 on the page, so it mixes further down. Filled surfaces read + from --mantine-primary-color-filled (= --c-primary) and are unaffected. */ + --color-primary-600: color-mix(in srgb, var(--c-primary) 75%, var(--p-black)); --color-primary-700: color-mix(in srgb, var(--c-primary) 74%, var(--p-black)); --color-primary-800: color-mix(in srgb, var(--c-primary) 60%, var(--p-black)); --color-primary-900: color-mix(in srgb, var(--c-primary) 46%, var(--p-black)); @@ -211,7 +243,9 @@ html[data-app-theme="custom"] { var(--c-primary) 18%, transparent ); - --mantine-primary-color-light-color: var(--c-primary); + /* Text of the light/subtle variants — the fill value only reaches ~3:1 on + its own tint, so accent copy takes the deeper step. */ + --mantine-primary-color-light-color: var(--c-accent-text); /* Brand-tint family — re-derived from --c-primary so legacy accents harmonise to the chosen hue. */ @@ -280,10 +314,29 @@ html[data-app-theme="custom"] { /* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { + --c-accent-text: var(--p-blue-400); + /* Shade 600 is the ramp's text-capable step (Mantine's shade 6 — link colour + and the tuple's default text). The light theme reaches it by mixing toward + black; on a dark ground it has to travel the other way to stay readable. */ + --color-primary-600: color-mix(in srgb, var(--c-primary) 85%, var(--p-white)); + /* The accent's tint rungs, which back every light-variant surface. The light + theme builds them by mixing toward white; left unmixed for dark they stay a + pale blue and put dark-theme ink on a near-white ground. */ + --color-primary-50: color-mix(in srgb, var(--c-primary) 12%, var(--c-bg)); + --color-primary-100: color-mix(in srgb, var(--c-primary) 20%, var(--c-bg)); + --color-primary-200: color-mix(in srgb, var(--c-primary) 35%, var(--c-bg)); + --color-primary-300: color-mix(in srgb, var(--c-primary) 55%, var(--c-bg)); + --color-primary-400: color-mix(in srgb, var(--c-primary) 78%, var(--c-bg)); + /* The inks that sit on those tints. Mixing toward black is what makes them + readable on a pale ground; on the dark tints above they have to travel + toward white for the same reason. */ + --color-primary-700: color-mix(in srgb, var(--c-primary) 70%, var(--p-white)); + --color-primary-800: color-mix(in srgb, var(--c-primary) 55%, var(--p-white)); + --color-primary-900: color-mix(in srgb, var(--c-primary) 40%, var(--p-white)); /* Neutral text / borders / overlay (not accent-tinted). */ --c-text: var(--p-snow); --c-text-muted: var(--p-zinc-200); - --c-text-subtle: var(--p-zinc-300); + --c-text-subtle: var(--p-zinc-250); --c-btn-solid: var(--c-text); --c-btn-inverse: var(--p-ink); --c-btn-secondary: var(--p-c-1a1a1d); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index aa0a1b38cc..554baddcca 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -62,6 +62,7 @@ /* ── Z-index ladder ── */ --z-dropdown: 25; --z-drawer: 50; + --z-popover: 150; --z-toast: 200; /* Fullscreen tool-picker surfaces (editor) */ --z-fullscreen-icon-svg: 1; diff --git a/frontend/editor/src/core/theme/mantineTheme.ts b/frontend/editor/src/core/theme/mantineTheme.ts index bd0f7b764f..06ee36b269 100644 --- a/frontend/editor/src/core/theme/mantineTheme.ts +++ b/frontend/editor/src/core/theme/mantineTheme.ts @@ -3,6 +3,7 @@ import { MantineColorsTuple, MantineTheme, MantineThemeComponent, + type CSSVariablesResolver, } from "@mantine/core"; // Define color tuples using CSS variables @@ -45,6 +46,21 @@ const yellow: MantineColorsTuple = [ "var(--color-yellow-900)", ]; +// Mantine falls back to its own palette for any colour name the theme does not +// define, which is how stock reds/blues (failing contrast) reached the UI. +const red: MantineColorsTuple = [ + "var(--color-red-50)", + "var(--color-red-100)", + "var(--color-red-200)", + "var(--color-red-300)", + "var(--color-red-400)", + "var(--color-red-500)", + "var(--color-red-600)", + "var(--color-red-700)", + "var(--color-red-800)", + "var(--color-red-900)", +]; + const gray: MantineColorsTuple = [ "var(--color-gray-50)", "var(--color-gray-100)", @@ -72,6 +88,84 @@ const dark: MantineColorsTuple = [ "#050506", // dark-9 — deepest ]; +/** + * Mantine derives each variant's text colour from the palette itself, landing + * around 3.7:1 on its own tint — too low for badge, button and helper text. + * These slots point at the accessible shade for the scheme instead. + * + * Slots whose values are written in tokens that already flip with the colour + * scheme (`--c-accent-text`, `--color-*-dark`, `--c-text-muted`), plus the + * solid fills, which are chosen to carry a white label on either ground. Both + * schemes therefore take the same map; only the slots naming a fixed primitive + * need a per-scheme value. + */ +const accessibleColorSlots = { + // Link colour: Mantine derives it from the accent's shade 6. + "--mantine-color-anchor": "var(--c-accent-text)", + "--mantine-color-primary-light-color": "var(--c-accent-text)", + "--mantine-color-blue-light-color": "var(--c-accent-text)", + "--mantine-color-red-light-color": "var(--color-red-dark)", + "--mantine-color-green-light-color": "var(--color-green-dark)", + "--mantine-color-yellow-light-color": "var(--color-amber-dark)", + // Colour names the app never registers still reach Mantine's own palette + // through `color="..."` props, so pin their light-variant text too. + "--mantine-color-orange-light-color": "var(--color-amber-dark)", + "--mantine-color-grape-light-color": "var(--color-purple-dark)", + "--mantine-color-teal-light-color": "var(--color-green-dark)", + "--mantine-color-cyan-light-color": "var(--c-accent-text)", + // Mantine's own semantic slots. The -text variants back input errors and + // text-only variants; the -filled ones back solid badges, and the stock + // orange and grey are too light to carry a white label. + "--mantine-color-dimmed": "var(--c-text-muted)", + // Placeholders default to a divider-weight grey, which is not a text shade. + "--mantine-color-placeholder": "var(--c-text-subtle)", + // Neutral badges and chips derive their label from the grey ramp, which lands + // mid-scale and reads against neither ground. + "--mantine-color-gray-light-color": "var(--c-text-muted)", + "--mantine-color-error": "var(--color-red-dark)", + "--mantine-color-red-text": "var(--color-red-dark)", + "--mantine-color-green-text": "var(--color-green-dark)", + "--mantine-color-orange-text": "var(--color-amber-dark)", + "--mantine-color-blue-text": "var(--c-accent-text)", + "--mantine-color-blue-filled": "var(--c-accent-solid)", + "--mantine-color-green-filled-hover": "var(--c-success-solid)", + "--mantine-color-orange-filled": "var(--p-amber-700)", + "--mantine-color-green-filled": "var(--p-green-700)", + "--mantine-color-yellow-filled": "var(--p-amber-700)", + // Outline variants paint their label with the hue's -outline slot, which + // defaults to the solid fill and is therefore too light to read. + "--mantine-color-red-outline": "var(--color-red-dark)", + "--mantine-color-green-outline": "var(--color-green-dark)", + "--mantine-color-teal-outline": "var(--color-green-dark)", + "--mantine-color-orange-outline": "var(--color-amber-dark)", + "--mantine-color-yellow-outline": "var(--color-amber-dark)", + "--mantine-color-blue-outline": "var(--c-accent-text)", + "--mantine-color-primary-outline": "var(--c-accent-text)", + "--mantine-color-teal-filled": "var(--p-green-700)", + "--mantine-color-gray-filled": "var(--p-gray-600)", +} as const; + +export const editorCssVariablesResolver: CSSVariablesResolver = () => ({ + variables: {}, + light: { + ...accessibleColorSlots, + // Indigo has no semantic token to flip, so each scheme names its own step. + "--mantine-color-indigo-light-color": "var(--p-indigo-600)", + }, + dark: { + ...accessibleColorSlots, + "--mantine-color-indigo-light-color": "var(--p-indigo-300)", + // Hues the theme never registers keep Mantine's own ramp, whose shade 0 is + // a pale tint in both schemes — a light panel in a dark UI. The registered + // hues take their tint from the app's own dark rungs already. + "--mantine-color-orange-0": "var(--color-yellow-50)", + "--mantine-color-orange-1": "var(--color-yellow-100)", + "--mantine-color-grape-0": "var(--color-purple-light)", + "--mantine-color-indigo-0": "var(--color-primary-50)", + "--mantine-color-cyan-0": "var(--color-primary-50)", + }, +}); + export const mantineTheme = createTheme({ // Primary color primaryColor: "primary", @@ -79,6 +173,11 @@ export const mantineTheme = createTheme({ // Color palette colors: { primary, + // `blue` is the same ramp as `primary`: components written against Mantine's + // default palette name still land on the app's accent instead of stock blue. + blue: primary, + teal: green, + red, green, yellow, gray, @@ -124,6 +223,35 @@ export const mantineTheme = createTheme({ // Component customizations components: { + // Mantine renders the modal/drawer dismiss control as an icon-only button + // with no text, so without a name it is unreachable by screen reader. The + // app's own Modal wrapper (@app/ui/Modal) names itself; this covers the + // components that still mount Mantine's Modal directly. Pass a translated + // aria-label at the call site where the control means more than "close". + Anchor: { + styles: { + root: { + // Mantine links default to the accent shade, which reads at 4.4:1 on + // the page; accent copy has its own deeper token. + color: "var(--c-accent-text)", + }, + }, + }, + CloseButton: { + defaultProps: { "aria-label": "Close" }, + }, + ColorInput: { + // Mantine ships the eye-dropper trigger as an icon-only button with no + // accessible name. + defaultProps: { + eyeDropperButtonProps: { + "aria-label": "Pick a colour from the screen", + }, + }, + }, + Drawer: { + defaultProps: { closeButtonProps: { "aria-label": "Close" } }, + }, Button: { styles: { root: { @@ -336,6 +464,7 @@ export const mantineTheme = createTheme({ }, Modal: { + defaultProps: { closeButtonProps: { "aria-label": "Close" } }, styles: { content: { backgroundColor: "var(--c-surface)", diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css index 239658dfeb..5ca38559b2 100644 --- a/frontend/editor/src/core/theme/primitives.css +++ b/frontend/editor/src/core/theme/primitives.css @@ -12,9 +12,10 @@ --p-gray-400: #9ca3af; --p-gray-500: #6b7280; /* Subtle body text in light mode. gray-500 clears 4.5:1 on pure white but - only reaches 4.39:1 on the --p-paper canvas; this is the same hue nudged - dark enough to pass (4.87:1) while staying lighter than gray-600. */ - --p-gray-550: #646b76; + falls under it on the --p-paper canvas; this is the same hue nudged dark + enough to pass on the tinted surfaces too (4.94:1 on the blue banner tint, + the darkest ground it lands on) while staying lighter than gray-600. */ + --p-gray-550: #5d636d; --p-gray-600: #4b5563; --p-gray-700: #374151; --p-gray-800: #1f2937; @@ -30,6 +31,9 @@ --p-zinc-600: #333338; --p-zinc-500: #3f3f46; --p-zinc-400: #52525b; + /* Subtlest text shade that still clears 4.5:1 on the deepest dark + surfaces; zinc-300 is a border/icon shade and reads at ~3:1 there. */ + --p-zinc-250: #9a9aa2; --p-zinc-300: #71717a; --p-zinc-200: #a1a1aa; --p-zinc-100: #f4f4f5; @@ -41,15 +45,28 @@ --p-blue-500: #3b82f6; --p-blue-600: #2563eb; --p-blue-700: #1d4ed8; + --p-green-50: #f0fdf4; + --p-green-100: #dcfce7; + --p-green-200: #bbf7d0; --p-green-500: #22c55e; --p-green-600: #16a34a; --p-green-700: #15803d; + --p-green-800: #166534; + --p-amber-50: #fffbeb; + --p-amber-100: #fef3c7; + --p-amber-200: #fde68a; --p-amber-400: #fbbf24; --p-amber-500: #f59e0b; --p-amber-600: #d97706; + --p-amber-700: #b45309; + --p-amber-800: #92400e; + --p-red-50: #fef2f2; + --p-red-100: #fee2e2; + --p-red-200: #fecaca; --p-red-400: #f87171; --p-red-500: #ef4444; --p-red-600: #dc2626; + --p-red-700: #b91c1c; /* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */ --p-brand-red-200: #d9a8a8; @@ -63,6 +80,7 @@ --p-indigo-300: #a5b4fc; --p-indigo-400: #818cf8; --p-indigo-500: #6366f1; + --p-indigo-600: #4f46e5; --p-indigo-800: #3730a3; /* Extended accent/status/neutral weights referenced by app CSS. */ @@ -137,6 +155,7 @@ --p-c-2d3560: #2d3560; --p-emerald-400: #34d399; --p-azure-650: #3a7be8; + --p-azure-700: #0a6ac9; --p-navy-500: #3b4b6e; --p-c-475569: #475569; --p-c-334155: #334155; @@ -145,13 +164,14 @@ --p-c-545454: #545454; --p-azure-450: #5b9bf7; --p-c-64748b: #64748b; - --p-c-656d76: #656d76; + --p-c-8b9bb4: #8b9bb4; + --p-c-656d76: #5f6772; --p-c-6e7781: #6e7781; --p-violet-600: #7c3aed; --p-c-7e7e7e: #7e7e7e; --p-c-8250df: #8250df; --p-violet-500: #8b5cf6; - --p-c-8c959f: #8c959f; + --p-c-8c959f: #57606a; --p-blue-200: #93c5fd; --p-c-94a3b8: #94a3b8; --p-code-type: #953800; diff --git a/frontend/editor/src/core/tokens/base.css b/frontend/editor/src/core/tokens/base.css index 562c6bcc25..e0285e8b94 100644 --- a/frontend/editor/src/core/tokens/base.css +++ b/frontend/editor/src/core/tokens/base.css @@ -29,7 +29,7 @@ button { } a { - color: var(--c-primary); + color: var(--c-accent-text); text-decoration: none; } a:hover { diff --git a/frontend/editor/src/core/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css index 2fe1fc3b70..d7dfdb529a 100644 --- a/frontend/editor/src/core/tokens/tokens.css +++ b/frontend/editor/src/core/tokens/tokens.css @@ -25,6 +25,14 @@ --color-text-on-accent: #ffffff; /* Brand / status */ + --color-blue: var(--p-blue-500); + --color-blue-light: color-mix(in srgb, var(--p-blue-500) 13%, var(--p-white)); + --color-blue-border: color-mix( + in srgb, + var(--p-blue-500) 40%, + var(--p-white) + ); + --color-blue-dark: var(--p-blue-700); --color-purple: var(--p-blue-400); --color-purple-light: color-mix( in srgb, @@ -36,7 +44,11 @@ var(--p-blue-400) 40%, var(--p-white) ); - --color-purple-dark: var(--p-blue-600); + /* The -dark shades are the on-light TEXT colours (accents.css --_text and + status copy). They have to clear 4.5:1 on the tinted -light surfaces they + sit on, not just on the page, which is why they sit well below the base + hue rather than one step under it. */ + --color-purple-dark: var(--p-blue-700); --color-green: var(--p-green-500); --color-green-light: color-mix( in srgb, @@ -48,11 +60,11 @@ var(--p-green-500) 40%, var(--p-white) ); - --color-green-dark: var(--p-green-600); + --color-green-dark: var(--p-green-800); --color-red: var(--p-red-500); --color-red-light: color-mix(in srgb, var(--p-red-500) 13%, var(--p-white)); --color-red-border: color-mix(in srgb, var(--p-red-500) 40%, var(--p-white)); - --color-red-dark: var(--p-red-600); + --color-red-dark: var(--p-red-700); --color-amber: var(--p-amber-500); --color-amber-light: color-mix( in srgb, @@ -64,7 +76,7 @@ var(--p-amber-500) 42%, var(--p-white) ); - --color-amber-dark: var(--p-amber-600); + --color-amber-dark: var(--p-amber-800); --color-orange: var(--p-amber-600); --color-orange-light: color-mix( in srgb, @@ -100,7 +112,9 @@ --color-tooltip-text: var(--p-gray-50); /* Navigation */ - --color-section-label: var(--p-gray-400); + /* Form/section labels are small uppercase text, so they need a real text + shade: gray-400 only reaches 2.3:1 on the page background. */ + --color-section-label: var(--p-gray-600); /* Header */ --color-search-border-hover: var(--p-gray-300); @@ -162,6 +176,8 @@ --code-bg-alt: var(--p-c-eef1f4); --code-bg-header: var(--p-c-eaeef2); --code-text: var(--p-c-1f2328); + /* Both sit on --code-bg-header, the darkest of the code grounds, so they run + deeper than a plain "dim/muted" pair would on white. */ --code-dim: var(--p-c-656d76); --code-muted: var(--p-c-8c959f); --code-keyword: var(--p-c-cf222e); @@ -180,6 +196,10 @@ /* Bumped from #475569 (fails AA) — see light-theme note. */ --color-text-placeholder: var(--p-gray-600); + --color-blue: var(--p-blue-500); + --color-blue-light: var(--p-zinc-750); + --color-blue-border: var(--p-zinc-600); + --color-blue-dark: var(--p-blue-400); --color-purple: var(--p-blue-400); --color-purple-light: var(--p-zinc-750); --color-purple-border: var(--p-zinc-600); @@ -195,7 +215,10 @@ --color-red: var(--p-red-400); --color-red-light: var(--p-zinc-800); --color-red-border: var(--p-red-600); - --color-red-dark: var(--p-red-500); + /* The -dark shades are this theme's TEXT colours. On the dark card and + raised surfaces red-500 only reaches ~3.9:1, so error copy runs a step + lighter than the base hue. */ + --color-red-dark: var(--p-red-400); --color-amber: var(--p-amber-400); --color-amber-light: color-mix( in srgb, @@ -223,7 +246,9 @@ --color-tooltip-bg: var(--p-gray-250); --color-tooltip-text: var(--p-blue-700); - --color-section-label: var(--p-gray-600); + /* Uppercase 12px label text: the light theme's grey only reaches 2.3:1 + on the dark surfaces, so this theme takes a real on-dark shade. */ + --color-section-label: var(--p-zinc-200); --color-search-border-hover: var(--p-gray-600); --color-search-text: var(--p-gray-500); @@ -251,7 +276,9 @@ --code-bg-alt: var(--p-c-1e293b); --code-bg-header: var(--p-c-1a2332); --code-text: var(--p-gray-250); - --code-dim: var(--p-c-64748b); + /* Sits on the code header, the lightest of the dark code grounds, where + the mid-slate only reaches 3.3:1. */ + --code-dim: var(--p-c-8b9bb4); --code-muted: var(--p-c-94a3b8); --code-keyword: var(--p-blue-400); --code-string: var(--p-amber-400); diff --git a/frontend/editor/src/core/tools/formFill/FormFill.module.css b/frontend/editor/src/core/tools/formFill/FormFill.module.css index 800e610c03..edd19d0814 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.module.css +++ b/frontend/editor/src/core/tools/formFill/FormFill.module.css @@ -222,7 +222,7 @@ margin-top: 0.25rem; font-size: 0.6875rem; font-weight: 500; - color: var(--mantine-color-red-6); + color: var(--color-red-dark); } .emptyState { diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index 599d586319..5ed9dcd47b 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -35,7 +35,7 @@ import { } from "@app/tools/formFill/FormFillContext"; import { useNavigation } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles, useFileState } from "@app/contexts/FileContext"; import { Skeleton } from "@mantine/core"; import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; import type { BaseToolProps } from "@app/types/tool"; @@ -124,7 +124,7 @@ const _MODE_TABS: ModeTabDef[] = [ const FormFill = (_props: BaseToolProps) => { const { t } = useTranslation(); const { selectedTool } = useNavigation(); - const { selectors, state: fileState } = useFileState(); + const { state: fileState } = useFileState(); const { state: formState, @@ -178,7 +178,9 @@ const FormFill = (_props: BaseToolProps) => { const isDirtyRef = useRef(formState.isDirty); isDirtyRef.current = formState.isDirty; - const activeFiles = selectors.getFiles(); + // Subscribing read: getFiles() during render doesn't re-run when the workbench + // changes, so the panel kept showing the pre-hydration (or pre-version) file. + const { files: activeFiles } = useAllFiles(); const selectedFileIds = fileState.ui.selectedFileIds; const currentFile = useMemo(() => { if (activeFiles.length === 0) return null; diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 72c7d7c5b6..2dafb3d07a 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -34,6 +34,7 @@ export interface AppConfig { serverCertificateEnabled?: boolean; hardwareSigningAvailable?: boolean; enableMobileScanner?: boolean; + enableMobileSignature?: boolean; mobileScannerConvertToPdf?: boolean; mobileScannerImageResolution?: string; mobileScannerPageFormat?: string; diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index 0d99b3589d..3dc00c306d 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -61,6 +61,12 @@ export interface StirlingFileStub extends BaseFileMetadata { * unclassified files / non-SaaS builds. */ classificationLabels?: string[]; + /** + * This session proved the stored bytes unreadable (WebKit losing a blob's + * backing store). The row renders as "data lost" instead of pretending the + * file can open; re-uploading is the only recovery. + */ + dataUnavailable?: boolean; // Note: File object stored in provider ref, not in state } diff --git a/frontend/editor/src/core/types/superSearch.ts b/frontend/editor/src/core/types/superSearch.ts new file mode 100644 index 0000000000..3d8e72849f --- /dev/null +++ b/frontend/editor/src/core/types/superSearch.ts @@ -0,0 +1,134 @@ +import type React from "react"; + +/** + * The super search's shared contract: what a results provider returns and the + * shapes hosts exchange with the shared rankers. Pure types, kept apart from + * the provider implementations so leaf modules (flavor seams, the portal's + * entity search) can depend on the contract without importing a provider — + * which would be a circular import. + */ + +export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor"; + +export interface SuperSearchResult { + /** Stable unique key across all groups. */ + key: string; + /** Group id — the editor uses SuperSearchGroupId; other hosts use their own. */ + group: string; + title: string; + subtitle?: string; + /** LocalIcon name (files/settings); tools provide a React node via `icon`. */ + iconName?: string; + icon?: React.ReactNode; + score: number; + onSelect: () => void | Promise; +} + +export interface SuperSearchGroup { + id: string; + label: string; + /** Optional higher-level section label rendered above consecutive groups. */ + sectionLabel?: string; + results: SuperSearchResult[]; +} + +export interface SuperSearchScope { + id: string; + label: string; + aliases?: string[]; +} + +export interface UseSuperSearchResult { + /** Non-empty groups, in display order. */ + groups: SuperSearchGroup[]; + /** All results flattened in display order (for keyboard navigation). */ + flatResults: SuperSearchResult[]; + /** True while the My Files store is loading for the first time. */ + loadingFiles: boolean; +} + +export interface SuperSearchQueryOptions { + scopeIds?: readonly string[]; +} + +/** + * Visibility gates shared by the settings and Processor sources. Hosts pass + * `null` while the app config is still loading — the rankers treat that as + * "most restrictive" so gated results can appear once config lands but never + * flash open before it. + */ +export interface SuperSearchGates { + isAdmin: boolean; + loginEnabled: boolean; + portalAccessible?: boolean; + /** + * Whether no-login mode keeps the read-only admin settings preview + * (`system.showSettingsWhenNoLogin`, default true). Mirrors the settings + * nav builder's admin gate: `isAdmin || (!loginEnabled && this)`. + */ + showSettingsWhenNoLogin?: boolean; + /** + * True for a signed-out (anonymous) session. Gates account-bound sections + * on hosts where auth state, not the local backend config, is the truth + * (SaaS). Hosts whose users are always signed in may omit it. + */ + isAnonymous?: boolean; +} + +/** + * The Processor's entity lanes as scope definitions — ids, the portal view + * each targets, chip labels and typed-prefix aliases. Both hosts' chip lists + * and the portal's entity module derive from this one list, so the chips + * can't drift apart; it lives in the types leaf because the editor's chip + * list must not import portal code. + */ +export interface PortalEntityScopeDef { + id: + | "portal-users" + | "portal-policies" + | "portal-pipelines" + | "portal-sources"; + /** Portal view id the scope targets (visibility check vs the page index). */ + viewId: string; + labelKey: string; + labelFallback: string; + aliases: readonly string[]; +} + +/** + * The developer-docs scope. Separate from the entity defs above: docs are a + * bundled full-text manifest, not a fetched entity list, so they don't ride + * the fetch cache — but they get a chip and a results group like the rest. + */ +export const PORTAL_DOCS_SCOPE_ID = "portal-docs"; + +export const PORTAL_ENTITY_SCOPE_DEFS: readonly PortalEntityScopeDef[] = [ + { + id: "portal-users", + viewId: "users", + labelKey: "portal.nav.users", + labelFallback: "Users", + aliases: ["user", "users", "member", "members"], + }, + { + id: "portal-policies", + viewId: "policies", + labelKey: "portal.nav.policies", + labelFallback: "Policies", + aliases: ["policy", "policies"], + }, + { + id: "portal-pipelines", + viewId: "pipelines", + labelKey: "portal.nav.pipelines", + labelFallback: "Pipelines", + aliases: ["pipeline", "pipelines"], + }, + { + id: "portal-sources", + viewId: "sources", + labelKey: "portal.nav.sources", + labelFallback: "Sources", + aliases: ["source", "sources"], + }, +]; diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index bf498eb450..b2bdf7fb4b 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -7,7 +7,7 @@ export interface AddAttachmentRequest { /** * The image file to be overlaid onto the PDF. */ - attachments: string[]; + attachments: File[]; /** * Convert the resulting PDF to PDF/A-3b format after adding attachments */ @@ -148,7 +148,7 @@ export interface AddStampRequest { * The rotation of the stamp in degrees */ rotation?: number; - stampImage?: string; + stampImage?: File; /** * The stamp text */ @@ -187,7 +187,7 @@ export interface AddWatermarkRequest { * The rotation of the watermark in degrees */ rotation?: number; - watermarkImage?: string; + watermarkImage?: File; /** * The watermark text */ @@ -525,9 +525,7 @@ export interface FlattenRequest { */ renderDpi?: number; } -export interface GeneralExtractBookmarksRequest { - file: string; -} +export type GeneralExtractBookmarksRequest = Record; export type GeneralFile = Record; export type GeneralPdfToSinglePageRequest = Record; export type GeneralRemoveImagePdfRequest = Record; @@ -788,7 +786,7 @@ export interface OverlayImageRequest { * Whether to overlay the image onto every page of the PDF. */ everyPage?: boolean; - imageFile: string; + imageFile: File; /** * The x-coordinate at which to place the top-left corner of the image. */ @@ -806,7 +804,7 @@ export interface OverlayPdfsRequest { /** * An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode. */ - overlayFiles: string[]; + overlayFiles: File[]; /** * The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts */ @@ -1276,7 +1274,6 @@ export interface ScannerEffectRequest { yellowish?: boolean; } export interface SecurityCertSignSessionsRequest { - file: string; request?: WorkflowCreationRequest; } export interface WorkflowCreationRequest { @@ -1291,8 +1288,8 @@ export interface WorkflowCreationRequest { } export interface SecurityCertSignValidateCertificateRequest { certType: string; - jksFile?: string; - p12File?: string; + jksFile?: File; + p12File?: File; password?: string; } export type SecurityGetInfoOnPdfRequest = Record; @@ -1302,7 +1299,7 @@ export interface SignPDFWithCertRequest { * The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates. */ alias?: string; - certFile?: string; + certFile?: File; /** * The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app. */ @@ -1314,7 +1311,7 @@ export interface SignPDFWithCertRequest { | "SERVER" | "WINDOWS_STORE" | "PKCS11"; - jksFile?: string; + jksFile?: File; /** * The location where the PDF is signed */ @@ -1323,7 +1320,7 @@ export interface SignPDFWithCertRequest { * The name of the signer */ name?: string; - p12File?: string; + p12File?: File; /** * The page number where the signature should be visible. This is required if showSignature is set to true */ @@ -1340,7 +1337,7 @@ export interface SignPDFWithCertRequest { * Optional PKCS#11 slot index. When omitted the first slot with a token is used. */ pkcs11Slot?: number; - privateKeyFile?: string; + privateKeyFile?: File; /** * The reason for signing the PDF */ @@ -1355,7 +1352,7 @@ export interface SignPDFWithCertRequest { showSignature?: boolean; } export interface SignatureValidationRequest { - certFile?: string; + certFile?: File; } export interface SplitPagesRequest { /** @@ -1741,5 +1738,22 @@ export const TOOL_ENDPOINTS = [ "/api/v1/security/verify-pdf", ] as const satisfies readonly ToolEndpoint[]; +/** The supporting-file parameters each endpoint accepts beyond its primary fileInput, by name. */ +export const TOOL_FILE_FIELDS = { + "/api/v1/general/overlay-pdfs": ["overlayFiles"], + "/api/v1/misc/add-attachments": ["attachments"], + "/api/v1/misc/add-image": ["imageFile"], + "/api/v1/misc/add-stamp": ["stampImage"], + "/api/v1/security/add-watermark": ["watermarkImage"], + "/api/v1/security/cert-sign": [ + "privateKeyFile", + "certFile", + "p12File", + "jksFile", + ], + "/api/v1/security/cert-sign/validate-certificate": ["p12File", "jksFile"], + "/api/v1/security/validate-signature": ["certFile"], +} as const satisfies Partial>; + /** Union of every generated tool request model. */ export type ToolApiRequest = ToolApiParams[ToolEndpoint]; diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index e2e8dc63bd..360c15c566 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -46,6 +46,12 @@ height: 2.5rem; font-size: 1rem; } +/* Account-settings hero disc. */ +.sui-avatar--xl { + width: 4.5rem; + height: 4.5rem; + font-size: 1.75rem; +} .sui-avatar__img { width: 100%; diff --git a/frontend/editor/src/core/ui/Avatar.tsx b/frontend/editor/src/core/ui/Avatar.tsx index c7cfac501b..42e7aca11f 100644 --- a/frontend/editor/src/core/ui/Avatar.tsx +++ b/frontend/editor/src/core/ui/Avatar.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from "react"; import "@app/ui/Avatar.css"; -export type AvatarSize = "xs" | "sm" | "md" | "lg"; +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; export type AvatarTone = | "blue" | "purple" @@ -23,10 +24,12 @@ export interface AvatarProps { className?: string; } -function initialsOf(name: string): string { +function avatarInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + // Single word (a username or an email) reads as one letter — two letters of + // "admin" ("AD") looks like a different person's initials, not a truncation. + if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } @@ -43,6 +46,13 @@ export function Avatar({ ariaLabel, className, }: AvatarProps) { + // A picture URL that 404s (expired signed URL, deleted upload) must not leave + // an empty disc — fall back to the same initials the no-picture case shows, so + // every surface rendering this identity agrees on what it draws. + const [srcFailed, setSrcFailed] = useState(false); + useEffect(() => setSrcFailed(false), [src]); + const showImage = Boolean(src) && !srcFailed; + const classes = [ "sui-avatar", `sui-avatar--${size}`, @@ -53,11 +63,16 @@ export function Avatar({ .filter(Boolean) .join(" "); - const content = src ? ( - {ariaLabel + const content = showImage ? ( + {ariaLabel setSrcFailed(true)} + /> ) : ( - {initialsOf(name)} + {avatarInitials(name)} ); diff --git a/frontend/editor/src/core/ui/Banner.css b/frontend/editor/src/core/ui/Banner.css index 6f3a53e930..9d12c8fff2 100644 --- a/frontend/editor/src/core/ui/Banner.css +++ b/frontend/editor/src/core/ui/Banner.css @@ -45,16 +45,16 @@ } .sui-banner--info .sui-banner__icon { - color: var(--c-primary); + color: var(--c-accent-text); } .sui-banner--success .sui-banner__icon { - color: var(--color-green); + color: var(--color-green-dark); } .sui-banner--warning .sui-banner__icon { color: var(--color-amber-dark); } .sui-banner--danger .sui-banner__icon { - color: var(--color-red); + color: var(--color-red-dark); } .sui-banner__body { diff --git a/frontend/editor/src/core/ui/Button.css b/frontend/editor/src/core/ui/Button.css index 529d97fe16..37d052a96e 100644 --- a/frontend/editor/src/core/ui/Button.css +++ b/frontend/editor/src/core/ui/Button.css @@ -15,6 +15,11 @@ display: none; } +/* ---- fat ---- */ +.sui-btn--fat.mantine-Button-root { + --button-radius: 0.75rem; +} + /* ---- shape ---- */ .sui-btn--circle.mantine-Button-root { --button-radius: 50%; diff --git a/frontend/editor/src/core/ui/Button.stories.tsx b/frontend/editor/src/core/ui/Button.stories.tsx index 630d14010c..22e6c9464e 100644 --- a/frontend/editor/src/core/ui/Button.stories.tsx +++ b/frontend/editor/src/core/ui/Button.stories.tsx @@ -89,6 +89,7 @@ const meta: Meta = { options: ["center", "start", "end", "between"], }, shape: { control: "inline-radio", options: ["default", "circle", "pill"] }, + fat: { control: "boolean" }, text: { control: "text" }, }, }; @@ -203,6 +204,35 @@ export const Padding: Story = { ), }; +/** `fat` is the chunky CTA treatment: 2.75rem tall, 1.25rem side padding, 0.75rem corners. */ +export const Fat: Story = { + render: () => ( +

+ +
+ ), +}; + /** Icons are optional and positional: `leftSection`, `rightSection`, or both. */ export const WithIcons: Story = { render: (args) => ( diff --git a/frontend/editor/src/core/ui/Button.tsx b/frontend/editor/src/core/ui/Button.tsx index 8c04aa226c..4b6bf2db85 100644 --- a/frontend/editor/src/core/ui/Button.tsx +++ b/frontend/editor/src/core/ui/Button.tsx @@ -42,6 +42,7 @@ type ButtonOwnProps = { variant?: ButtonVariant; accent?: ButtonAccent; size?: ButtonSize; + fat?: boolean; /** Label size relative to `size`. Defaults to the `size`-derived value. */ fontSize?: ButtonFontSize; /** Padding override for both axes */ @@ -103,6 +104,9 @@ function ButtonGroup({ ); } +const FAT_HEIGHT = "2.75rem"; +const FAT_PADDING_X = "lg" satisfies ControlPadding; + const MANTINE_VARIANT: Record = { primary: "filled", secondary: "outline", @@ -123,6 +127,7 @@ const ButtonRoot = forwardRef( variant = "primary", accent = "default", size = "sm", + fat = false, fontSize, p, px, @@ -153,8 +158,15 @@ const ButtonRoot = forwardRef( const iconOnly = !hasLabel && !fullWidth && (!!leftSection || !!rightSection || loading); + // A button whose label is momentarily absent while loading still needs an + // accessible name; the spinner and any icon are decorative. + const fallbackLabel = + !hasLabel && loading && !rest["aria-label"] && !rest["aria-labelledby"] + ? "Loading" + : undefined; + // px/py override p for their axis; each stays undefined (= size default) if unset. - const padX = px ?? p; + const padX = px ?? p ?? (fat ? FAT_PADDING_X : undefined); const padY = py ?? p; // Sections flank a label → spread them without requiring justify="between". @@ -168,6 +180,7 @@ const ButtonRoot = forwardRef( `sui-acc-${accent}`, `sui-btn--${variant}`, iconOnly ? "sui-btn--icon" : "", + fat ? "sui-btn--fat" : "", shape !== "default" ? `sui-btn--${shape}` : "", overflow === "wrap" ? "sui-btn--wrap" : "", !hover ? "sui-btn--no-hover" : "", @@ -217,6 +230,7 @@ const ButtonRoot = forwardRef( return ( ( className={classes} style={{ ...(accentVars as CSSProperties), - ...({ "--button-height": CONTROL_HEIGHT[size] } as CSSProperties), + ...({ + "--button-height": fat ? FAT_HEIGHT : CONTROL_HEIGHT[size], + } as CSSProperties), // Relative label size, scaled off the `size` base (unset → Mantine default). ...(fontSize ? ({ @@ -245,6 +261,10 @@ const ButtonRoot = forwardRef( ...(padY ? ({ "--sui-btn-py": CONTROL_PADDING[padY] } as CSSProperties) : {}), + // mantineTheme writes font-weight inline on every button root, so this must be inline too. + ...(fat + ? ({ fontWeight: "var(--font-weight-semibold)" } as CSSProperties) + : {}), // Icon-only: zero the size padding inline so the lone icon centres. ...(iconOnly ? ({ "--button-padding-x": "0" } as CSSProperties) : {}), ...style, diff --git a/frontend/editor/src/core/ui/Card.css b/frontend/editor/src/core/ui/Card.css index fcbefdab5b..62deaa0cf0 100644 --- a/frontend/editor/src/core/ui/Card.css +++ b/frontend/editor/src/core/ui/Card.css @@ -1,11 +1,6 @@ .sui-card { position: relative; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-md); transition: - box-shadow var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); } @@ -28,7 +23,6 @@ } .sui-card--interactive:hover { border-color: var(--c-border-strong); - box-shadow: var(--shadow-lg); transform: translateY(-0.0625rem); } @@ -43,7 +37,7 @@ left: 0; bottom: 0; width: 0.25rem; - border-radius: var(--radius-lg) 0 0 var(--radius-lg); + border-radius: var(--radius-nav) 0 0 var(--radius-nav); } .sui-card--accent-default::before { background: var(--c-primary); diff --git a/frontend/editor/src/core/ui/Card.tsx b/frontend/editor/src/core/ui/Card.tsx index dbdb1a393a..42d4c2d93a 100644 --- a/frontend/editor/src/core/ui/Card.tsx +++ b/frontend/editor/src/core/ui/Card.tsx @@ -1,4 +1,5 @@ import type { HTMLAttributes, ReactNode } from "react"; +import "@app/ui/Surface.css"; import "@app/ui/Card.css"; /** Subset of the shared accent dial that has a styled strip (see Card.css). */ @@ -18,7 +19,7 @@ export interface CardProps extends HTMLAttributes { * (e.g. a list with row dividers). */ padding?: "none" | "tight" | "default" | "loose"; - /** Use the lifted surface treatment (taller shadow, hover affordance). */ + /** Adds the clickable affordance (pointer cursor, hover lift). */ interactive?: boolean; children?: ReactNode; } @@ -40,6 +41,7 @@ export function Card({
+ )}
-
+      {/* Focusable and named: long samples scroll, and a scrollable region needs
+          to be reachable by keyboard to be scrolled at all. */}
+      
         {code}
       
diff --git a/frontend/editor/src/core/ui/Collapsible.css b/frontend/editor/src/core/ui/Collapsible.css index d0c1308f3b..6f7164dea5 100644 --- a/frontend/editor/src/core/ui/Collapsible.css +++ b/frontend/editor/src/core/ui/Collapsible.css @@ -1,6 +1,4 @@ .sui-collapsible { - border: 1px solid var(--c-border-subtle); - border-radius: 0.5625rem; overflow: hidden; } diff --git a/frontend/editor/src/core/ui/Collapsible.tsx b/frontend/editor/src/core/ui/Collapsible.tsx index 94f1052056..229b2d2ef1 100644 --- a/frontend/editor/src/core/ui/Collapsible.tsx +++ b/frontend/editor/src/core/ui/Collapsible.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import "@app/ui/Surface.css"; import "@app/ui/Collapsible.css"; export interface CollapsibleProps { @@ -29,7 +30,9 @@ export function Collapsible({ }: CollapsibleProps) { return (
+ + } + /> + ), +}; + +/** The one look choice: the `compact` variant. */ +export const Compact: Story = { + render: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + variant="compact" + /> + ), +}; diff --git a/frontend/editor/src/core/ui/DataTable.tsx b/frontend/editor/src/core/ui/DataTable.tsx new file mode 100644 index 0000000000..ccb5578016 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.tsx @@ -0,0 +1,516 @@ +import { type KeyboardEvent, type ReactNode, useMemo, useState } from "react"; +import { + type ColumnDef, + createColumnHelper, + createSortedRowModel, + flexRender, + type RowData, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_basic, + type SortingState, + tableFeatures, + useTable, +} from "@tanstack/react-table"; +import { useTranslation } from "react-i18next"; +import { Skeleton } from "@app/ui/Skeleton"; +import { + type CellAction, + type DataTableColumn, + renderCellActions, +} from "@app/ui/dataTableColumns"; +import "@app/ui/DataTable.css"; + +export * from "@app/ui/dataTableColumns"; + +/** Per-column presentation carried through TanStack's typed `meta` slot. */ +interface ColumnMeta { + align: "left" | "right"; + nowrap: boolean; + fit: boolean; + /** Visually-hidden header text for blank affordance/action columns, so the + * column still has an accessible name (avoids axe `empty-table-header`). */ + srHeader?: string; +} + +/** + * Feature registry for every DataTable, built once. Sorting is always + * registered so any column can opt in; the core row model defaults in. + */ +const DATA_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + columnMeta: {} as ColumnMeta, + // Register the comparators the column vocabulary uses. Without this v9 falls + // back to a case-sensitive `basic` sort and warns per column. + sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic }, +}); +type DataTableFeatures = typeof DATA_TABLE_FEATURES; + +/** Closed appearance dial — the only look choice a call-site may make. */ +export type DataTableVariant = "default" | "compact"; + +/** + * A collapsible section of rows under a locked header. Group headers are + * structured (title + muted meta + optional right-aligned actions), never raw + * markup, so grouped tables stay as opinionated as flat ones. Provide `groups` + * instead of `rows`. + */ +export interface DataTableGroup { + key: string; + title: string; + /** Muted sub-text on the header (e.g. "5 people Ā· led by Dana"). */ + meta?: string; + /** Right-aligned header actions (e.g. "Add to team", a kebab menu). */ + actions?: CellAction[]; + rows: T[]; + /** Collapse rows past this count behind a "Show all N" toggle. */ + collapseAfter?: number; + /** Render the group's rows greyed/disabled (non-actionable, e.g. coming-soon). */ + muted?: boolean; +} + +export interface DataTableProps { + /** Columns built with the `column` vocabulary — never raw JSX. */ + columns: DataTableColumn[]; + /** Flat rows. Provide this OR `groups`, not both. */ + rows?: T[]; + /** Grouped rows with section headers. Takes precedence over `rows`. */ + groups?: DataTableGroup[]; + rowKey: (row: T) => string; + + /** Makes rows interactive (hover + click + keyboard). */ + onRowClick?: (row: T) => void; + /** Per-row interactivity gate, checked only when `onRowClick` is set. */ + isRowInteractive?: (row: T) => boolean; + /** Trailing affordance drawn on interactive rows. */ + rowAffordance?: "none" | "chevron"; + + /** Initial sort, applied to the matching sortable column. */ + defaultSort?: { key: string; direction?: "asc" | "desc" }; + + /** First-load state: renders column-shaped skeleton rows under the header. */ + loading?: boolean; + /** Skeleton row count while loading. Defaults to 6. */ + skeletonRows?: number; + /** Error slot — replaces the rows with an alert message row. */ + error?: ReactNode; + /** Shown when there are no rows (and not loading / no error). Text or a node. */ + empty?: ReactNode; + + /** Content above the table (filters, search, actions), inside the surface. */ + toolbar?: ReactNode; + /** The only appearance choice. */ + variant?: DataTableVariant; + /** Accessible caption for the table. */ + caption?: string; + /** Labels for a group's "show all / show less" toggle (pass translated). + * `showAll` receives the group's total row count. */ + collapseLabels?: { showAll: (total: number) => string; showLess: string }; +} + +function ChevronGlyph() { + return ( + + + + ); +} + +function SortGlyph() { + return ( + + + + ); +} + +const CHEVRON_COLUMN_KEY = "__affordance"; + +/** + * The shared Stirling table. Call-sites supply data + behaviour; the component + * owns 100% of the appearance. Columns come from the `column` vocabulary (typed + * cell kinds, no raw markup), the surface / density / states are standardized + * here, and the only look choice exposed is the closed `variant`. Behaviour - + * sorting today, more later - is opt-in per column or via props. + */ +export function DataTable({ + columns, + rows = [], + groups, + rowKey, + onRowClick, + isRowInteractive, + rowAffordance = "none", + defaultSort, + loading = false, + skeletonRows = 6, + error, + empty, + toolbar, + variant = "default", + caption, + collapseLabels = { + showAll: (n) => `Show all ${n}`, + showLess: "Show less", + }, +}: DataTableProps) { + const { t } = useTranslation(); + const [sorting, setSorting] = useState( + defaultSort + ? [{ id: defaultSort.key, desc: defaultSort.direction === "desc" }] + : [], + ); + const [openGroups, setOpenGroups] = useState>(new Set()); + const toggleGroup = (key: string) => + setOpenGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // The data source is either grouped or flat; TanStack (headers, sorting for + // the flat path) is fed the flattened rows. + const flatRows = useMemo( + () => (groups ? groups.flatMap((g) => g.rows) : rows), + [groups, rows], + ); + + const interactive = Boolean(onRowClick); + const showChevron = interactive && rowAffordance === "chevron"; + // A row that holds its own controls (actions/links/select/caps) can't also be + // a `role="button"` (a button may not contain interactive descendants); it + // keeps the click as a mouse shortcut, and the inner control is the keyboard path. + const rowsContainControls = columns.some((c) => c.interactive); + + const effectiveColumns = useMemo[]>(() => { + if (!showChevron) return columns; + return [ + ...columns, + { + key: CHEVRON_COLUMN_KEY, + header: "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + renderCell: (row) => + (isRowInteractive?.(row) ?? true) ? ( + + + + ) : null, + }, + ]; + }, [columns, showChevron, isRowInteractive]); + + const tanstackColumns = useMemo[]>(() => { + const helper = createColumnHelper(); + return effectiveColumns.map((c) => { + // A blank header (trailing affordance/action columns) still needs an + // accessible name for assistive tech. + const srHeader = c.header + ? undefined + : c.key === CHEVRON_COLUMN_KEY + ? t("common.open", "Open") + : t("common.actions", "Actions"); + const meta: ColumnMeta = { + align: c.align, + nowrap: c.nowrap, + fit: c.fit, + srHeader, + }; + if (c.sortable && c.sortValue) { + const sortValue = c.sortValue; + return helper.accessor((row: T): unknown => sortValue(row), { + id: c.key, + header: () => c.header, + cell: (ctx) => c.renderCell(ctx.row.original), + enableSorting: true, + sortUndefined: "last", + sortFn: c.sortFn ?? "basic", + meta, + }); + } + return helper.display({ + id: c.key, + header: () => c.header, + cell: (ctx) => c.renderCell(ctx.row.original), + meta, + }); + }); + }, [effectiveColumns, t]); + + const table = useTable({ + features: DATA_TABLE_FEATURES, + data: flatRows, + columns: tanstackColumns, + state: { sorting }, + onSortingChange: setSorting, + getRowId: (row) => rowKey(row), + }); + + const colCount = effectiveColumns.length; + + // Shared row wiring so grouped rows behave like flat ones (interactivity + + // the affordance column) instead of being a second-class path. + const rowProps = (original: T, muted?: boolean) => { + const rowInteractive = + interactive && (isRowInteractive?.(original) ?? true); + // A row that owns the whole interaction takes the button role + keyboard + // handling; a row with its own controls keeps just the mouse click. + const asButton = rowInteractive && !rowsContainControls; + return { + className: [ + "sui-datatable__row", + rowInteractive ? "sui-datatable__row--interactive" : "", + muted ? "sui-datatable__row--muted" : "", + ] + .filter(Boolean) + .join(" "), + onClick: rowInteractive ? () => onRowClick?.(original) : undefined, + tabIndex: asButton ? 0 : undefined, + role: asButton ? ("button" as const) : undefined, + onKeyDown: asButton + ? (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(original); + } + } + : undefined, + }; + }; + + // No rows at all - covers a grouped table whose groups are all empty (or an + // empty groups list), which would otherwise render a header-only table. + const noRows = groups + ? groups.every((g) => g.rows.length === 0) + : rows.length === 0; + + let body: ReactNode; + if (loading) { + body = Array.from({ length: skeletonRows }).map((_, r) => ( + + {effectiveColumns.map((c) => ( + + + + ))} + + )); + } else if (error != null) { + body = ( + + + {error} + + + ); + } else if (noRows) { + const isNode = typeof empty === "object" && empty !== null; + body = ( + + + {empty ?? "No data"} + + + ); + } else if (groups) { + body = groups.flatMap((g) => { + const limit = g.collapseAfter ?? Infinity; + const open = openGroups.has(g.key); + const overflow = g.rows.length > limit; + const shown = overflow && !open ? g.rows.slice(0, limit) : g.rows; + const header = ( + + +
+
+ {g.title} + {g.meta && ( + {g.meta} + )} +
+ {g.actions && + g.actions.length > 0 && + renderCellActions(g.actions)} +
+ + + ); + const rowEls = shown.map((row) => ( + + {effectiveColumns.map((c) => ( + + {c.renderCell(row)} + + ))} + + )); + const moreEl = overflow ? ( + + + + + + ) : null; + return moreEl ? [header, ...rowEls, moreEl] : [header, ...rowEls]; + }); + } else { + body = table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => { + const meta = cell.column.columnDef.meta; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )); + } + + return ( +
+
+ {toolbar &&
{toolbar}
} +
+ + {caption && ( + + )} + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta; + const align = meta?.align ?? "left"; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + const label = header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + ); + return ( + + ); + })} + + ))} + + {body} +
{caption}
+ {canSort ? ( + + ) : meta?.srHeader ? ( + + {meta.srHeader} + + ) : ( + label + )} +
+
+
+
+ ); +} + +function cellClass( + align: "left" | "right", + nowrap: boolean, + fit: boolean, +): string { + return [ + "sui-datatable__td", + `sui-datatable__td--${align}`, + nowrap ? "sui-datatable__td--nowrap" : "", + fit ? "sui-datatable__td--fit" : "", + ] + .filter(Boolean) + .join(" "); +} + +function headerClass(align: "left" | "right", fit: boolean): string { + return [ + "sui-datatable__th", + `sui-datatable__th--${align}`, + fit ? "sui-datatable__th--fit" : "", + ] + .filter(Boolean) + .join(" "); +} diff --git a/frontend/editor/src/core/ui/Drawer.tsx b/frontend/editor/src/core/ui/Drawer.tsx index 7327b854dc..8ef10baf79 100644 --- a/frontend/editor/src/core/ui/Drawer.tsx +++ b/frontend/editor/src/core/ui/Drawer.tsx @@ -74,7 +74,9 @@ export function Drawer({ role="presentation" /> - +
, document.body, diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 38b20ee1d8..d71026d881 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -4,28 +4,20 @@ } .sui-dd__menu { - position: absolute; - top: calc(100% + var(--space-1)); + /* Positioned (fixed, portaled to ) entirely by the Menu component. */ min-width: 12rem; padding: var(--space-1); background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); - z-index: var(--z-dropdown); + z-index: var(--z-popover); animation: fadeInUp var(--motion-enter) both; display: flex; flex-direction: column; gap: 0.0625rem; } -.sui-dd__menu--start { - left: 0; -} -.sui-dd__menu--end { - right: 0; -} - .sui-dd__item { display: flex; align-items: center; @@ -54,7 +46,7 @@ .sui-dd__item.is-active { background: var(--c-primary-subtle); - color: var(--c-accent-fg); + color: var(--c-accent-text); font-weight: 500; } diff --git a/frontend/editor/src/core/ui/Dropdown.tsx b/frontend/editor/src/core/ui/Dropdown.tsx index bf481c9ea5..25019d7c3c 100644 --- a/frontend/editor/src/core/ui/Dropdown.tsx +++ b/frontend/editor/src/core/ui/Dropdown.tsx @@ -6,12 +6,14 @@ import { useContext, useEffect, useId, + useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import "@app/ui/Dropdown.css"; type Alignment = "start" | "end"; @@ -20,6 +22,8 @@ interface DropdownContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; + /** The portaled menu element, so click-outside can exclude it. */ + menuRef: React.RefObject; menuId: string; align: Alignment; } @@ -68,15 +72,20 @@ function Root({ const triggerRef = useRef(null); const containerRef = useRef(null); + const menuRef = useRef(null); const menuId = useId(); - // Click-outside + Escape close. + // Click-outside + Escape close. The menu is portaled to , so it is not + // inside containerRef - check it separately or a click on it would close the + // menu before the item's handler runs. useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { + const target = e.target as Node; if ( containerRef.current && - !containerRef.current.contains(e.target as Node) + !containerRef.current.contains(target) && + !(menuRef.current && menuRef.current.contains(target)) ) { setOpen(false); } @@ -96,7 +105,7 @@ function Root({ }, [open, setOpen]); const value = useMemo( - () => ({ open, setOpen, triggerRef, menuId, align }), + () => ({ open, setOpen, triggerRef, menuRef, menuId, align }), [open, setOpen, menuId, align], ); @@ -150,23 +159,79 @@ export interface DropdownMenuProps { } function Menu({ children, className, width }: DropdownMenuProps) { - const { open, menuId, align } = useDropdownCtx(); - if (!open) return null; - const style = - width !== undefined + const { open, menuId, align, triggerRef, menuRef } = useDropdownCtx(); + // Fixed position tracked to the trigger. Portaling to keeps the menu + // out of any `overflow` ancestor (e.g. a table's horizontal scroll area), + // which would otherwise clip it and add a scrollbar. + const [pos, setPos] = useState<{ + top?: number; + bottom?: number; + left?: number; + right?: number; + maxHeight: number; + } | null>(null); + + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const el = triggerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const gap = 4; + const margin = 8; + const spaceBelow = window.innerHeight - r.bottom - margin; + const spaceAbove = r.top - margin; + // Flip above when there's more room there, so a trigger near the viewport + // bottom doesn't open a fixed menu that runs off-screen and can't scroll. + const below = spaceBelow >= spaceAbove; + const horizontal = + align === "end" + ? { right: window.innerWidth - r.right } + : { left: r.left }; + setPos({ + ...horizontal, + ...(below + ? { top: r.bottom + gap } + : { bottom: window.innerHeight - r.top + gap }), + maxHeight: Math.max(0, (below ? spaceBelow : spaceAbove) - gap), + }); + }; + place(); + // Track the trigger while scrolling/resizing (capture catches inner scrollers). + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open, align, triggerRef]); + + if (!open || !pos) return null; + const style: React.CSSProperties = { + position: "fixed", + // Explicit auto (not undefined) so the CSS fallback `top`/`left` can't leak + // in on the axis this placement isn't pinning. + top: pos.top ?? "auto", + bottom: pos.bottom ?? "auto", + left: pos.left ?? "auto", + right: pos.right ?? "auto", + maxHeight: pos.maxHeight, + overflowY: "auto", + ...(width !== undefined ? { minWidth: typeof width === "number" ? `${width}px` : width } - : undefined; - return ( + : {}), + }; + return createPortal( +
, + document.body, ); } diff --git a/frontend/editor/src/core/ui/EmptyState.css b/frontend/editor/src/core/ui/EmptyState.css index bf8250bc82..bd3d7cf03e 100644 --- a/frontend/editor/src/core/ui/EmptyState.css +++ b/frontend/editor/src/core/ui/EmptyState.css @@ -22,7 +22,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--c-primary); + color: var(--c-accent-text); } .sui-empty__title { diff --git a/frontend/editor/src/core/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css index f5cbe2d5fc..792845c255 100644 --- a/frontend/editor/src/core/ui/FormField.css +++ b/frontend/editor/src/core/ui/FormField.css @@ -14,7 +14,9 @@ } .sui-field__required { - color: var(--color-red); + /* The base red is a fill colour; as text on the form background it only + reaches 3.4:1. */ + color: var(--color-red-dark); } .sui-field__control { @@ -29,5 +31,5 @@ } .sui-field--error .sui-field__help { - color: var(--color-red); + color: var(--color-red-dark); } diff --git a/frontend/editor/src/core/ui/Forms.stories.tsx b/frontend/editor/src/core/ui/Forms.stories.tsx index 0d14cf3392..dff72b6da7 100644 --- a/frontend/editor/src/core/ui/Forms.stories.tsx +++ b/frontend/editor/src/core/ui/Forms.stories.tsx @@ -182,6 +182,7 @@ export const Slider_Confidence: Story = { step={0.01} onChange={setV} formatValue={(x) => x.toFixed(2)} + aria-label="Minimum confidence" /> ); @@ -203,6 +204,7 @@ export const Slider_Retention: Story = { step={1} onChange={setDays} formatValue={(d) => `${d} days`} + aria-label="Retain artifacts for" /> ); @@ -262,6 +264,7 @@ export const FullForm: Story = { step={0.01} onChange={setConf} formatValue={(v) => v.toFixed(2)} + aria-label="Confidence gate" /> diff --git a/frontend/editor/src/core/ui/ListRow.css b/frontend/editor/src/core/ui/ListRow.css index 836dba4d8f..019b679f5c 100644 --- a/frontend/editor/src/core/ui/ListRow.css +++ b/frontend/editor/src/core/ui/ListRow.css @@ -36,23 +36,23 @@ background: var(--c-surface-sunken); } .sui-listrow__leading[data-tone="success"] { - color: var(--color-green); + color: var(--color-green-dark); background: color-mix(in srgb, var(--color-green) 14%, transparent); } .sui-listrow__leading[data-tone="warning"] { - color: var(--color-amber); + color: var(--color-amber-dark); background: color-mix(in srgb, var(--color-amber) 14%, transparent); } .sui-listrow__leading[data-tone="danger"] { - color: var(--color-red); + color: var(--color-red-dark); background: color-mix(in srgb, var(--color-red) 14%, transparent); } .sui-listrow__leading[data-tone="info"] { - color: var(--c-primary); + color: var(--c-accent-text); background: color-mix(in srgb, var(--c-primary) 14%, transparent); } .sui-listrow__leading[data-tone="purple"] { - color: var(--color-purple); + color: var(--color-purple-dark); background: color-mix(in srgb, var(--color-purple) 14%, transparent); } diff --git a/frontend/editor/src/core/ui/MantineForms.css b/frontend/editor/src/core/ui/MantineForms.css index ebedf547f7..29cd64413d 100644 --- a/frontend/editor/src/core/ui/MantineForms.css +++ b/frontend/editor/src/core/ui/MantineForms.css @@ -55,7 +55,7 @@ /* Pills match SUI's Chip component: small rounded tags. */ .sui-mantine-pill { background: var(--c-primary-tint) !important; - color: var(--c-primary-hover) !important; + color: var(--c-accent-text) !important; border: 1px solid var(--c-primary-border) !important; border-radius: var(--radius-sm) !important; font-size: 0.75rem !important; diff --git a/frontend/editor/src/core/ui/MantineForms.stories.tsx b/frontend/editor/src/core/ui/MantineForms.stories.tsx index 08e2b2e179..e8860c1f30 100644 --- a/frontend/editor/src/core/ui/MantineForms.stories.tsx +++ b/frontend/editor/src/core/ui/MantineForms.stories.tsx @@ -446,6 +446,7 @@ export const Slider_Default: Story = { max={1} step={0.01} formatValue={(x) => x.toFixed(2)} + aria-label="Confidence threshold" /> ); @@ -467,6 +468,7 @@ export const Slider_WithMarks: Story = { max={365} step={1} formatValue={(d) => `${d}d`} + aria-label="Retain artifacts for" marks={[ { value: 30, label: "30d" }, { value: 90, label: "90d" }, @@ -494,6 +496,7 @@ export const Slider_NoLabel: Story = { max={100} step={1} showValue={false} + aria-label="Opacity" /> ); diff --git a/frontend/editor/src/core/ui/MethodBadge.css b/frontend/editor/src/core/ui/MethodBadge.css index 9b138ed389..1cc89c02a4 100644 --- a/frontend/editor/src/core/ui/MethodBadge.css +++ b/frontend/editor/src/core/ui/MethodBadge.css @@ -15,7 +15,7 @@ border-color: var(--color-green-border); } .sui-method--post { - color: var(--c-primary); + color: var(--c-accent-text); background: var(--c-primary-tint); border-color: var(--c-primary-border); } diff --git a/frontend/editor/src/core/ui/MetricCard.css b/frontend/editor/src/core/ui/MetricCard.css index 8c7adfeb00..ae61a7abd1 100644 --- a/frontend/editor/src/core/ui/MetricCard.css +++ b/frontend/editor/src/core/ui/MetricCard.css @@ -3,12 +3,7 @@ flex-direction: column; gap: 0.5rem; padding: 1.125rem 1.25rem; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-md); transition: - box-shadow var(--motion-fast), transform var(--motion-fast), border-color var(--motion-fast); min-width: 10rem; @@ -37,7 +32,6 @@ } .sui-metric--interactive:hover { border-color: var(--c-border-strong); - box-shadow: var(--shadow-lg); transform: translateY(-0.0625rem); } .sui-metric--interactive:focus-visible { @@ -83,10 +77,10 @@ font-size: 0.6875rem; } .sui-metric__delta--up { - color: var(--color-green); + color: var(--color-green-dark); } .sui-metric__delta--down { - color: var(--color-red); + color: var(--color-red-dark); } .sui-metric__delta--flat, .sui-metric__desc { diff --git a/frontend/editor/src/core/ui/MetricCard.tsx b/frontend/editor/src/core/ui/MetricCard.tsx index cd61580095..651a7d4065 100644 --- a/frontend/editor/src/core/ui/MetricCard.tsx +++ b/frontend/editor/src/core/ui/MetricCard.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import "@app/ui/Surface.css"; import "@app/ui/MetricCard.css"; export type DeltaDirection = "up" | "down" | "flat"; @@ -52,6 +53,7 @@ export function MetricCard({ const interactive = !!onClick; const classes = [ + "sui-surface", "sui-metric", `sui-metric--${size}`, emphasis === "primary" ? "sui-metric--primary" : "", diff --git a/frontend/editor/src/core/ui/MetricStrip.css b/frontend/editor/src/core/ui/MetricStrip.css index 8344fe1393..6497d26684 100644 --- a/frontend/editor/src/core/ui/MetricStrip.css +++ b/frontend/editor/src/core/ui/MetricStrip.css @@ -21,10 +21,6 @@ align-items: center; gap: 1.75rem; padding: 0.625rem 1.25rem; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); } .sui-metric-strip__leading { diff --git a/frontend/editor/src/core/ui/MetricStrip.tsx b/frontend/editor/src/core/ui/MetricStrip.tsx index b9e2a03bb3..ded2db624a 100644 --- a/frontend/editor/src/core/ui/MetricStrip.tsx +++ b/frontend/editor/src/core/ui/MetricStrip.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import "@app/ui/Surface.css"; import "@app/ui/MetricStrip.css"; export interface MetricStripProps { @@ -21,6 +22,7 @@ export function MetricStrip({ className, }: MetricStripProps) { const classes = [ + layout === "row" ? "sui-surface" : "", "sui-metric-strip", `sui-metric-strip--${layout}`, className ?? "", diff --git a/frontend/editor/src/core/ui/NavItem.css b/frontend/editor/src/core/ui/NavItem.css index 8b9949eab5..9fd39f3eff 100644 --- a/frontend/editor/src/core/ui/NavItem.css +++ b/frontend/editor/src/core/ui/NavItem.css @@ -20,7 +20,7 @@ } .sui-navitem.is-active { background: var(--c-primary-subtle); - color: var(--c-accent-fg); + color: var(--c-accent-text); font-weight: 500; } .sui-navitem.is-active:hover { @@ -76,17 +76,17 @@ background: var(--color-red); } .sui-navitem[data-accent="blue"] .sui-navitem__icon { - color: var(--c-primary); + color: var(--c-accent-text); } .sui-navitem[data-accent="purple"] .sui-navitem__icon { - color: var(--color-purple); + color: var(--color-purple-dark); } .sui-navitem[data-accent="green"] .sui-navitem__icon { - color: var(--color-green); + color: var(--color-green-dark); } .sui-navitem[data-accent="amber"] .sui-navitem__icon { - color: var(--color-amber); + color: var(--color-amber-dark); } .sui-navitem[data-accent="red"] .sui-navitem__icon { - color: var(--color-red); + color: var(--color-red-dark); } diff --git a/frontend/editor/src/core/ui/NodeCard.css b/frontend/editor/src/core/ui/NodeCard.css index 7982e2581a..a7a6d8e781 100644 --- a/frontend/editor/src/core/ui/NodeCard.css +++ b/frontend/editor/src/core/ui/NodeCard.css @@ -8,10 +8,6 @@ display: flex; align-items: stretch; box-sizing: border-box; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); transition: border-color var(--motion-fast), box-shadow var(--motion-fast), diff --git a/frontend/editor/src/core/ui/NodeCard.tsx b/frontend/editor/src/core/ui/NodeCard.tsx index 451cc8823b..dd8471fae1 100644 --- a/frontend/editor/src/core/ui/NodeCard.tsx +++ b/frontend/editor/src/core/ui/NodeCard.tsx @@ -1,6 +1,7 @@ import type { HTMLAttributes, MouseEvent, ReactNode, Ref } from "react"; import { Button } from "@app/ui/Button"; import { IconBadge, type IconBadgeAccent } from "@app/ui/IconBadge"; +import "@app/ui/Surface.css"; import "@app/ui/NodeCard.css"; /** Border tone. `selected` (a separate prop) overrides this with the primary ring. */ @@ -53,6 +54,7 @@ export function NodeCard({ {...rest} ref={ref} className={[ + "sui-surface", "sui-node-card", tone === "warning" ? "sui-node-card--warning" : "", selected ? "is-selected" : "", diff --git a/frontend/editor/src/core/ui/PanelHeader.css b/frontend/editor/src/core/ui/PanelHeader.css index 02f32c3707..f8dafd2303 100644 --- a/frontend/editor/src/core/ui/PanelHeader.css +++ b/frontend/editor/src/core/ui/PanelHeader.css @@ -46,7 +46,7 @@ button.sui-panelhdr__bar:hover { height: 1.75rem; border-radius: 9999px; background: var(--mantine-color-blue-light); - color: var(--mantine-color-blue-filled); + color: var(--c-accent-text); flex-shrink: 0; } @@ -154,15 +154,15 @@ button.sui-panelhdr__bar:hover { var(--mantine-color-blue-filled) 18%, transparent ); - color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled)); + color: var(--c-accent-text); } /* Dark mode: the subtle gray close button is too dim against the dark rail — brighten it to a clearly-visible light grey (near-white on hover). */ [data-mantine-color-scheme="dark"] .sui-panelhdr__close { - color: var(--mantine-color-gray-4); + color: var(--c-text-subtle); } [data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover { - color: var(--mantine-color-gray-2); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/ProgressBar.stories.tsx b/frontend/editor/src/core/ui/ProgressBar.stories.tsx index 7c8b386dda..8c88bc039a 100644 --- a/frontend/editor/src/core/ui/ProgressBar.stories.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.stories.tsx @@ -6,7 +6,12 @@ const meta: Meta = { component: ProgressBar, tags: ["autodocs"], parameters: { layout: "padded" }, - args: { value: 0.5, height: 6, thresholded: false }, + args: { + value: 0.5, + height: 6, + thresholded: false, + label: "Docs processed", + }, argTypes: { value: { control: { type: "range", min: 0, max: 1, step: 0.01 } }, height: { control: { type: "number" } }, @@ -42,7 +47,7 @@ export const ThresholdLadder: Story = { {Math.round(v * 100)}% - +
))}
diff --git a/frontend/editor/src/core/ui/ProgressBar.tsx b/frontend/editor/src/core/ui/ProgressBar.tsx index e6ca30c0e1..cb11a2982a 100644 --- a/frontend/editor/src/core/ui/ProgressBar.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.tsx @@ -10,8 +10,9 @@ export interface ProgressBarProps { /** Optional override colour (CSS gradient or solid). Disables threshold behaviour. */ color?: string; className?: string; - /** Accessible label for screen readers. */ - label?: string; + /** Accessible name — describe what is being measured ("Storage used"), since + * the bar carries no visible text of its own. */ + label: string; } function clamp01(n: number) { diff --git a/frontend/editor/src/core/ui/Select.tsx b/frontend/editor/src/core/ui/Select.tsx index 242ddf9740..c4861a7752 100644 --- a/frontend/editor/src/core/ui/Select.tsx +++ b/frontend/editor/src/core/ui/Select.tsx @@ -46,6 +46,7 @@ export interface SelectProps { id?: string; name?: string; "aria-label"?: string; + "aria-labelledby"?: string; "aria-invalid"?: boolean; "aria-describedby"?: string; required?: boolean; @@ -74,6 +75,7 @@ type PassthroughProps = Omit< | "id" | "name" | "aria-label" + | "aria-labelledby" | "aria-describedby" | "required" | "disabled" @@ -106,6 +108,7 @@ export function Select({ id, name, "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, required, @@ -128,6 +131,7 @@ export function Select({ id, name, "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, required, disabled, diff --git a/frontend/editor/src/core/ui/SettingsRow.tsx b/frontend/editor/src/core/ui/SettingsRow.tsx index 65f48cddc8..8dbf9600ab 100644 --- a/frontend/editor/src/core/ui/SettingsRow.tsx +++ b/frontend/editor/src/core/ui/SettingsRow.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { cloneElement, isValidElement, useId, type ReactNode } from "react"; import "@app/ui/SettingsRow.css"; export interface SettingsRowProps { @@ -23,17 +23,31 @@ export function SettingsRow({ control, className, }: SettingsRowProps) { + const labelId = useId(); + // The row's label is plain text beside the control, not a
{Array.from({ length: total }, (_, i) => { const step = i + 1; diff --git a/frontend/editor/src/core/ui/Surface.css b/frontend/editor/src/core/ui/Surface.css new file mode 100644 index 0000000000..93b7c104c1 --- /dev/null +++ b/frontend/editor/src/core/ui/Surface.css @@ -0,0 +1,20 @@ +/* Duplicates `.sui-nav-surface` on purpose: nav chrome may diverge from in-page surfaces. */ +.sui-surface { + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-nav); +} + +:root { + --sui-elevation-raised: + 0 1px 2px rgba(0, 0, 0, 0.04), 0 2px 6px rgba(0, 0, 0, 0.06); +} + +[data-theme="dark"] { + --sui-elevation-raised: + 0 1px 2px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.35); +} + +.sui-surface--raised { + box-shadow: var(--sui-elevation-raised); +} diff --git a/frontend/editor/src/core/ui/Surface.stories.tsx b/frontend/editor/src/core/ui/Surface.stories.tsx new file mode 100644 index 0000000000..5f9afb09e9 --- /dev/null +++ b/frontend/editor/src/core/ui/Surface.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Surface } from "@app/ui/Surface"; + +const meta: Meta = { + title: "Primitives/Surface", + component: Surface, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** The box any in-page panel, tile or table container sits in. */ +export const Default: Story = { + args: { + children: ( +
+
Panel title
+
+ Same fill, border and radius on every surface. +
+
+ ), + }, +}; + +/** `as` renders a landmark element instead of a div. */ +export const AsSection: Story = { + args: { + as: "section", + "aria-label": "Usage", + children: ( +
+ + Any content, not just cards. + +
+ ), + }, +}; diff --git a/frontend/editor/src/core/ui/Surface.tsx b/frontend/editor/src/core/ui/Surface.tsx new file mode 100644 index 0000000000..bbb3be1d7a --- /dev/null +++ b/frontend/editor/src/core/ui/Surface.tsx @@ -0,0 +1,33 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import "@app/ui/Surface.css"; + +export interface SurfaceProps extends HTMLAttributes { + /** Element to render; `section`/`article`/`aside` when the box is a landmark. */ + as?: "div" | "section" | "article" | "aside"; + /** For a surface in front of another surface: same fill, one step of elevation. */ + raised?: boolean; +} + +/** The standard in-page surface: fill, hairline border, shared radius, no shadow. */ +export const Surface = forwardRef( + function Surface( + { as: Component = "div", raised = false, className, children, ...rest }, + ref, + ) { + return ( + + {children} + + ); + }, +); diff --git a/frontend/editor/src/core/ui/Table.css b/frontend/editor/src/core/ui/Table.css deleted file mode 100644 index 3d759c1fcd..0000000000 --- a/frontend/editor/src/core/ui/Table.css +++ /dev/null @@ -1,76 +0,0 @@ -.sui-table-wrap { - width: 100%; - overflow-x: auto; - border: 1px solid var(--c-border); - border-radius: var(--radius-md); - background: var(--c-surface); -} - -.sui-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8125rem; -} - -/* Header text kept for assistive tech only, so a column of controls can be named - without putting a heading above it. Defined here rather than borrowing a global - utility, since the portal loads its own stylesheet. */ -.sui-table__th-sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - -.sui-table__th { - text-align: left; - font-weight: 600; - color: var(--c-text-subtle); - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0.625rem 0.875rem; - border-bottom: 1px solid var(--c-border); - white-space: nowrap; -} -.sui-table__th--right, -.sui-table__td--right { - text-align: right; -} -.sui-table__th--center, -.sui-table__td--center { - text-align: center; -} - -.sui-table__td { - padding: 0.625rem 0.875rem; - color: var(--c-text-muted); - border-bottom: 1px solid var(--c-border-subtle); - vertical-align: middle; -} -.sui-table tbody tr:last-child .sui-table__td { - border-bottom: none; -} - -.sui-table__row--interactive { - cursor: pointer; - transition: background var(--motion-fast); -} -.sui-table__row--interactive:hover { - background: var(--c-hover); -} -.sui-table__row--interactive:focus-visible { - outline: 0.125rem solid var(--c-primary); - outline-offset: -0.125rem; -} - -.sui-table__empty { - padding: 2rem; - text-align: center; - color: var(--c-text-subtle); -} diff --git a/frontend/editor/src/core/ui/Table.stories.tsx b/frontend/editor/src/core/ui/Table.stories.tsx deleted file mode 100644 index 2e7e0f64f1..0000000000 --- a/frontend/editor/src/core/ui/Table.stories.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Table, type TableColumn } from "@app/ui/Table"; -import { StatusBadge } from "@app/ui/StatusBadge"; - -interface Region { - id: string; - name: string; - code: string; - status: "healthy" | "degraded"; - docs: number; - latency: string; -} - -const REGIONS: Region[] = [ - { - id: "1", - name: "US East", - code: "us-east-1", - status: "healthy", - docs: 12481, - latency: "41 ms", - }, - { - id: "2", - name: "US West", - code: "us-west-2", - status: "healthy", - docs: 8210, - latency: "63 ms", - }, - { - id: "3", - name: "EU West", - code: "eu-west-1", - status: "degraded", - docs: 3044, - latency: "190 ms", - }, -]; - -const COLUMNS: TableColumn[] = [ - { key: "name", header: "Region", render: (r) => r.name }, - { - key: "code", - header: "Code", - render: (r) => ( - {r.code} - ), - }, - { - key: "status", - header: "Status", - render: (r) => ( - - {r.status} - - ), - }, - { - key: "docs", - header: "Docs 24h", - align: "right", - render: (r) => r.docs.toLocaleString(), - }, - { key: "latency", header: "P95", align: "right", render: (r) => r.latency }, -]; - -const meta: Meta = { - title: "Compound/Table", - component: Table, - tags: ["autodocs"], - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */ -export const Basic: Story = { - render: () => ( - columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> - ), -}; - -/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */ -export const Interactive: Story = { - render: () => ( - - columns={COLUMNS} - rows={REGIONS} - rowKey={(r) => r.id} - onRowClick={() => {}} - /> - ), -}; - -/** Empty body slot. */ -export const Empty: Story = { - render: () => ( - - columns={COLUMNS} - rows={[]} - rowKey={(r) => r.id} - empty="No regions deployed yet." - /> - ), -}; diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx deleted file mode 100644 index 277362eab8..0000000000 --- a/frontend/editor/src/core/ui/Table.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { ReactNode } from "react"; -import "@app/ui/Table.css"; - -export interface TableColumn { - /** Stable column id. */ - key: string; - header: ReactNode; - /** - * Hides the header visually but keeps it for assistive tech. For a trailing column of controls - * or chevrons, where a visible heading would be noise but a blank one leaves the cells below it - * unlabelled. - */ - headerHidden?: boolean; - /** Cell renderer for a row. */ - render: (row: T) => ReactNode; - align?: "left" | "right" | "center"; - /** Optional fixed/min width (any CSS length). */ - width?: string; -} - -export interface TableProps { - columns: TableColumn[]; - rows: T[]; - /** Stable key per row. */ - rowKey: (row: T) => string; - /** Makes rows interactive (hover + click + keyboard). */ - onRowClick?: (row: T) => void; - /** - * Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which - * this returns false is inert: no click/keyboard, and not announced as a button. Defaults to - * all rows interactive. - */ - isRowInteractive?: (row: T) => boolean; - /** - * Set when rows render controls of their own. The row keeps its click as a mouse shortcut but - * stops announcing itself as a button, because a button may not contain other controls and a - * {@code } is no longer a row to a screen reader. That row control is then the - * keyboard path to the same action, so nothing is lost by leaving the row itself inert. - */ - rowsContainControls?: boolean; - /** Rendered in place of the body when there are no rows. */ - empty?: ReactNode; - className?: string; -} - -/** - * Minimal data table primitive. Columns own their own cell renderers, so the - * table stays presentational — callers pre-sort/filter and pass the rows they - * want shown. Rows become focusable buttons-in-disguise when `onRowClick` is - * set. - */ -export function Table({ - columns, - rows, - rowKey, - onRowClick, - isRowInteractive, - rowsContainControls = false, - empty, - className, -}: TableProps) { - const interactive = Boolean(onRowClick); - return ( -
- - - - {columns.map((c) => ( - - ))} - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.map((row) => { - const rowInteractive = - interactive && (isRowInteractive?.(row) ?? true); - // Only a row that owns the whole interaction takes the button role and the keyboard - // handling that goes with it; see rowsContainControls. - const rowIsControl = rowInteractive && !rowsContainControls; - return ( - onRowClick?.(row) : undefined} - tabIndex={rowIsControl ? 0 : undefined} - role={rowIsControl ? "button" : undefined} - onKeyDown={ - rowIsControl - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick?.(row); - } - } - : undefined - } - > - {columns.map((c) => ( - - ))} - - ); - }) - )} - -
- {c.headerHidden ? ( - {c.header} - ) : ( - c.header - )} -
- {empty ?? "No data"} -
- {c.render(row)} -
-
- ); -} diff --git a/frontend/editor/src/core/ui/Tabs.css b/frontend/editor/src/core/ui/Tabs.css index 9fa18ce5b4..65501a4d21 100644 --- a/frontend/editor/src/core/ui/Tabs.css +++ b/frontend/editor/src/core/ui/Tabs.css @@ -42,7 +42,7 @@ border-radius: var(--radius-pill); } .sui-tabs--pill .sui-tabs__tab.is-active { - color: var(--sui-tab-accent, var(--c-primary)); + color: var(--sui-tab-accent, var(--c-accent-text)); background: var(--c-primary-tint); border-color: var(--sui-tab-accent, var(--c-primary-border)); font-weight: 500; @@ -56,7 +56,7 @@ margin-bottom: -1px; } .sui-tabs--underline .sui-tabs__tab.is-active { - color: var(--sui-tab-accent, var(--c-primary)); + color: var(--sui-tab-accent, var(--c-accent-text)); border-bottom-color: var(--sui-tab-accent, var(--c-primary)); font-weight: 500; } diff --git a/frontend/editor/src/core/ui/Tabs.tsx b/frontend/editor/src/core/ui/Tabs.tsx index 5c91934c77..2cd3b8193a 100644 --- a/frontend/editor/src/core/ui/Tabs.tsx +++ b/frontend/editor/src/core/ui/Tabs.tsx @@ -50,7 +50,7 @@ export function Tabs({ aria-label={ariaLabel} > {items.map((item) => { - const isActive = item.key === activeKey; + const isActive = item.key === activeKey && !item.disabled; const styleVars = isActive && item.accentColor ? ({ "--sui-tab-accent": item.accentColor } as React.CSSProperties) @@ -61,13 +61,17 @@ export function Tabs({ type="button" aria-pressed={isActive} disabled={item.disabled} + aria-disabled={item.disabled || undefined} className={ "sui-tabs__tab" + (isActive ? " is-active" : "") + (item.disabled ? " is-disabled" : "") } style={styleVars} - onClick={() => onChange(item.key)} + onClick={() => { + if (item.disabled) return; + onChange(item.key); + }} > {item.dotColor && ( void; /** Accessible label associated to the control. */ label?: string; + /** Names the switch from text rendered elsewhere (e.g. a SettingsRow label). */ + "aria-labelledby"?: string; /** Optional helper text rendered next to the label. */ description?: string; disabled?: boolean; @@ -23,6 +25,7 @@ export function ToggleSwitch({ checked, onChange, label, + "aria-labelledby": ariaLabelledBy, description, disabled, size = "md", @@ -39,6 +42,7 @@ export function ToggleSwitch({ id={controlId} type="checkbox" role="switch" + aria-labelledby={ariaLabelledBy} checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css index cf83edacaf..b5b530ee6c 100644 --- a/frontend/editor/src/core/ui/accents.css +++ b/frontend/editor/src/core/ui/accents.css @@ -9,7 +9,8 @@ var(--c-btn-inverse) ); --_on: var(--c-btn-inverse); - --_text: var(--c-primary-hover); + /* Deeper than --c-primary-hover, which lands at 4.28:1 on the accent tint. */ + --_text: var(--c-accent-text); --_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface)); --_tint: color-mix(in srgb, var(--c-primary) 12%, transparent); --_solid-2: var(--c-btn-secondary); @@ -26,24 +27,33 @@ /* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the fill and the outline/text are the SAME red in both light and dark. */ .sui-acc-danger { - --_solid: var(--p-red-500); - --_solid-hover: var(--p-red-600); + /* One step deeper than the base red so the white label on the filled + variant clears 4.5:1. */ + --_solid: var(--p-red-600); + --_solid-hover: var(--p-red-700); --_on: #ffffff; - --_text: var(--p-red-500); + /* Theme-aware like the other accents: the mid red only clears 3.6:1 as text + on a light surface, so light mode needs the deeper shade while dark mode + needs the lighter one. */ + --_text: var(--color-red-dark); --_bd: color-mix(in srgb, var(--p-red-500) 45%, transparent); --_tint: color-mix(in srgb, var(--p-red-500) 12%, transparent); } .sui-acc-success { - --_solid: var(--color-green); - --_solid-hover: var(--color-green-dark); + /* The base green is a tint/border colour; as a fill behind white text it + only reaches 2.3:1, so the solid variant uses the deeper shade. */ + --_solid: var(--p-green-700); + --_solid-hover: var(--p-green-800); --_on: #ffffff; --_text: var(--color-green-dark); --_bd: var(--color-green-border); --_tint: color-mix(in srgb, var(--color-green) 12%, transparent); } .sui-acc-warning { - --_solid: var(--color-amber); - --_solid-hover: var(--color-amber-dark); + /* Deepened so the white label clears 4.5:1, matching how danger and success + resolve the same problem — every filled accent reads white-on-colour. */ + --_solid: var(--p-amber-700); + --_solid-hover: var(--p-amber-800); --_on: #ffffff; --_text: var(--color-amber-dark); --_bd: var(--color-amber-border); @@ -106,7 +116,9 @@ var(--p-cyan-400) 100% ); --_on: #ffffff; - --_text: var(--p-indigo-500); + /* The gradient's mid indigo is a fill colour; as text on the light surface + it only reaches 4.1:1 (3.6:1 on its own tint). */ + --_text: var(--p-indigo-600); --_bd: var(--p-indigo-200); --_tint: color-mix(in srgb, var(--p-indigo-500) 10%, transparent); } diff --git a/frontend/editor/src/core/ui/dataTableColumns.tsx b/frontend/editor/src/core/ui/dataTableColumns.tsx new file mode 100644 index 0000000000..df31dc384a --- /dev/null +++ b/frontend/editor/src/core/ui/dataTableColumns.tsx @@ -0,0 +1,569 @@ +import { Fragment, type ReactNode } from "react"; +import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge"; +import { Chip, type ChipAccent } from "@app/ui/Chip"; +import { Button } from "@app/ui/Button"; +import { Dropdown } from "@app/ui/Dropdown"; +import { ProgressBar } from "@app/ui/ProgressBar"; +import { Select, type SelectOption } from "@app/ui/Select"; + +/** + * The column vocabulary for {@link DataTable}. Call-sites pick a cell KIND and + * supply the data + semantics; the component owns 100% of the appearance. There + * is no raw-JSX / className escape hatch by design; a cell can only look the way + * the design system draws its kind, so every table looks and behaves the same. + */ + +type Align = "left" | "right"; +type SortValue = string | number | boolean | null | undefined; + +/** + * Which built-in comparator sorts a column. Set by the builder from the cell's + * data type - `alphanumeric` (case-insensitive, natural: `v2` before `v10`) for + * text, `basic` (raw numeric) for numbers. Call-sites never choose this. + */ +export type DataTableSortFn = "alphanumeric" | "basic"; + +/** Opaque, fully-resolved column. Produced only by the {@link column} builders. */ +export interface DataTableColumn { + key: string; + header: ReactNode; + align: Align; + /** Prevent wrapping (mono/number values). */ + nowrap: boolean; + /** Shrink the column to its content (actions / affordances). */ + fit: boolean; + sortable: boolean; + sortValue?: (row: T) => SortValue; + /** Comparator kind, derived from the cell type. Only set when sortable. */ + sortFn?: DataTableSortFn; + /** Cell renders its own interactive control (button/link/select/chip). Rows + * containing one drop their `role="button"` so a button never nests inside a + * button - the control is the keyboard path instead. */ + interactive?: boolean; + /** Internal, design-system-owned renderer. Call-sites never supply this. */ + renderCell: (row: T) => ReactNode; +} + +/** The only design-system glyph a cell may use (icon-only actions). */ +export type CellGlyph = "kebab"; + +function KebabGlyph() { + return ( + + + + + + ); +} + +/** An item in a kebab action menu. */ +export interface CellMenuItem { + label: string; + tone?: "default" | "danger"; + disabled?: boolean; + onClick: () => void; + /** Draw a divider above this item. */ + dividerBefore?: boolean; +} + +/** A row/group action. A locked button, or a kebab menu when `menu` is set. */ +export interface CellAction { + label: string; + glyph?: CellGlyph; + /** Icon-only (uses `label` as the accessible name). */ + iconOnly?: boolean; + tone?: "default" | "danger"; + onClick?: () => void; + loading?: boolean; + disabled?: boolean; + /** When set, the button opens this menu instead of firing `onClick`. */ + menu?: CellMenuItem[]; +} + +/** Renders a row of locked action buttons / kebab menus. Shared by the + * `actions` cell kind and grouped-table headers. */ +export function renderCellActions(actions: CellAction[]): ReactNode { + return ( +
e.stopPropagation()}> + {actions.map((a) => + a.menu ? ( + + + + + + {a.menu.map((m) => ( + + {m.dividerBefore && } + + {m.label} + + + ))} + + + ) : ( + + ), + )} +
+ ); +} + +/** An external link inside a cell. */ +export interface CellLink { + label: string; + href: string; + ariaLabel?: string; +} + +interface Common { + key: string; + header: ReactNode; + sortable?: boolean; +} + +function base( + o: Common, + extra: Pick, "align" | "nowrap" | "fit" | "renderCell"> & { + sortValue?: (row: T) => SortValue; + sortFn?: DataTableSortFn; + interactive?: boolean; + }, +): DataTableColumn { + return { + key: o.key, + header: o.header, + align: extra.align, + nowrap: extra.nowrap, + fit: extra.fit, + sortable: !!o.sortable, + sortValue: o.sortable ? extra.sortValue : undefined, + sortFn: o.sortable ? extra.sortFn : undefined, + interactive: extra.interactive, + renderCell: extra.renderCell, + }; +} + +function text( + o: Common & { + get: (row: T) => string; + /** Optional bold label rendered before the value as "Label: value". */ + label?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const label = o.label?.(r); + return label ? ( + + {label}: {o.get(r)} + + ) : ( + {o.get(r)} + ); + }, + }); +} + +function mono( + o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => {o.get(r)}, + }); +} + +function muted( + o: Common & { + get: (row: T) => string | null | undefined; + placeholder?: string; + /** Override the sort key (e.g. an ISO date behind a "3 days ago" label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "alphanumeric", + renderCell: (r) => ( + + {o.get(r) || (o.placeholder ?? "-")} + + ), + }); +} + +function number( + o: Common & { + get: (row: T) => number | null | undefined; + format?: (n: number, row: T) => string; + placeholder?: string; + /** Override the sort key (e.g. a raw count behind a formatted label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "right", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "basic", + renderCell: (r) => { + const n = o.get(r); + if (n == null) { + return ( + + {o.placeholder ?? "-"} + + ); + } + return ( + + {o.format ? o.format(n, r) : String(n)} + + ); + }, + }); +} + +function badge( + o: Common & { + get: (row: T) => { tone: StatusTone; label: string }; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r).label), + sortFn: "alphanumeric", + renderCell: (r) => { + const b = o.get(r); + return ( + + {b.label} + + ); + }, + }); +} + +/** + * A user-defined label, rendered as a dot-less pill. Use this ONLY for labels + * that come from data / the user (e.g. a document's classification). Values from + * a fixed set we define (types, environments, providers) are `text`, not pills. + */ +export interface CellLabel { + label: string; + accent?: ChipAccent; +} + +function labels( + o: Common & { get: (row: T) => CellLabel[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: (r) => o.get(r)[0]?.label ?? undefined, + sortFn: "alphanumeric", + renderCell: (r) => ( +
+ {o.get(r).map((l) => ( + + {l.label} + + ))} +
+ ), + }); +} + +/** + * An interactive capability chip: click to grant, remove to revoke, dashed to + * offer adding. A functional cell (it toggles state), distinct from static + * `labels`. + */ +export interface CellCap { + label: string; + accent?: ChipAccent; + onClick?: () => void; + onRemove?: () => void; + dashed?: boolean; +} + +function caps( + o: Common & { get: (row: T) => CellCap[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + interactive: true, + renderCell: (r) => ( +
+ {o.get(r).map((c) => ( + + {c.label} + + ))} +
+ ), + }); +} + +function entity( + o: Common & { + /** Semantic leading icon (component owns its size + colour container). */ + icon?: (row: T) => ReactNode; + primary: (row: T) => string; + /** Muted inline suffix after the name, its own node (e.g. "(you)"). */ + suffix?: (row: T) => string | null | undefined; + /** Secondary muted line under the name. */ + note?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.primary(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const icon = o.icon?.(r); + const suffix = o.suffix?.(r); + const note = o.note?.(r); + return ( +
+ {icon != null && ( + + {icon} + + )} +
+ + {o.primary(r)} + {suffix && ( + {suffix} + )} + + {note && {note}} +
+
+ ); + }, + }); +} + +function actions(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellAction[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => renderCellActions(o.get(r)), + }; +} + +function progress( + o: Common & { + get: (row: T) => { value: number; label?: string }; + /** Accessible name for the bar (it has no visible text). Defaults to the + * shown percent; pass a description like "Load for us-east-1" when useful. */ + ariaLabel?: (row: T) => string; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: (r) => o.get(r).value, + sortFn: "basic", + renderCell: (r) => { + const p = o.get(r); + const shown = p.label ?? `${Math.round(p.value * 100)}%`; + return ( +
+ + + + {shown} +
+ ); + }, + }); +} + +function links(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellLink[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => ( +
+ ), + }; +} + +function select(o: { + key: string; + header: ReactNode; + get: (row: T) => { + value?: string | null; + defaultValue?: string; + options: SelectOption[]; + ariaLabel?: string; + disabled?: boolean; + }; + /** Omit for an uncontrolled select (local UI state only). */ + onChange?: (row: T, value: string | null) => void; +}): DataTableColumn { + return { + key: o.key, + header: o.header, + align: "left", + nowrap: true, + fit: false, + sortable: false, + interactive: true, + renderCell: (r) => { + const s = o.get(r); + const change = o.onChange; + return ( +
+ setQuery(e.target.value)} - placeholder={t("portal.search.placeholder")} - aria-label={t("portal.search.ariaLabel")} - className="portal-search__input" - autoComplete="off" - spellCheck={false} - /> - - ESC - -
- -
- {isLoading && ( -
- - - - -
- )} - {isEmpty && ( - - )} - {!isLoading && - !isEmpty && - Object.entries(groups).map(([group, items]) => ( -
-
{group}
- {items.map((item) => ( - - ))} -
- ))} -
-
- - ); -} 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 54a66a8e1a..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"; @@ -9,7 +13,8 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import { CloseIcon, SettingsIcon } from "@portal/components/icons"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, GROUP_PLATFORM, @@ -40,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. @@ -49,7 +57,7 @@ export function Sidebar() { // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) // needs a full page load. const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate("/"); + if (EDITOR_IS_SAME_APP) navigate(EDITOR_BASENAME); else window.location.href = EDITOR_URL; }; @@ -145,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 daa066bd70..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,74 +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: "", - 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 b70fbb5a26..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") @@ -72,6 +71,7 @@ export function PrepaidCapacityCard({ 0 ? wallet.pricePerDocMinor @@ -76,10 +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 cb94c2316f..c1b27720df 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -42,7 +42,7 @@ padding: 0.3rem 0.7rem; font-size: 0.8125rem; font-weight: 500; - color: var(--c-primary); + color: var(--c-accent-text); background: var(--color-bg-subtle); border: 1px solid var(--c-border); border-radius: 999px; @@ -119,7 +119,7 @@ /* Free grant still offsetting spend — reads as a positive credit note, pulled up under the "PDFs processed" line. */ .portal-billing__free-remaining { - color: var(--color-green); + color: var(--color-green-dark); margin-top: -0.5rem; } @@ -380,7 +380,7 @@ .portal-billing__plan-features li::before { content: "āœ“"; - color: var(--color-green); + color: var(--color-green-dark); position: absolute; left: 0; font-weight: 600; @@ -557,7 +557,7 @@ page; when the shared-component move lands, these dedupe to one stylesheet. */ .scc { --scc-accent: var(--c-primary); - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent); --scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent); --scc-chip-bg: var(--c-surface-sunken); @@ -572,7 +572,7 @@ margin-top: 0.75rem; } [data-theme="dark"] .scc { - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent); --scc-chip-bg: var(--c-surface-sunken); --scc-chip-border: var(--c-border); @@ -723,7 +723,7 @@ } .portal-billing__role-pill[data-leader="true"] { background: color-mix(in srgb, var(--c-primary) 12%, transparent); - color: var(--c-primary); + color: var(--c-accent-text); border: 1px solid color-mix(in srgb, var(--c-primary) 25%, transparent); } .portal-billing__role-pill[data-leader="false"] { @@ -754,10 +754,10 @@ margin-bottom: 8px; } .portal-billing__planhead-lbl--free { - color: var(--color-green); + color: var(--color-green-dark); } .portal-billing__planhead-lbl--meter { - color: var(--c-primary); + color: var(--c-accent-text); } .portal-billing__planhead-title { margin: 0; @@ -871,7 +871,7 @@ border-radius: 0.65rem; background: var(--color-bg-subtle); border: 1px solid var(--c-border); - color: var(--c-primary); + color: var(--c-accent-text); } /* Identity, stats, and the "Invite teammates" action all sit on one line; the metric cells render flat (no per-stat box) and are divided by hairlines, so @@ -1173,7 +1173,7 @@ .portal-billing__caplimit-chip[data-selected="true"] { background: color-mix(in srgb, var(--c-primary) 12%, transparent); border-color: var(--c-primary); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-billing__caplimit-chip:disabled { opacity: 0.45; @@ -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 { @@ -1370,7 +1365,7 @@ .portal-billing__bundle-row-change { font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); flex-shrink: 0; } .portal-billing__bundle-row-body { @@ -1410,7 +1405,7 @@ color: var(--c-text); } .portal-billing__bundle-card--active .portal-billing__bundle-card-title { - color: var(--c-primary); + color: var(--c-accent-text); } .portal-billing__bundle-card-meta { font-size: 0.6875rem; @@ -1419,7 +1414,7 @@ flex-shrink: 0; } .portal-billing__bundle-card--active .portal-billing__bundle-card-meta { - color: var(--c-primary); + color: var(--c-accent-text); } .portal-billing__bundle-card-desc { margin-top: 0.1875rem; @@ -1456,7 +1451,7 @@ color: var(--c-text); } .portal-billing__bundle-receipt-price { - color: var(--c-primary) !important; + color: var(--c-accent-text) !important; } .portal-billing__bundle-consent { padding-top: 0.75rem; @@ -1498,7 +1493,7 @@ font: inherit; font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-align: left; } .portal-billing__bundle-download-share { @@ -1555,7 +1550,7 @@ flex-shrink: 0; font-size: 11px; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); background: var(--c-surface); padding: 2px 8px; border-radius: 8px; 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) => ( -
+
1
-

{t("portal.docs.quickstart.step1.title")}

+

{t("portal.docs.quickstart.step1.title")}

{t("portal.docs.quickstart.step1.body")}

2
-

{t("portal.docs.quickstart.step2.title")}

+

{t("portal.docs.quickstart.step2.title")}

{t("portal.docs.quickstart.step2.body")}

3
-

{t("portal.docs.quickstart.step3.title")}

+

{t("portal.docs.quickstart.step3.title")}

{t("portal.docs.quickstart.step3.body")}

{playbooks.map((p) => ( -

{p.title}

+

{p.title}

{p.blurb}

{p.steps.map((step, i) => ( diff --git a/frontend/editor/src/portal/components/docs/SdksSection.tsx b/frontend/editor/src/portal/components/docs/SdksSection.tsx index 1a0e72933e..331117af68 100644 --- a/frontend/editor/src/portal/components/docs/SdksSection.tsx +++ b/frontend/editor/src/portal/components/docs/SdksSection.tsx @@ -32,7 +32,7 @@ export function SdksSection({ sdks }: { sdks: Sdk[] }) { {sdk.icon} -

{sdk.name}

+

{sdk.name}

{badge && ( {t(badge.labelKey)} diff --git a/frontend/editor/src/portal/components/docs/SkillsSection.tsx b/frontend/editor/src/portal/components/docs/SkillsSection.tsx index ef34705c43..e373fc25c3 100644 --- a/frontend/editor/src/portal/components/docs/SkillsSection.tsx +++ b/frontend/editor/src/portal/components/docs/SkillsSection.tsx @@ -19,7 +19,7 @@ export function SkillsSection({ skills }: { skills: AgentSkill[] }) { ✷ -

{s.name}

+

{s.name}

{s.blurb}

{s.ops} diff --git a/frontend/editor/src/portal/components/documents/DocumentExtractions.stories.tsx b/frontend/editor/src/portal/components/documents/DocumentExtractions.stories.tsx index d96ef55a1f..1f8f6691dd 100644 --- a/frontend/editor/src/portal/components/documents/DocumentExtractions.stories.tsx +++ b/frontend/editor/src/portal/components/documents/DocumentExtractions.stories.tsx @@ -1,11 +1,32 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Extraction } from "@portal/api/documents"; import { documentsFor } from "@portal/mocks/documents"; import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions"; import "@portal/views/Documents.css"; const ALL = documentsFor("enterprise"); -const NON_SENSITIVE = ALL.find((d) => !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 8e96e4d997..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx +++ /dev/null @@ -1,226 +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 6d097b33af..0000000000 --- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx +++ /dev/null @@ -1,267 +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 ( -