diff --git a/.dockerignore b/.dockerignore index 4ecd420aa0..85355537d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,8 +34,8 @@ frontend/playwright-report/ # Tauri/desktop builds src-tauri/target/ src-tauri/dist/ -frontend/src-tauri/target/ -frontend/src-tauri/dist/ +frontend/editor/src-tauri/target/ +frontend/editor/src-tauri/dist/ # IDE and editor .idea/ diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 8066cc3620..5343246c25 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -38,6 +38,8 @@ project: &project - frontend/** - docker/** - scripts/RestartHelper.java + - scripts/db-migration/** + - .github/workflows/db-migration-test.yml frontend: &frontend - frontend/** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e70e0365f..3ab69765a9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,6 +25,8 @@ updates: - "/docker/frontend" - "/docker/base" - "/docker/engine" + - "/docker/unoserver" + - "/engine" schedule: interval: "weekly" cooldown: diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 6fe125c376..9eb38bde3d 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -69,8 +69,8 @@ labels: - label: 'Tauri' files: - - 'frontend/src-tauri/**' - - 'frontend/src-tauri/.*' + - 'frontend/editor/src-tauri/**' + - 'frontend/editor/src-tauri/.*' - label: 'engine' files: diff --git a/.github/scripts/check_language_toml.py b/.github/scripts/check_language_toml.py index 2afa27c225..b931bac52b 100644 --- a/.github/scripts/check_language_toml.py +++ b/.github/scripts/check_language_toml.py @@ -13,7 +13,7 @@ Usage: """ # Sample for Windows: -# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml +# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-GB/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml import argparse import glob @@ -184,7 +184,7 @@ def check_for_differences(reference_file, file_list, branch, actor): if len(file_list) == 1: file_arr = file_list[0].split() - base_dir = Path.cwd() / "frontend" / "public" / "locales" + base_dir = Path.cwd() / "frontend" / "editor" / "public" / "locales" for file_path in file_arr: file_path = Path(file_path) @@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor): report.append("## ❌ Overall Check Status: **_Failed_**") report.append("") report.append( - f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)" + f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-GB/translation.toml)" ) else: report.append("## ✅ Overall Check Status: **_Success_**") @@ -372,6 +372,7 @@ if __name__ == "__main__": os.path.join( os.getcwd(), "frontend", + "editor", "public", "locales", "*", diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 0571d416cd..71071262cc 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -287,6 +287,7 @@ jobs: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw + - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" @@ -309,7 +310,7 @@ jobs: ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH # Create V2 PR-specific directories - mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs} + mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage} # Move docker-compose file to correct location mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index 43be06555a..023325d383 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -37,6 +37,11 @@ jobs: outputs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + - name: Classify the trigger id: decide env: diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index e5f9703319..9c701a5a61 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -1,8 +1,9 @@ name: AI Engine CI -# Validates the Python AI engine: regenerates tool models, runs fixers, -# lint, type-check, and tests. Called from build.yml on PRs and merge_group; -# also runs directly on push to main as a post-merge safety net. +# Validates the Python AI engine: regenerates tool models and runs the +# engine quality gate (lint, type-check, format-check, tests). Called from +# build.yml on PRs and merge_group; also runs directly on push to main as +# a post-merge safety net. on: workflow_call: push: @@ -26,7 +27,7 @@ jobs: egress-policy: audit - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 @@ -51,27 +52,95 @@ jobs: run: task engine:tool-models - name: Verify tool models are up to date + id: tool-models-check + continue-on-error: true + run: git diff --exit-code engine/src/stirling/models/tool_models.py + + - name: Comment on tool models check failure + # Only post a comment on PRs. github-script's PR helpers need an + # issue/PR number, which doesn't exist on merge_group runs. + if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const marker = ''; + const body = [ + marker, + '### Tool Models Check Failed', + '', + 'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.', + '', + 'Run `task engine:tool-models` to regenerate, then commit the updated file.', + ].join('\n'); + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Fail if tool models check failed + if: steps.tool-models-check.outcome == 'failure' run: | - if ! git diff --exit-code engine/src/stirling/models/tool_models.py; then - echo "tool_models.py is out of date." - echo "Run 'task engine:tool-models' locally and commit the updated file." - exit 1 - fi + echo "============================================" + echo " Tool Models Check Failed" + echo "============================================" + echo "" + echo "The generated engine/src/stirling/models/tool_models.py" + echo "is out of date with the Java OpenAPI spec and will" + echo "need to be regenerated before it can be merged in." + echo "" + echo "Run 'task engine:tool-models' to regenerate, then" + echo "commit the updated file." + echo "============================================" + exit 1 - - name: Run fixers - run: task engine:fix + - name: Remove tool models check comment on success + if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } - - name: Verify fixes are committed - id: fixer_changes - run: | - if ! git diff --quiet; then - git --no-pager diff --stat - echo "::error::There are issues with your Python code that will need to be fixed before they can be merged in. Run 'task engine:fix' to auto-fix what can be fixed automatically, then run 'task engine:check' to see what still needs fixing manually." - exit 1 - fi + - name: Quality-check engine + id: engine-check + run: task engine:check + continue-on-error: true - - name: Comment on fixer failures - if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request' + - name: Comment on engine check failure + # Only post a comment on PRs. github-script's PR helpers need an + # issue/PR number, which doesn't exist on merge_group runs. + if: steps.engine-check.outcome == 'failure' && github.event_name == 'pull_request' continue-on-error: true uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -107,11 +176,39 @@ jobs: }); } - - name: Run linting - run: task engine:lint + - name: Fail if engine check failed + if: steps.engine-check.outcome == 'failure' + run: | + echo "============================================" + echo " Engine Check Failed" + echo "============================================" + echo "" + echo "There are issues with your Python code that" + echo "will need to be fixed before they can be merged in." + echo "" + echo "Run 'task engine:fix' to auto-fix what can be" + echo "fixed automatically, then run 'task engine:check'" + echo "to see what still needs fixing manually." + echo "============================================" + exit 1 - - name: Run type checking - run: task engine:typecheck - - - name: Run tests - run: task engine:test + - name: Remove engine check comment on success + if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index b6dcd479c9..8371695856 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -75,7 +75,7 @@ jobs: egress-policy: audit - name: Checkout repository (for PKGBUILD templates) - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Update stirling-pdf-desktop PKGBUILD env: diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 72cb4a3074..73871dd7c3 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -67,7 +67,7 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - - name: Comment on Java formatting failure + - name: Comment on backend format check failure # Only post a comment on PRs. github-script's PR helpers need an # issue/PR number, which doesn't exist on merge_group runs. if: steps.spotless-check.outcome == 'failure' && github.event_name == 'pull_request' @@ -78,15 +78,11 @@ jobs: const marker = ''; const body = [ marker, - '### Java Formatting Check Failed', + '### Backend Format Check Failed', '', - 'Your code has formatting issues. Run the following command to fix them:', + 'There are formatting issues in your Java code that will need to be fixed before they can be merged in.', '', - '```bash', - 'task backend:format', - '```', - '', - 'Then commit and push the changes.', + 'Run `task backend:format` to auto-fix, then commit and push the changes.', ].join('\n'); const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, @@ -110,22 +106,43 @@ jobs: }); } - - name: Fail if Java formatting issues found + - name: Fail if backend format check failed if: steps.spotless-check.outcome == 'failure' run: | echo "============================================" - echo " Java Formatting Check Failed" + echo " Backend Format Check Failed" echo "============================================" echo "" - echo "Your code has formatting issues." - echo "Run the following command to fix them:" + echo "There are formatting issues in your Java code" + echo "that will need to be fixed before they can be" + echo "merged in." echo "" - echo " task backend:format" - echo "" - echo "Then commit and push the changes." + echo "Run 'task backend:format' to auto-fix, then" + echo "commit and push the changes." echo "============================================" exit 1 + - name: Remove backend format check comment on success + if: steps.spotless-check.outcome == 'success' && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } + - name: Build with Gradle and spring security ${{ matrix.spring-security }} run: task backend:build:ci env: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ade9e277e4..ac47fe1341 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,6 +68,18 @@ jobs: uses: ./.github/workflows/backend-build.yml secrets: inherit + db-migration-test: + # Boots the current bootJar against H2 fixtures captured from past + # releases (v2.0.0 / v2.5.0 / v2.10.0) and verifies admin login still + # works after Hibernate's ddl-auto=update migrates the schema. Gated on + # the `project` filter so doc-only PRs skip this ~5-minute job. + if: needs.files-changed.outputs.project == 'true' + needs: [files-changed] + permissions: + contents: read + uses: ./.github/workflows/db-migration-test.yml + secrets: inherit + check-generateOpenApiDocs: if: needs.files-changed.outputs.openapi == 'true' needs: [files-changed] @@ -184,6 +196,7 @@ jobs: needs: - files-changed - build + - db-migration-test - check-generateOpenApiDocs - frontend-validation - playwright-e2e @@ -198,11 +211,17 @@ jobs: - dependency-review runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + - name: Verify every required job passed (or was legitimately skipped) env: RESULTS: | files-changed=${{ needs.files-changed.result }} build=${{ needs.build.result }} + db-migration-test=${{ needs.db-migration-test.result }} check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }} frontend-validation=${{ needs.frontend-validation.result }} playwright-e2e=${{ needs.playwright-e2e.result }} diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml new file mode 100644 index 0000000000..2a386d1a5a --- /dev/null +++ b/.github/workflows/db-migration-test.yml @@ -0,0 +1,93 @@ +name: DB migration smoke test + +# Boots the current Stirling-PDF JAR against H2 fixtures captured from past +# releases (v2.0.0 / v2.5.0 / v2.10.0) and verifies admin login still works. +# Catches schema changes that would break existing user databases under +# Hibernate's `ddl-auto=update` upgrade path. + +on: + workflow_call: + +permissions: + contents: read + +jobs: + pick: + uses: ./.github/workflows/_runner-pick.yml + + migration-test: + needs: pick + runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }} + timeout-minutes: 30 + env: + DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + 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: Cache Gradle dependency artifacts + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches/modules-2/files-2.1 + ~/.gradle/caches/modules-2/metadata-2.* + key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + gradle-version: 9.3.1 + cache-disabled: true + + # No `-PnoSpotless` here yet because the upstream cache layer matches the + # backend build's; reuse keeps cold-cache cost identical. + - name: Build Stirling-PDF JAR + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon + + - name: Locate built JAR + id: jar + run: | + jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \ + | grep -vE '(-plain|-sources)\.jar$' | head -n 1) + if [[ -z "$jar" ]]; then + echo "::error::No JAR under app/core/build/libs" + ls -lah app/core/build/libs || true + exit 1 + fi + # Absolute path - the migration script pushd's into a temp workdir + # before invoking java, which would dangle a relative path. + jar=$(realpath "$jar") + echo "path=$jar" >> "$GITHUB_OUTPUT" + echo "Built JAR: $jar" + + - name: Run migration smoke test + env: + STIRLING_JAR: ${{ steps.jar.outputs.path }} + run: bash scripts/db-migration/run-migration-test.sh + + - name: Upload app logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: db-migration-app-logs + # Path matches the preserved workdir in run-migration-test.sh - + # only failing fixtures leave a directory behind. + path: /tmp/stirling-migration-failed-*/app.log + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index fde92def28..91f1cb0b95 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -586,21 +586,23 @@ jobs: if: always() && steps.digicert-setup.conclusion != 'failure' shell: bash run: | - mkdir -p ./dist + # Absolute dist path so the cd below can't break the copy targets. + DIST="$GITHUB_WORKSPACE/dist" + mkdir -p "$DIST" cd ./frontend/editor/src-tauri/target # Find and rename artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then # Only ship the MSI installer on Windows. The loose exe and WiX toolset exes # are not the user-facing installer - the MSI contains the signed inner exe. - find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; + find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \; elif [ "${{ matrix.platform }}" = "macos-15" ]; then - find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; - find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \; + find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \; + find . -name "*.app" -exec cp -r {} "$DIST/Stirling-PDF-${{ matrix.name }}.app" \; else - find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \; - find . -name "*.rpm" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.rpm" \; - find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; + find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \; + find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \; + find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \; fi - name: Upload build artifacts diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 58e4d882f0..018cc61b67 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -2,7 +2,7 @@ name: Update Package Manager Manifests on: release: - types: [ released ] + types: [released] workflow_dispatch: inputs: version: @@ -84,7 +84,7 @@ jobs: egress-policy: audit - name: Checkout homebrew-stirling-pdf tap (also hosts Scoop bucket) - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: Stirling-Tools/homebrew-stirling-pdf token: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 8828d00d1c..e7fa110fe4 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -4,6 +4,10 @@ name: Build Tauri Applications # Linux). Called from build.yml on PRs that touch desktop sources (gated # via the `tauri` filter in .github/config/.files.yaml). Also runnable # on demand via workflow_dispatch with a per-platform selector. +# +# Note: editing this file is itself enough to make the `tauri` path filter +# match, which is how non-desktop PRs (e.g. backend-only fixes) opt into a +# desktop smoke build. on: workflow_call: inputs: @@ -153,6 +157,9 @@ jobs: JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }} run: task desktop:prepare + - name: Run Tauri/Cargo tests + run: task desktop:test + # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup @@ -413,20 +420,22 @@ jobs: - name: Rename artifacts shell: bash run: | - mkdir -p ./dist + # Absolute dist path so the cd below can't break the copy targets. + DIST="$GITHUB_WORKSPACE/dist" + mkdir -p "$DIST" cd ./frontend/editor/src-tauri/target # Find and rename artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then # Only ship the MSI installer. The loose exe and WiX toolset exes # are not the user-facing installer - the MSI contains the signed inner exe. - find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; + find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \; elif [ "${{ matrix.platform }}" = "macos-15" ]; then - find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; + find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \; else - find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \; - find . -name "*.rpm" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.rpm" \; - find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; + find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \; + find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \; + find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \; fi # Verify the MSI AND the inner exe extracted from it are signed. diff --git a/.gitignore b/.gitignore index 261b2a40ae..1b7e69c498 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ customFiles/ configs/ watchedFolders/ clientWebUI/ +# 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. +.alpha-local/ !cucumber/ !cucumber/exampleFiles/ !cucumber/exampleFiles/example_html.zip @@ -157,7 +161,7 @@ app/proprietary/build common/build proprietary/build stirling-pdf/build -frontend/src-tauri/provisioner/target +frontend/editor/src-tauri/provisioner/target # Byte-compiled / optimized / DLL files __pycache__/ @@ -273,3 +277,6 @@ docs/type3/signatures/ # Playwright MCP screenshots / traces .playwright-mcp/ *.playwright-mcp.png + +# Local screenshot artifacts from *-screenshots.spec.ts +frontend/screenshots/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae9ed62530..2490b4ae6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.11 + rev: v0.15.14 hooks: - id: ruff args: diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 56db8eb4ac..2a3adffeb7 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -22,12 +22,13 @@ tasks: vars: PORT: '{{.PORT | default "8080"}}' AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: @@ -39,6 +40,25 @@ tasks: - cmd: ./gradlew clean bootRun -PbuildWithFrontend=true platforms: [linux, darwin] + 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: .`. + 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"}}' + env: + SERVER_PORT: '{{.PORT}}' + STIRLING_FLAVOR: saas + cmds: + - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}" + platforms: [windows] + - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}} + platforms: [linux, darwin] + build: desc: "Full backend build" cmds: diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index 4615c977a5..ac4ccbe806 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -31,52 +31,67 @@ tasks: provisioner: desc: "Build installer provisioner" platforms: [windows] + dir: editor cmds: - - node editor/scripts/build-provisioner.mjs + - node scripts/build-provisioner.mjs dev: desc: "Start Tauri desktop dev mode" deps: [prepare] ignore_error: true + dir: editor cmds: - - cd editor && npx tauri dev --no-watch + - npx tauri dev --no-watch build: desc: "Build Tauri desktop app (production)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build + - npx tauri build build:dev: desc: "Build Tauri desktop app (dev, no bundling)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --no-bundle + - npx tauri build --no-bundle build:dev:mac: desc: "Build Tauri desktop .app bundle (macOS)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --bundles app + - npx tauri build --bundles app build:dev:windows: desc: "Build Tauri desktop NSIS installer (Windows)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --bundles nsis + - npx tauri build --bundles nsis build:dev:linux: desc: "Build Tauri desktop AppImage (Linux)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --bundles appimage + - npx tauri build --bundles appimage + + test: + desc: "Run Tauri/Cargo tests" + deps: [prepare] + dir: editor/src-tauri + cmds: + - cargo test clean: desc: "Clean Tauri/Cargo build artifacts" + dir: editor cmds: - task: jlink:clean - - cd editor/src-tauri && cargo clean - - rm -rf editor/dist editor/build + - cd src-tauri && cargo clean + - rm -rf dist build # ============================================================ # JLink — Build bundled Java runtime for Tauri @@ -106,9 +121,10 @@ tasks: jlink:runtime: desc: "Create custom JRE with jlink" deps: [jlink:jar] + dir: editor/src-tauri cmds: - - rm -rf editor/src-tauri/runtime/jre - - mkdir -p editor/src-tauri/runtime + - rm -rf runtime/jre + - mkdir -p runtime - >- jlink --add-modules {{.JLINK_MODULES}} @@ -116,14 +132,36 @@ tasks: --compress=zip-6 --no-header-files --no-man-pages - --output editor/src-tauri/runtime/jre + --output runtime/jre + # jlink emits its files mode 444 (read-only). Tauri's build-script + # resource copier preserves source permissions when staging + # `runtime/jre/**/*` into `target//runtime/jre/...`, so the + # staged copies are read-only too. On any subsequent incremental + # build the copier tries to overwrite them and fails with a bare + # `Permission denied (os error 13)` (Rust's io::Error Display drops + # the path, so the failure is opaque). Make the source writable here + # so the staged destinations are writable and can be overwritten. + # + # Trade-off: this task runs for both `task desktop:dev` and + # `task desktop:build`, so production bundles also ship mode-644 + # JRE files instead of 444. Functionally harmless on POSIX (the + # `other` bit is `r--` either way, and on macOS code signing is the + # real integrity check) and on Windows the DOS read-only attribute + # isn't load-bearing for the bundled JDK. If we ever need strict + # 444 in production, split the chmod into a dev-only step and have + # `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 }" + platforms: [windows] status: - - test -d editor/src-tauri/runtime/jre + - test -f runtime/jre/release jlink:clean: desc: "Remove JLink runtime and bundled JARs" + dir: editor/src-tauri cmds: - - rm -rf editor/src-tauri/libs editor/src-tauri/runtime + - rm -rf libs runtime # macOS-only. Replaces jlink:runtime's single-arch JRE with a universal # (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64 @@ -134,8 +172,9 @@ tasks: desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build" deps: [jlink:jar] platforms: [darwin] + dir: editor env: JLINK_MODULES: "{{.JLINK_MODULES}}" - OUTPUT_DIR: editor/src-tauri/runtime/jre + OUTPUT_DIR: src-tauri/runtime/jre cmds: - - editor/scripts/build-universal-mac-jre.sh + - scripts/build-universal-mac-jre.sh diff --git a/Taskfile.yml b/Taskfile.yml index 7edf1e9b41..dc4d5130bd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -58,6 +58,23 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + dev:saas: + desc: "Start SaaS backend + frontend concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' + deps: + - task: backend:dev:saas + vars: + PORT: '{{.BACKEND_PORT}}' + - task: frontend:dev:saas + vars: + PORT: '{{.FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + OPEN: "true" + dev:all: desc: "Start backend + frontend + engine concurrently on free ports" vars: @@ -74,7 +91,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}' - - task: frontend:dev:prototypes + - task: frontend:dev vars: PORT: '{{.FRONTEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' diff --git a/app/.env.saas b/app/.env.saas new file mode 100644 index 0000000000..fb5feec559 --- /dev/null +++ b/app/.env.saas @@ -0,0 +1,34 @@ +############################################################################### +# Stirling-PDF SaaS environment defaults. +# +# 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. +# +# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in. +############################################################################### + +# ---------- Supabase project ---------- +# Project reference (the subdomain part of .supabase.co). Required. +# Set in .env.saas.local. +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`). +# 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= +SAAS_DEV_DB_USERNAME=postgres +SAAS_DEV_DB_PASSWORD= diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000000..e2a86ce4cf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +# Whitelist committed env defaults. `.env.saas.local` (and any other .env*) +# stays ignored via the root .gitignore. +!.env.saas diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 315c6bb188..cd2fe06a3b 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -44,6 +44,10 @@ "moduleName": ".*", "moduleLicense": "The MIT License" }, + { + "moduleName": ".*", + "moduleLicense": "MIT-0" + }, { "moduleName": "com.github.jai-imageio:jai-imageio-core", "moduleLicense": "LICENSE.txt" diff --git a/app/common/build.gradle b/app/common/build.gradle index 95f555759e..245a162346 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -60,7 +60,7 @@ dependencies { exclude group: 'com.google.code.gson', module: 'gson' } - api 'com.stirling:jpdfium:1.0.0' + api 'com.stirling:jpdfium:1.0.1' // -PjpdfiumPlatforms=all| def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() @@ -75,9 +75,12 @@ dependencies { } logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}") jpdfiumPlatforms.each { platform -> - runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.0" + runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1" } + // Bucket4j (local in-process token bucket for RateLimitStore default impl) + implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0' + // ArchUnit: enforces module dependency direction (see ArchitectureTest) testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2' } diff --git a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java index d3d79760d6..0d91fedf13 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java +++ b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java @@ -77,6 +77,10 @@ public @interface AutoJobPostMapping { /** * Relative resource weight (1-100). See {@link * stirling.software.common.enumeration.ResourceWeight} for the standard tiers. + * + *

The default is a sentinel ({@link Integer#MIN_VALUE}); {@code + * AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime + * readers clamp the value into {@code [1, 100]}. */ - int resourceWeight() default 1; + int resourceWeight() default Integer.MIN_VALUE; } diff --git a/app/common/src/main/java/stirling/software/common/cluster/ClusterBackplane.java b/app/common/src/main/java/stirling/software/common/cluster/ClusterBackplane.java new file mode 100644 index 0000000000..098608270b --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/ClusterBackplane.java @@ -0,0 +1,24 @@ +package stirling.software.common.cluster; + +/** Health and identity facade for the active cluster backplane. */ +public interface ClusterBackplane { + + /** Returns {@code true} when the backplane is reachable; used for health endpoints. */ + boolean isHealthy(); + + /** Returns {@code "inprocess"} or {@code "valkey"}. */ + String backplaneType(); + + /** Returns this JVM's stable node id (matches {@code Cluster.resolvedNodeId()}). */ + String localNodeId(); + + /** + * Whether this JVM should run the local {@link + * stirling.software.common.service.TaskManager#cleanupOldJobs()} loop. Distributed backplanes + * own job expiry via their own TTL, so they should override this to return {@code false}. + * Defaults to {@code true} so in-process behavior is preserved without an explicit override. + */ + default boolean shouldRunLocalCleanup() { + return true; + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/ClusterConfig.java b/app/common/src/main/java/stirling/software/common/cluster/ClusterConfig.java new file mode 100644 index 0000000000..127fbfbfbd --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/ClusterConfig.java @@ -0,0 +1,63 @@ +package stirling.software.common.cluster; + +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Cluster; + +/** + * Validates that cluster mode is internally consistent. + * + *

Cluster settings are bound on the central {@link ApplicationProperties} under {@code + * cluster.*}; this class reads {@link ApplicationProperties#getCluster()} and runs guards in {@link + * PostConstruct}. When {@code cluster.enabled=false} (the default) all checks are skipped so a + * single-instance install needs no new config. + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ClusterConfig { + + private final ApplicationProperties applicationProperties; + + @PostConstruct + void validate() { + Cluster cluster = applicationProperties.getCluster(); + if (!cluster.isEnabled()) { + return; + } + String backplane = cluster.getBackplane(); + if ("valkey".equalsIgnoreCase(backplane)) { + String url = cluster.getValkey() == null ? null : cluster.getValkey().getUrl(); + if (url == null || url.isBlank()) { + throw new IllegalStateException( + "cluster.enabled=true with backplane=valkey requires" + + " cluster.valkey.url to be set (e.g." + + " redis://valkey:6379)."); + } + } else if ("inprocess".equalsIgnoreCase(backplane)) { + // enabled+inprocess only coordinates the local JVM; cross-node lookups will 410. + log.warn( + "cluster.enabled=true with backplane=inprocess - only the local" + + " JVM is coordinated. Cross-node lookups and the file proxy will fail." + + " Use backplane=valkey for real multi-node deployments."); + } else { + // Fail fast on typos like "valky" so Spring doesn't later report a cryptic + // "no ClusterBackplane bean" - the operator-facing error names the bad value. + throw new IllegalStateException( + "cluster.enabled=true with unknown backplane '" + + backplane + + "'. Valid values: inprocess | valkey."); + } + log.info( + "Cluster mode enabled (backplane={}, nodeRole={}, nodeId={}).", + backplane, + cluster.resolvedRole(), + cluster.resolvedNodeId()); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/ClusterNode.java b/app/common/src/main/java/stirling/software/common/cluster/ClusterNode.java new file mode 100644 index 0000000000..c008d0e991 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/ClusterNode.java @@ -0,0 +1,12 @@ +package stirling.software.common.cluster; + +import java.time.Instant; + +/** + * Snapshot of a peer node as recorded in the {@link InstanceRegistry}. + * + * @param internalAddress {@code host:port} the node listens on for {@code /internal/cluster/**} + * @param role one of {@code WEB}, {@code WORKER}, {@code BOTH} + */ +public record ClusterNode( + String nodeId, String internalAddress, Instant lastHeartbeat, String role) {} diff --git a/app/common/src/main/java/stirling/software/common/cluster/DistributedLock.java b/app/common/src/main/java/stirling/software/common/cluster/DistributedLock.java new file mode 100644 index 0000000000..4c77e0f93a --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/DistributedLock.java @@ -0,0 +1,21 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Optional; + +/** Cluster-wide mutual exclusion primitive; non-reentrant by contract. */ +public interface DistributedLock { + + Optional tryAcquire(String lockKey, Duration leaseTime); + + interface LockHandle extends AutoCloseable { + void release(); + + boolean renew(Duration leaseTime); + + @Override + default void close() { + release(); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/FileStore.java b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java new file mode 100644 index 0000000000..f576e1e974 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java @@ -0,0 +1,45 @@ +package stirling.software.common.cluster; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Low-level storage seam for result/job files. */ +public interface FileStore { + + /** Stored file record. */ + record Stored(String fileId, long size) {} + + /** Store the given stream and return a generated file id and total bytes written. */ + Stored store(InputStream in, String originalName) throws IOException; + + /** + * Store the file at {@code source} and return a generated file id and total bytes written. + * + *

Default implementation opens {@code source} as a stream and delegates to {@link + * #store(InputStream, String)}. Local-disk implementations should override to use a direct + * file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux), + * which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap. + */ + default Stored store(Path source, String originalName) throws IOException { + try (InputStream in = Files.newInputStream(source)) { + return store(in, originalName); + } + } + + /** Open the stored file for streaming reads. Caller closes. */ + InputStream retrieve(String fileId) throws IOException; + + /** Load the stored file into a byte array. */ + byte[] retrieveBytes(String fileId) throws IOException; + + /** Size of the stored file in bytes. */ + long size(String fileId) throws IOException; + + /** Delete the stored file. Returns true if a file was removed. */ + boolean delete(String fileId); + + /** Whether the file id exists in the store. */ + boolean exists(String fileId); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/InstanceRegistry.java b/app/common/src/main/java/stirling/software/common/cluster/InstanceRegistry.java new file mode 100644 index 0000000000..289a352690 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/InstanceRegistry.java @@ -0,0 +1,18 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Collection; +import java.util.Optional; + +/** Maps {@code nodeId} to its internal cluster address, with TTL'd heartbeats. */ +public interface InstanceRegistry { + + /** Register or refresh this node. Idempotent so a wiped backplane self-heals on next tick. */ + void register(ClusterNode node, Duration heartbeatTtl); + + Optional lookup(String nodeId); + + Collection activeNodes(); + + void deregister(String nodeId); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/JobStore.java b/app/common/src/main/java/stirling/software/common/cluster/JobStore.java new file mode 100644 index 0000000000..03083c06ec --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/JobStore.java @@ -0,0 +1,24 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Collection; +import java.util.Optional; + +/** Cluster-visible storage for job status and result metadata, with TTL'd entries. */ +public interface JobStore { + + /** Persist or overwrite a job entry. {@code ttl} sets the lifetime of the entry. */ + void put(JobStoreEntry entry, Duration ttl); + + Optional get(String jobId); + + void delete(String jobId); + + boolean exists(String jobId); + + /** Reverse lookup: which job owns this result file id? */ + Optional findJobIdByFileId(String fileId); + + /** Snapshot of every active entry. Used by admin/stats endpoints; may be O(n). */ + Collection all(); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/JobStoreEntry.java b/app/common/src/main/java/stirling/software/common/cluster/JobStoreEntry.java new file mode 100644 index 0000000000..e730938b6d --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/JobStoreEntry.java @@ -0,0 +1,30 @@ +package stirling.software.common.cluster; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Cluster-visible projection of a job's status and result metadata, as persisted in {@link + * JobStore}. + * + * @param owningNodeId the node id that originally executed the job + */ +public record JobStoreEntry( + String jobId, + JobState state, + String owningNodeId, + Instant createdAt, + Instant completedAt, + String error, + List fileIds, + Map resultMeta) { + + /** Lifecycle states for a job as observed by the cluster. */ + public enum JobState { + PENDING, + RUNNING, + COMPLETE, + FAILED + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/KeyValueCache.java b/app/common/src/main/java/stirling/software/common/cluster/KeyValueCache.java new file mode 100644 index 0000000000..fae44ad827 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/KeyValueCache.java @@ -0,0 +1,16 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Optional; + +/** Short-TTL namespaced key/value cache backed by the cluster backplane. */ +public interface KeyValueCache { + + void put(String namespace, String key, String value, Duration ttl); + + Optional get(String namespace, String key); + + void evict(String namespace, String key); + + void evictNamespace(String namespace); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/RateLimitStore.java b/app/common/src/main/java/stirling/software/common/cluster/RateLimitStore.java new file mode 100644 index 0000000000..82351961a2 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/RateLimitStore.java @@ -0,0 +1,18 @@ +package stirling.software.common.cluster; + +import java.time.Duration; + +/** Token-bucket rate limiting backed by the cluster backplane. */ +public interface RateLimitStore { + + /** + * Attempt to consume one token from the bucket identified by {@code bucketKey}. + * + * @param bucketKey opaque key identifying the bucket (e.g. {@code api:user:123}) + * @param capacity bucket capacity + * @param refillPeriod time window over which {@code capacity} tokens refill + */ + RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod); + + record RateLimitDecision(boolean allowed, long remainingTokens, long nanosToWaitForRefill) {} +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/StickyMissRecorder.java b/app/common/src/main/java/stirling/software/common/cluster/StickyMissRecorder.java new file mode 100644 index 0000000000..cba9b3ceb4 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/StickyMissRecorder.java @@ -0,0 +1,7 @@ +package stirling.software.common.cluster; + +/** Records one increment per sticky-session miss (a 410 Gone for a job owned by another node). */ +@FunctionalInterface +public interface StickyMissRecorder { + void recordStickyMiss(); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterBackplane.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterBackplane.java new file mode 100644 index 0000000000..8cc147b89a --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterBackplane.java @@ -0,0 +1,31 @@ +package stirling.software.common.cluster.inprocess; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.model.ApplicationProperties; + +@Slf4j +public class InProcessClusterBackplane implements ClusterBackplane { + + private final ApplicationProperties applicationProperties; + + public InProcessClusterBackplane(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } + + @Override + public boolean isHealthy() { + return true; + } + + @Override + public String backplaneType() { + return "inprocess"; + } + + @Override + public String localNodeId() { + return applicationProperties.getCluster().resolvedNodeId(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterConfiguration.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterConfiguration.java new file mode 100644 index 0000000000..005ec319c3 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterConfiguration.java @@ -0,0 +1,65 @@ +package stirling.software.common.cluster.inprocess; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.DistributedLock; +import stirling.software.common.cluster.InstanceRegistry; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.KeyValueCache; +import stirling.software.common.cluster.RateLimitStore; +import stirling.software.common.model.ApplicationProperties; + +/** + * Default cluster backplane wiring: every interface gets an {@code InProcess*} bean. Active when + * cluster mode is off or {@code cluster.backplane=inprocess}. + */ +@Slf4j +@Configuration +@ConditionalOnExpression( + "!${cluster.enabled:false} ||" + + " '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')") +public class InProcessClusterConfiguration { + + @Bean + @ConditionalOnMissingBean + public ClusterBackplane clusterBackplane(ApplicationProperties applicationProperties) { + log.info("Cluster backplane: in-process (single node)"); + return new InProcessClusterBackplane(applicationProperties); + } + + @Bean + @ConditionalOnMissingBean + public JobStore jobStore() { + return new InProcessJobStore(); + } + + @Bean + @ConditionalOnMissingBean + public RateLimitStore rateLimitStore() { + return new InProcessRateLimitStore(); + } + + @Bean + @ConditionalOnMissingBean + public DistributedLock distributedLock() { + return new InProcessDistributedLock(); + } + + @Bean + @ConditionalOnMissingBean + public KeyValueCache keyValueCache() { + return new InProcessKeyValueCache(); + } + + @Bean + @ConditionalOnMissingBean + public InstanceRegistry instanceRegistry() { + return new InProcessInstanceRegistry(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessDistributedLock.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessDistributedLock.java new file mode 100644 index 0000000000..d937b3f39f --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessDistributedLock.java @@ -0,0 +1,128 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import stirling.software.common.cluster.DistributedLock; + +/** + * In-process {@link DistributedLock}, non-reentrant per the interface contract, with lease-expiry + * semantics that mirror a SET-NX-EX style distributed backend. + * + *

Each lock state carries a per-acquire {@code ownerToken} and an {@code expiryNanos}; another + * caller can take over once the lease has elapsed even if the original holder never called {@link + * LockHandle#release()}. This matters mostly for parity with the Valkey-backed implementation + * (Redis {@code SETEX} auto-expires the key); within a single JVM a crashed holder takes its lock + * state with it, but tests and code that rely on the {@code leaseTime} parameter still need it to + * be honored. + * + *

Expiry is lazy: an expired lock state lingers in the map until the next acquire attempt for + * the same key replaces it. Per-key cleanup also happens on explicit {@link LockHandle#release()}, + * so a balanced acquire/release workload keeps the map size bounded. + */ +public class InProcessDistributedLock implements DistributedLock { + + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + private final AtomicLong tokenSeq = new AtomicLong(); + + /** + * Lease state for a single acquired lock. {@code ownerToken} prevents a former holder from + * releasing or renewing a lock now owned by someone else after lease expiry; {@code + * expiryNanos} is read/written only inside {@link ConcurrentHashMap#compute} so the bin lock + * provides the necessary happens-before guarantee. + */ + private static final class LockState { + final long ownerToken; + long expiryNanos; + + LockState(long ownerToken, long expiryNanos) { + this.ownerToken = ownerToken; + this.expiryNanos = expiryNanos; + } + } + + @Override + public Optional tryAcquire(String lockKey, Duration leaseTime) { + long token = tokenSeq.incrementAndGet(); + long nowNanos = System.nanoTime(); + long expiryNanos = nowNanos + leaseTime.toNanos(); + boolean[] acquired = {false}; + locks.compute( + lockKey, + (k, existing) -> { + if (existing == null || existing.expiryNanos - nowNanos <= 0L) { + // No lock, or the previous lease has expired - we take it. Subtraction + // form avoids the long-overflow trap that would bite a naive + // expiryNanos <= nowNanos comparison around System.nanoTime() rollover. + acquired[0] = true; + return new LockState(token, expiryNanos); + } + return existing; + }); + if (!acquired[0]) { + return Optional.empty(); + } + return Optional.of(new InProcessHandle(lockKey, token)); + } + + private void releaseInternal(String lockKey, long token) { + locks.compute( + lockKey, + (k, existing) -> { + if (existing == null || existing.ownerToken != token) { + // Already removed, expired-and-replaced, or never ours. + return existing; + } + return null; + }); + } + + private boolean renewInternal(String lockKey, long token, Duration leaseTime) { + long nowNanos = System.nanoTime(); + boolean[] renewed = {false}; + locks.compute( + lockKey, + (k, existing) -> { + if (existing == null + || existing.ownerToken != token + || existing.expiryNanos - nowNanos <= 0L) { + // Lock is gone or expired; renewal is a no-op so the caller can detect it. + return existing; + } + existing.expiryNanos = nowNanos + leaseTime.toNanos(); + renewed[0] = true; + return existing; + }); + return renewed[0]; + } + + private final class InProcessHandle implements LockHandle { + private final String lockKey; + private final long token; + private boolean released; + + InProcessHandle(String lockKey, long token) { + this.lockKey = lockKey; + this.token = token; + } + + @Override + public synchronized void release() { + if (released) { + return; + } + released = true; + releaseInternal(lockKey, token); + } + + @Override + public synchronized boolean renew(Duration leaseTime) { + if (released) { + return false; + } + return renewInternal(lockKey, token, leaseTime); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistry.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistry.java new file mode 100644 index 0000000000..d46801f8b6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistry.java @@ -0,0 +1,42 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import stirling.software.common.cluster.ClusterNode; +import stirling.software.common.cluster.InstanceRegistry; + +public class InProcessInstanceRegistry implements InstanceRegistry { + + private final AtomicReference self = new AtomicReference<>(); + + @Override + public void register(ClusterNode node, Duration heartbeatTtl) { + self.set(node); + } + + @Override + public Optional lookup(String nodeId) { + ClusterNode current = self.get(); + return current != null && current.nodeId().equals(nodeId) + ? Optional.of(current) + : Optional.empty(); + } + + @Override + public Collection activeNodes() { + ClusterNode current = self.get(); + return current == null ? Collections.emptyList() : Collections.singletonList(current); + } + + @Override + public void deregister(String nodeId) { + ClusterNode current = self.get(); + if (current != null && current.nodeId().equals(nodeId)) { + self.set(null); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessJobStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessJobStore.java new file mode 100644 index 0000000000..500716e8e9 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessJobStore.java @@ -0,0 +1,96 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.JobStoreEntry; + +@Slf4j +public class InProcessJobStore implements JobStore { + + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + + @Override + public void put(JobStoreEntry entry, Duration ttl) { + Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl); + entries.put(entry.jobId(), new Holder(entry, expiry)); + } + + @Override + public Optional get(String jobId) { + Holder h = entries.get(jobId); + if (h == null) { + return Optional.empty(); + } + if (h.isExpired()) { + entries.remove(jobId, h); + return Optional.empty(); + } + return Optional.of(h.entry); + } + + @Override + public void delete(String jobId) { + entries.remove(jobId); + } + + @Override + public boolean exists(String jobId) { + return get(jobId).isPresent(); + } + + @Override + public Optional findJobIdByFileId(String fileId) { + for (Map.Entry e : entries.entrySet()) { + Holder h = e.getValue(); + if (h.isExpired()) { + continue; + } + List fileIds = h.entry.fileIds(); + if (fileIds != null && fileIds.contains(fileId)) { + return Optional.of(e.getKey()); + } + } + return Optional.empty(); + } + + @Override + public Collection all() { + List result = new ArrayList<>(entries.size()); + for (Holder h : entries.values()) { + if (!h.isExpired()) { + result.add(h.entry); + } + } + return result; + } + + /** Drop entries whose TTL has elapsed. Called by the {@code TaskManager} cleanup scheduler. */ + public int purgeExpired() { + int removed = 0; + Instant now = Instant.now(); + for (Map.Entry e : entries.entrySet()) { + if (!e.getValue().expiry.equals(Instant.MAX) && e.getValue().expiry.isBefore(now)) { + if (entries.remove(e.getKey(), e.getValue())) { + removed++; + } + } + } + return removed; + } + + private record Holder(JobStoreEntry entry, Instant expiry) { + boolean isExpired() { + return !expiry.equals(Instant.MAX) && expiry.isBefore(Instant.now()); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCache.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCache.java new file mode 100644 index 0000000000..845d272881 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCache.java @@ -0,0 +1,55 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import stirling.software.common.cluster.KeyValueCache; + +public class InProcessKeyValueCache implements KeyValueCache { + + private final ConcurrentHashMap> namespaces = + new ConcurrentHashMap<>(); + + @Override + public void put(String namespace, String key, String value, Duration ttl) { + Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl); + namespaces + .computeIfAbsent(namespace, n -> new ConcurrentHashMap<>()) + .put(key, new Expiring(value, expiry)); + } + + @Override + public Optional get(String namespace, String key) { + Map ns = namespaces.get(namespace); + if (ns == null) { + return Optional.empty(); + } + Expiring e = ns.get(key); + if (e == null) { + return Optional.empty(); + } + if (e.expiry.isBefore(Instant.now())) { + ns.remove(key, e); + return Optional.empty(); + } + return Optional.of(e.value); + } + + @Override + public void evict(String namespace, String key) { + Map ns = namespaces.get(namespace); + if (ns != null) { + ns.remove(key); + } + } + + @Override + public void evictNamespace(String namespace) { + namespaces.remove(namespace); + } + + private record Expiring(String value, Instant expiry) {} +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStore.java new file mode 100644 index 0000000000..b2cd4cd74a --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStore.java @@ -0,0 +1,49 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.ConsumptionProbe; +import io.github.bucket4j.local.LocalBucketBuilder; + +import stirling.software.common.cluster.RateLimitStore; + +/** Bucket4j-backed token bucket implementation of {@link RateLimitStore}. */ +public class InProcessRateLimitStore implements RateLimitStore { + + /** Cap to bound memory; oldest accessed buckets are evicted. */ + private static final int MAX_BUCKETS = 10_000; + + private final Map buckets = + Collections.synchronizedMap( + new LinkedHashMap(256, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_BUCKETS; + } + }); + + @Override + public RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod) { + String compositeKey = bucketKey + "|" + capacity + "|" + refillPeriod.toNanos(); + Bucket bucket = + buckets.computeIfAbsent(compositeKey, k -> buildBucket(capacity, refillPeriod)); + ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); + return new RateLimitDecision( + probe.isConsumed(), + probe.getRemainingTokens(), + probe.isConsumed() ? 0L : probe.getNanosToWaitForRefill()); + } + + private static Bucket buildBucket(long capacity, Duration refillPeriod) { + Bandwidth limit = + Bandwidth.builder().capacity(capacity).refillGreedy(capacity, refillPeriod).build(); + LocalBucketBuilder builder = Bucket.builder(); + builder.addLimit(limit); + return builder.build(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java new file mode 100644 index 0000000000..6d8d0d91fa --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java @@ -0,0 +1,128 @@ +package stirling.software.common.cluster.inprocess; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.FileStore; + +/** Local-disk {@link FileStore} storing files under a base directory keyed by a UUID file id. */ +@Slf4j +public class LocalDiskFileStore implements FileStore { + + private final String baseDirPath; + + public LocalDiskFileStore(String baseDirPath) { + this.baseDirPath = baseDirPath; + } + + @Override + public Stored store(InputStream in, String originalName) throws IOException { + String fileId = UUID.randomUUID().toString(); + Path filePath = resolve(fileId); + Files.createDirectories(filePath.getParent()); + boolean success = false; + try { + long size = Files.copy(in, filePath); + success = true; + return new Stored(fileId, size); + } finally { + if (!success) { + try { + Files.deleteIfExists(filePath); + } catch (IOException cleanupEx) { + log.warn( + "Failed to clean up partial file {} after store failure", + filePath, + cleanupEx); + } + } + } + } + + /** + * File-to-file copy. {@link Files#copy(Path, Path, java.nio.file.CopyOption...)} can use {@code + * sendfile(2)} on Linux for a zero-copy kernel transfer when source and destination share a + * filesystem, avoiding the streaming overhead of pulling the bytes through the JVM heap. Reads + * the source size before copying so the post-copy stat is unnecessary. + */ + @Override + public Stored store(Path source, String originalName) throws IOException { + String fileId = UUID.randomUUID().toString(); + Path filePath = resolve(fileId); + Files.createDirectories(filePath.getParent()); + long size = Files.size(source); + boolean success = false; + try { + Files.copy(source, filePath); + success = true; + return new Stored(fileId, size); + } finally { + if (!success) { + try { + Files.deleteIfExists(filePath); + } catch (IOException cleanupEx) { + log.warn( + "Failed to clean up partial file {} after store failure", + filePath, + cleanupEx); + } + } + } + } + + @Override + public InputStream retrieve(String fileId) throws IOException { + return new BufferedInputStream(Files.newInputStream(resolve(fileId))); + } + + @Override + public byte[] retrieveBytes(String fileId) throws IOException { + Path filePath = resolve(fileId); + if (!Files.exists(filePath)) { + throw new IOException("File not found with ID: " + fileId); + } + return Files.readAllBytes(filePath); + } + + @Override + public long size(String fileId) throws IOException { + Path filePath = resolve(fileId); + if (!Files.exists(filePath)) { + throw new IOException("File not found with ID: " + fileId); + } + return Files.size(filePath); + } + + @Override + public boolean delete(String fileId) { + try { + return Files.deleteIfExists(resolve(fileId)); + } catch (IOException e) { + log.error("Error deleting file with ID: {}", fileId, e); + return false; + } + } + + @Override + public boolean exists(String fileId) { + return Files.exists(resolve(fileId)); + } + + public Path resolve(String fileId) { + if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { + throw new IllegalArgumentException("Invalid file ID"); + } + Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath(); + Path resolvedPath = basePath.resolve(fileId).normalize(); + if (!resolvedPath.startsWith(basePath)) { + throw new IllegalArgumentException("File ID resolves to an invalid path"); + } + return resolvedPath; + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreConfiguration.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreConfiguration.java new file mode 100644 index 0000000000..2df8246639 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreConfiguration.java @@ -0,0 +1,29 @@ +package stirling.software.common.cluster.inprocess; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import stirling.software.common.cluster.FileStore; + +/** + * Always-on wiring for the per-node local-disk {@link FileStore}. Active when {@code + * cluster.artifactStore=local} (the default; {@code matchIfMissing=true}). The S3 artifact-store + * supplies its own bean when {@code cluster.artifactStore=s3}. + */ +@Configuration +@ConditionalOnProperty( + prefix = "cluster", + name = "artifactStore", + havingValue = "local", + matchIfMissing = true) +public class LocalDiskFileStoreConfiguration { + + @Bean + @ConditionalOnMissingBean + public FileStore fileStore(@Value("${stirling.tempDir:/tmp/stirling-files}") String tempDir) { + return new LocalDiskFileStore(tempDir); + } +} diff --git a/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java b/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java index 54e42504cc..4b3c237ec8 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java +++ b/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java @@ -80,6 +80,7 @@ public class ConfigInitializer { YamlHelper settingsFile = new YamlHelper(settingTempPath); migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile); + migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile); boolean changesMade = settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile); @@ -116,31 +117,52 @@ public class ConfigInitializer { } if (yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin") != null) { template.updateValue( - List.of("premium", "proFeatures", "SSOAutoLogin"), + List.of("premium", "proFeatures", "ssoAutoLogin"), yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin")); } if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "autoUpdateMetadata") != null) { template.updateValue( - List.of("premium", "proFeatures", "CustomMetadata", "autoUpdateMetadata"), + List.of("premium", "proFeatures", "customMetadata", "autoUpdateMetadata"), yaml.getValueByExactKeyPath( "enterpriseEdition", "CustomMetadata", "autoUpdateMetadata")); } if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author") != null) { template.updateValue( - List.of("premium", "proFeatures", "CustomMetadata", "author"), + List.of("premium", "proFeatures", "customMetadata", "author"), yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author")); } if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator") != null) { template.updateValue( - List.of("premium", "proFeatures", "CustomMetadata", "creator"), + List.of("premium", "proFeatures", "customMetadata", "creator"), yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator")); } if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer") != null) { template.updateValue( - List.of("premium", "proFeatures", "CustomMetadata", "producer"), + List.of("premium", "proFeatures", "customMetadata", "producer"), yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer")); } } + + // TODO: Remove post migration + // settings.yml.template renamed the two non-camelCase proFeatures keys + // ("SSOAutoLogin" -> "ssoAutoLogin", "CustomMetadata" -> "customMetadata") so the whole + // settings pipeline is consistent camelCase. The save path (YamlHelper.updateValue) matches + // keys case-sensitively, so without this carry-forward an existing install's values written + // under the old PascalCase keys would be dropped on upgrade and reset to template defaults. + void migrateProFeaturesKeyCasing(YamlHelper yaml, YamlHelper template) { + Object ssoAutoLogin = yaml.getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin"); + if (ssoAutoLogin != null) { + template.updateValue(List.of("premium", "proFeatures", "ssoAutoLogin"), ssoAutoLogin); + } + for (String field : List.of("autoUpdateMetadata", "author", "creator", "producer")) { + Object value = + yaml.getValueByExactKeyPath("premium", "proFeatures", "CustomMetadata", field); + if (value != null) { + template.updateValue( + List.of("premium", "proFeatures", "customMetadata", field), value); + } + } + } } 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 227ba7709d..bb2eb81678 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 @@ -13,6 +13,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Locale; +import java.util.UUID; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -77,6 +78,7 @@ public class ApplicationProperties { private PdfEditor pdfEditor = new PdfEditor(); private AiEngine aiEngine = new AiEngine(); private InternalApi internalApi = new InternalApi(); + private Cluster cluster = new Cluster(); @Bean public PropertySource dynamicYamlPropertySource(ConfigurableEnvironment environment) @@ -254,6 +256,106 @@ public class ApplicationProperties { private int longRunningTimeoutSeconds = 600; } + /** + * Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix + * (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to + * off; when off the in-process backplane is wired and no other cluster keys are required. + */ + @Data + public static class Cluster { + + /** Master switch. When {@code false} (default) the in-process backplane is wired. */ + private boolean enabled = false; + + /** Backplane implementation selector. Valid values: {@code inprocess} | {@code valkey}. */ + private String backplane = "inprocess"; + + /** + * Transient cluster job-artifact store selector. Valid values: {@code local} | {@code s3}. + * + *

This is distinct from {@code storage.provider}, which selects the backend for + * persistent user-uploaded files. The two switches exist because the user-facing storage + * feature is optional ({@code storage.enabled=false} is common) but every multi-node + * cluster still needs a shared artifact store to serve cross-node downloads. Both + * implementations share credentials from {@code storage.s3.*} when set to {@code s3}. + */ + private String artifactStore = "local"; + + private Valkey valkey = new Valkey(); + private Node node = new Node(); + + private transient String cachedNodeId; + + public NodeRole resolvedRole() { + if (node == null || node.getRole() == null) { + return NodeRole.BOTH; + } + String value = node.getRole().trim().toUpperCase(Locale.ROOT); + try { + return NodeRole.valueOf(value); + } catch (IllegalArgumentException ex) { + return NodeRole.BOTH; + } + } + + public synchronized String resolvedNodeId() { + if (node != null && node.getId() != null && !node.getId().isBlank()) { + return node.getId(); + } + if (cachedNodeId == null) { + cachedNodeId = UUID.randomUUID().toString(); + } + return cachedNodeId; + } + + public enum NodeRole { + WEB, + WORKER, + BOTH + } + + @Data + public static class Valkey { + /** + * {@code redis://host:6379} or {@code rediss://...} for TLS. Required when cluster mode + * is on and backplane is valkey. + */ + private String url = ""; + + private Tls tls = new Tls(); + + @Data + public static class Tls { + /** + * When {@code true}, skip Valkey/Redis TLS certificate verification (dev/test + * only). Leave {@code false} in production. + */ + private boolean skipCertVerification = false; + } + } + + @Data + public static class Node { + /** Optional explicit node id. Blank = auto-generated UUID at startup. */ + private String id = ""; + + /** {@code web} | {@code worker} | {@code both}. */ + private String role = "both"; + + /** + * Internal cluster address advertised in the instance registry (host:port). Blank = + * derived at startup. + */ + private String internalAddress = ""; + + /** {@code http} | {@code https} - scheme used when peers call this node. */ + private String scheme = "http"; + + /** Heartbeat publish interval for the instance registry, in milliseconds. */ + private long heartbeatIntervalMs = 5000; + } + } + /** * HTTP timeouts for loopback calls to internal Stirling API endpoints, used by the AI workflow * executor and the pipeline processor. A bounded read timeout prevents a hung tool (e.g. an @@ -426,6 +528,16 @@ public class ApplicationProperties { private String provider; private Client client = new Client(); + /** + * When true, the OAuth2/OIDC login flow logs the full set of ID token and UserInfo + * claims at INFO level (and again at ERROR level if the username attribute cannot be + * resolved). Used to diagnose provider misconfiguration (for example ADFS not returning + * an {@code email} claim). WARNING: writes PII (sub, email, name) to application logs. + * Leave disabled in production; enable only while actively troubleshooting and disable + * again afterwards. + */ + private Boolean debugLogging = false; + public void setScopes(String scopes) { List scopesList = Arrays.stream(scopes.split(",")).map(String::trim).toList(); @@ -676,6 +788,7 @@ public class ApplicationProperties { private boolean enabled = false; private String provider = "local"; private Local local = new Local(); + private S3 s3 = new S3(); private Quotas quotas = new Quotas(); private Sharing sharing = new Sharing(); private Signing signing = new Signing(); @@ -685,6 +798,57 @@ public class ApplicationProperties { private String basePath = InstallationPathConfig.getPath() + "storage"; } + @Data + public static class S3 { + /** + * Optional custom endpoint (e.g. {@code https://.r2.cloudflarestorage.com}, + * {@code https://.supabase.co/storage/v1/s3}, or {@code http://localhost:9000} + * for MinIO). Blank = use AWS regional default. + */ + private String endpoint = ""; + + private String bucket = ""; + + private String region = "us-east-1"; + + private String accessKey = ""; + private String secretKey = ""; + + /** + * When {@code true} use path-style URLs ({@code //}) instead of + * virtual-hosted ({@code ./}). MinIO and most S3-compatible + * gateways require path-style; AWS S3 prefers virtual-hosted. + */ + private boolean pathStyleAccess = false; + + /** + * When {@code false} (default), {@code endpoint} hostnames that resolve to private, + * loopback, or link-local addresses are rejected at startup to block SSRF attacks via + * the cloud metadata service (e.g. {@code http://169.254.169.254/}). Set to {@code + * true} to opt in for MinIO / in-cluster S3 endpoints on private networks. + */ + private boolean allowPrivateEndpoints = false; + + /** + * Controls when the SDK adds an {@code x-amz-checksum-*} header on PUT/UploadPart. + * Default {@code WHEN_SUPPORTED} (the SDK default since 2.30) makes the SDK send a + * CRC32 checksum on every upload - this works on AWS S3, MinIO, current Supabase, + * Backblaze B2 (post-July-2025), and modern R2. Set to {@code WHEN_REQUIRED} to + * suppress the auto-checksum on vendors that reject unknown {@code x-amz-checksum-*} + * headers (older Backblaze B2, some R2 corner cases, GCS S3 endpoint). Invalid values + * fall back to {@code WHEN_SUPPORTED}. + */ + private String requestChecksumCalculation = "WHEN_SUPPORTED"; + + /** + * Controls when the SDK validates returned {@code x-amz-checksum-*} headers on GET + * responses. Default {@code WHEN_SUPPORTED}. Set to {@code WHEN_REQUIRED} if your + * vendor never returns these headers and you see false-positive checksum-mismatch + * errors. Invalid values fall back to {@code WHEN_SUPPORTED}. + */ + private String responseChecksumValidation = "WHEN_SUPPORTED"; + } + @Data public static class Sharing { private boolean enabled = false; 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 beb8028f70..e77e6d6fc6 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 @@ -1,15 +1,13 @@ package stirling.software.common.service; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.UUID; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -18,9 +16,11 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.cluster.FileStore; + /** * Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping - * system to handle file references. + * system to handle file references. Disk I/O is delegated to the injected {@link FileStore} bean. */ @Service @RequiredArgsConstructor @@ -30,251 +30,150 @@ public class FileStorage { /** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */ public record StoredFile(String fileId, long size) {} - @Value("${stirling.tempDir:/tmp/stirling-files}") - private String tempDirPath; - private final FileOrUploadService fileOrUploadService; + private final FileStore fileStore; - /** - * Store a file and return its unique ID - * - * @param file The file to store - * @return The unique ID assigned to the file - * @throws IOException If there is an error storing the file - */ public String storeFile(MultipartFile file) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - - // Ensure the directory exists - Files.createDirectories(filePath.getParent()); - - // Transfer the file to the storage location - file.transferTo(filePath.toFile()); - - log.debug("Stored file with ID: {}", fileId); - return fileId; - } - - /** - * Store a byte array as a file and return its unique ID - * - * @param bytes The byte array to store - * @param originalName The original name of the file (for extension) - * @return The unique ID assigned to the file - * @throws IOException If there is an error storing the file - */ - public String storeBytes(byte[] bytes, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - - // Ensure the directory exists - Files.createDirectories(filePath.getParent()); - - // Write the bytes to the file - Files.write(filePath, bytes); - - log.debug("Stored byte array with ID: {}", fileId); - return fileId; - } - - /** - * Retrieve a file by its ID as a MultipartFile - * - * @param fileId The ID of the file to retrieve - * @return The file as a MultipartFile - * @throws IOException If the file doesn't exist or can't be read - */ - public MultipartFile retrieveFile(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - - if (!Files.exists(filePath)) { - throw new IOException("File not found with ID: " + fileId); + // Fast path: when Spring buffered the multipart to disk (typical for large uploads), the + // backing Resource exposes a real File. Hand the Path to the FileStore so it can do a + // file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming + // the bytes through an 8K buffer. Falls back to the InputStream path for in-memory + // multiparts, exotic Resource impls, and anything that does not back onto a File. + Resource res; + try { + res = file.getResource(); + } catch (RuntimeException ignored) { + res = null; } + if (res != null && res.isFile()) { + try { + FileStore.Stored stored = + fileStore.store(res.getFile().toPath(), file.getOriginalFilename()); + log.debug("Stored file with ID: {} (fast path)", stored.fileId()); + return stored.fileId(); + } catch (IOException ex) { + // Some Resource impls advertise isFile()=true but throw on getFile(); fall through. + log.debug("Resource fast path failed, falling back to stream copy", ex); + } + } + try (InputStream in = file.getInputStream()) { + FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename()); + log.debug("Stored file with ID: {}", stored.fileId()); + return stored.fileId(); + } + } - byte[] fileData = Files.readAllBytes(filePath); + public String storeBytes(byte[] bytes, String originalName) throws IOException { + FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName); + log.debug("Stored byte array with ID: {}", stored.fileId()); + return stored.fileId(); + } + + public MultipartFile retrieveFile(String fileId) throws IOException { + byte[] fileData = fileStore.retrieveBytes(fileId); return fileOrUploadService.toMockMultipartFile(fileId, fileData); } - /** - * Retrieve a file by its ID as a byte array - * - * @param fileId The ID of the file to retrieve - * @return The file as a byte array - * @throws IOException If the file doesn't exist or can't be read - */ public byte[] retrieveBytes(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - - if (!Files.exists(filePath)) { - throw new IOException("File not found with ID: " + fileId); - } - - return Files.readAllBytes(filePath); + return fileStore.retrieveBytes(fileId); } - /** - * Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing - * the returned stream. - * - * @param fileId The ID of the file to retrieve - * @return A buffered InputStream for the file - * @throws IOException If the file doesn't exist or can't be read - */ public InputStream retrieveInputStream(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - // Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race - // between exists-check and open when another thread may delete concurrently. - return new BufferedInputStream(Files.newInputStream(filePath)); + return fileStore.retrieve(fileId); } - /** - * Store data from an InputStream as a file and return its unique ID and byte count. Streams - * directly to disk without buffering the entire content in heap. - * - * @param inputStream The input stream to read from - * @param originalName The original name of the file (unused, kept for API symmetry) - * @return A {@link StoredFile} containing the file ID and the number of bytes written - * @throws IOException If there is an error storing the file - */ public StoredFile storeInputStream(InputStream inputStream, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - Files.createDirectories(filePath.getParent()); - long size = Files.copy(inputStream, filePath); - log.debug("Stored input stream with ID: {}", fileId); - return new StoredFile(fileId, size); + FileStore.Stored stored = fileStore.store(inputStream, originalName); + log.debug("Stored input stream with ID: {}", stored.fileId()); + return new StoredFile(stored.fileId(), stored.size()); } public String storeFromStreamingBody(StreamingResponseBody body, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - Files.createDirectories(filePath.getParent()); - boolean success = false; - try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(filePath))) { - body.writeTo(os); - success = true; - } finally { - if (!success) { + // Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.) + // from the body writer would otherwise close the pipe with EOF and the consumer would + // return a truncated file with no error surfaced to the caller. + AtomicReference bodyError = new AtomicReference<>(); + try (PipedOutputStream out = new PipedOutputStream(); + PipedInputStream in = new PipedInputStream(out, 8192)) { + var executor = Executors.newSingleThreadExecutor(Thread.ofVirtual().factory()); + java.util.concurrent.Future task = null; + try { + task = + executor.submit( + () -> { + try { + body.writeTo(out); + } catch (Throwable ex) { + bodyError.set(ex); + } finally { + try { + out.close(); + } catch (IOException ignored) { + // closed on the consumer side too + } + } + }); + FileStore.Stored stored = fileStore.store(in, originalName); + Throwable writerErr = bodyError.get(); + if (writerErr != null) { + // Body failed mid-write: the FileStore persisted a truncated entry. + // Best-effort delete so we don't leak partial files; never let cleanup + // mask the original writer error. + try { + fileStore.delete(stored.fileId()); + } catch (RuntimeException cleanupEx) { + log.warn( + "Failed to delete partial file {} after writer error: {}", + stored.fileId(), + cleanupEx.getMessage()); + } + if (writerErr instanceof IOException ioe) { + throw ioe; + } + throw new IOException( + "StreamingResponseBody writer failed: " + writerErr.getMessage(), + writerErr); + } + log.debug("Stored StreamingResponseBody with ID: {}", stored.fileId()); + return stored.fileId(); + } finally { + // Interrupt and join the writer task: shutdown() alone returns immediately and a + // failed store leaves the writer running, leaking a thread per failed upload. + if (task != null && !task.isDone()) { + task.cancel(true); + } + executor.shutdown(); try { - Files.deleteIfExists(filePath); - } catch (IOException cleanupEx) { - log.warn( - "Failed to clean up partial file {} after store failure", - filePath, - cleanupEx); + if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException ie) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); } } } - log.debug("Stored StreamingResponseBody with ID: {}", fileId); - return fileId; } - /** - * Persist a {@link Resource} body to disk, returning the generated file ID. Used by the async - * job pipeline to capture {@code ResponseEntity} results produced by controllers. - */ public String storeFromResource(Resource resource, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - Files.createDirectories(filePath.getParent()); - boolean success = false; try (InputStream in = resource.getInputStream()) { - Files.copy(in, filePath); - success = true; - } finally { - if (!success) { - try { - Files.deleteIfExists(filePath); - } catch (IOException cleanupEx) { - log.warn( - "Failed to clean up partial file {} after store failure", - filePath, - cleanupEx); - } - } + FileStore.Stored stored = fileStore.store(in, originalName); + log.debug("Stored Resource with ID: {}", stored.fileId()); + return stored.fileId(); } - log.debug("Stored Resource with ID: {}", fileId); - return fileId; } - /** - * Delete a file by its ID - * - * @param fileId The ID of the file to delete - * @return true if the file was deleted, false otherwise - */ public boolean deleteFile(String fileId) { - try { - Path filePath = getFilePath(fileId); - return Files.deleteIfExists(filePath); - } catch (IOException e) { - log.error("Error deleting file with ID: {}", fileId, e); - return false; - } + return fileStore.delete(fileId); } - /** - * Check if a file exists by its ID - * - * @param fileId The ID of the file to check - * @return true if the file exists, false otherwise - */ public boolean fileExists(String fileId) { - Path filePath = getFilePath(fileId); - return Files.exists(filePath); + return fileStore.exists(fileId); } - /** - * Get the size of a file by its ID without loading the content into memory - * - * @param fileId The ID of the file - * @return The size of the file in bytes - * @throws IOException If the file doesn't exist or can't be read - */ public long getFileSize(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - - if (!Files.exists(filePath)) { - throw new IOException("File not found with ID: " + fileId); - } - - return Files.size(filePath); - } - - /** - * Get the path for a file ID - * - * @param fileId The ID of the file - * @return The path to the file - * @throws IllegalArgumentException if fileId contains path traversal characters or resolves - * outside base directory - */ - private Path getFilePath(String fileId) { - // Validate fileId to prevent path traversal - if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { - throw new IllegalArgumentException("Invalid file ID"); - } - - Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath(); - Path resolvedPath = basePath.resolve(fileId).normalize(); - - // Ensure resolved path is within the base directory - if (!resolvedPath.startsWith(basePath)) { - throw new IllegalArgumentException("File ID resolves to an invalid path"); - } - - return resolvedPath; - } - - /** - * Generate a unique file ID - * - * @return A unique file ID - */ - private String generateFileId() { - return UUID.randomUUID().toString(); + return fileStore.size(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 36a1d1857d..790e0626ac 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 @@ -3,9 +3,13 @@ package stirling.software.common.service; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -16,6 +20,7 @@ import java.util.concurrent.TimeUnit; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; @@ -26,6 +31,10 @@ import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.JobStoreEntry; +import stirling.software.common.cluster.JobStoreEntry.JobState; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; import stirling.software.common.model.job.ResultFile; @@ -40,20 +49,20 @@ public class TaskManager { private int jobResultExpiryMinutes = 30; private final FileStorage fileStorage; + private final JobStore jobStore; + private final ClusterBackplane clusterBackplane; private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor( Thread.ofVirtual().name("task-cleanup-", 0).factory()); - /** Initialize the task manager and start the cleanup scheduler */ - public TaskManager(FileStorage fileStorage) { + @Autowired + public TaskManager( + FileStorage fileStorage, JobStore jobStore, ClusterBackplane clusterBackplane) { this.fileStorage = fileStorage; + this.jobStore = jobStore; + this.clusterBackplane = clusterBackplane; - // Schedule periodic cleanup of old job results - cleanupExecutor.scheduleAtFixedRate( - this::cleanupOldJobs, - 10, // Initial delay - 10, // Interval - TimeUnit.MINUTES); + cleanupExecutor.scheduleAtFixedRate(this::cleanupOldJobs, 10, 10, TimeUnit.MINUTES); log.debug( "Task manager initialized with job result expiry of {} minutes", @@ -66,7 +75,9 @@ public class TaskManager { * @param jobId The job ID */ public void createTask(String jobId) { - jobResults.put(jobId, JobResult.createNew(jobId)); + JobResult result = JobResult.createNew(jobId); + jobResults.put(jobId, result); + writeThrough(jobId, result); log.debug("Created task with job ID: {}", jobId); } @@ -79,6 +90,7 @@ public class TaskManager { public void setResult(String jobId, Object result) { JobResult jobResult = getOrCreateJobResult(jobId); jobResult.completeWithResult(result); + writeThrough(jobId, jobResult); log.debug("Set result for job ID: {}", jobId); } @@ -101,6 +113,7 @@ public class TaskManager { extractZipToIndividualFiles(fileId, originalFileName); if (!extractedFiles.isEmpty()) { jobResult.completeWithFiles(extractedFiles); + writeThrough(jobId, jobResult); log.debug( "Set multiple file results for job ID: {} with {} files extracted from" + " ZIP", @@ -127,6 +140,7 @@ public class TaskManager { "Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage()); jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0); } + writeThrough(jobId, jobResult); } /** @@ -138,6 +152,7 @@ public class TaskManager { public void setMultipleFileResults(String jobId, List resultFiles) { JobResult jobResult = getOrCreateJobResult(jobId); jobResult.completeWithFiles(resultFiles); + writeThrough(jobId, jobResult); log.debug( "Set multiple file results for job ID: {} with {} files", jobId, @@ -153,6 +168,7 @@ public class TaskManager { public void setError(String jobId, String error) { JobResult jobResult = getOrCreateJobResult(jobId); jobResult.failWithError(error); + writeThrough(jobId, jobResult); log.debug("Set error for job ID: {}: {}", jobId, error); } @@ -169,6 +185,7 @@ public class TaskManager { // If no result or error has been set, mark it as complete with an empty result jobResult.completeWithResult("Task completed successfully"); } + writeThrough(jobId, jobResult); log.debug("Marked job ID: {} as complete", jobId); } @@ -205,6 +222,7 @@ public class TaskManager { JobResult jobResult = jobResults.get(jobId); if (jobResult != null) { jobResult.addNote(note); + writeThrough(jobId, jobResult); log.debug("Added note to job ID: {}: {}", jobId, note); return true; } @@ -295,8 +313,11 @@ public class TaskManager { return jobResults.computeIfAbsent(jobId, JobResult::createNew); } - /** Clean up old completed job results */ + /** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */ public void cleanupOldJobs() { + if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) { + return; + } LocalDateTime expiryThreshold = LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES); int removedCount = 0; @@ -315,6 +336,9 @@ public class TaskManager { // Remove the job result jobResults.remove(entry.getKey()); + if (jobStore != null) { + jobStore.delete(entry.getKey()); + } removedCount++; } } @@ -327,6 +351,53 @@ public class TaskManager { } } + /** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */ + private void writeThrough(String jobId, JobResult result) { + if (jobStore == null) { + return; + } + try { + jobStore.put(toEntry(jobId, result), Duration.ofMinutes(jobResultExpiryMinutes)); + } catch (RuntimeException ex) { + log.warn("JobStore write-through failed for job {}: {}", jobId, ex.getMessage()); + } + } + + private JobStoreEntry toEntry(String jobId, JobResult result) { + JobState state; + if (result.isComplete()) { + state = result.getError() != null ? JobState.FAILED : JobState.COMPLETE; + } else { + state = JobState.PENDING; + } + Instant createdAt = toInstant(result.getCreatedAt()); + Instant completedAt = toInstant(result.getCompletedAt()); + List fileIds = new ArrayList<>(); + if (result.hasFiles()) { + for (ResultFile rf : result.getAllResultFiles()) { + fileIds.add(rf.getFileId()); + } + } + Map meta = new HashMap<>(); + if (result.getNotes() != null && !result.getNotes().isEmpty()) { + meta.put("notesCount", Integer.toString(result.getNotes().size())); + } + String owningNodeId = clusterBackplane == null ? "local" : clusterBackplane.localNodeId(); + return new JobStoreEntry( + jobId, + state, + owningNodeId, + createdAt, + completedAt, + result.getError(), + fileIds, + meta); + } + + private Instant toInstant(LocalDateTime ldt) { + return ldt == null ? null : ldt.atZone(ZoneId.systemDefault()).toInstant(); + } + /** Shutdown the cleanup executor */ @PreDestroy public void shutdown() { @@ -370,7 +441,7 @@ public class TaskManager { while ((entry = zipIn.getNextEntry()) != null) { if (!entry.isDirectory()) { String contentType = determineContentType(entry.getName()); - // storeInputStream returns the fileId and byte count — no extra stat needed + // storeInputStream returns the fileId and byte count - no extra stat needed FileStorage.StoredFile stored = fileStorage.storeInputStream(zipIn, entry.getName()); @@ -458,7 +529,8 @@ public class TaskManager { } /** - * Find the job key that owns a given file ID. + * Find the job key that owns a given file ID. Checks the local in-memory map first, then falls + * back to the cluster-visible {@link JobStore}. * * @param fileId file identifier to look up * @return scoped job key if found, otherwise null @@ -474,6 +546,18 @@ public class TaskManager { } } } + if (jobStore != null) { + // Propagate JobStore failures: returning null on a backplane outage would conflate + // "no such file" with "lookup unavailable" and the caller would respond 404 to a + // transient blip that should be retried. Let Spring's exception handler surface a + // 5xx so clients know to retry. + try { + return jobStore.findJobIdByFileId(fileId).orElse(null); + } catch (RuntimeException e) { + log.warn("JobStore findJobIdByFileId failed for {}: {}", fileId, e.getMessage()); + throw e; + } + } return null; } } 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 ce8e05dc80..74c713b6db 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 @@ -83,7 +83,16 @@ public class RequestUriUtils { return false; } - // Blocklist of backend/non-frontend paths that should still go through filters + // Blocklist of backend/non-frontend paths that should still go through filters. + // + // `/files` was historically a backend route; it is now a frontend route + // owned by HomePage / FileManagerView. Direct-nav or refresh on /files + // (or /files/) was returning the Spring auth filter's 401 + // JSON instead of serving index.html, so the SPA never got a chance to + // mount and the user saw a raw error response. There are no `/files` + // backend mappings at the servlet root - the real storage endpoints + // live under `/api/v1/storage/files`, which is filtered out a few lines + // up by the `startsWith("/api/")` guard. String[] backendOnlyPrefixes = { "/register", "/pipeline", @@ -91,7 +100,6 @@ public class RequestUriUtils { "/pdfjs-legacy", "/fonts", "/images", - "/files", "/css", "/js", "/swagger", @@ -172,7 +180,7 @@ public class RequestUriUtils { "/api/v1/ui-data/footer-info") // Public footer configuration || trimmedUri.startsWith("/api/v1/invite/validate") || trimmedUri.startsWith("/api/v1/invite/accept") - // Health Endoints + // Health Endpoints || trimmedUri.startsWith("/actuator/health") || trimmedUri.startsWith("/health") || trimmedUri.startsWith("/healthz") @@ -181,7 +189,7 @@ public class RequestUriUtils { || trimmedUri.startsWith( "/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth) || trimmedUri.startsWith("/v1/api-docs") - // Workflow participant endpoints — access controlled by share tokens, not login + // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected || trimmedUri.matches("^/share/[^/]+/?$"); diff --git a/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java b/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java index 68f22dfe84..285407dca3 100644 --- a/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java +++ b/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java @@ -56,4 +56,17 @@ class ArchitectureTest { .resideInAPackage("stirling.software.saas.."); rule.check(commonClasses); } + + @Test + void clusterInterfacesHaveNoImplementationDependencies() { + ArchRule rule = + noClasses() + .that() + .resideInAPackage("stirling.software.common.cluster..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "stirling.software.proprietary..", "stirling.software.saas.."); + rule.check(commonClasses); + } } diff --git a/app/common/src/test/java/stirling/software/common/cluster/BackplaneContractCompilationTest.java b/app/common/src/test/java/stirling/software/common/cluster/BackplaneContractCompilationTest.java new file mode 100644 index 0000000000..9da2c41cf7 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/BackplaneContractCompilationTest.java @@ -0,0 +1,51 @@ +package stirling.software.common.cluster; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class BackplaneContractCompilationTest { + + @Test + void jobStoreEntryRecordRoundTrips() { + Instant now = Instant.now(); + JobStoreEntry entry = + new JobStoreEntry( + "job-1", + JobStoreEntry.JobState.PENDING, + "node-a", + now, + null, + null, + List.of("file-1"), + Map.of("k", "v")); + assertEquals("job-1", entry.jobId()); + assertEquals(JobStoreEntry.JobState.PENDING, entry.state()); + assertEquals("node-a", entry.owningNodeId()); + assertEquals(now, entry.createdAt()); + assertEquals(List.of("file-1"), entry.fileIds()); + assertEquals("v", entry.resultMeta().get("k")); + } + + @Test + void clusterNodeRecordRoundTrips() { + Instant heartbeat = Instant.now(); + ClusterNode node = new ClusterNode("node-a", "10.0.0.1:8080", heartbeat, "BOTH"); + assertEquals("node-a", node.nodeId()); + assertEquals("10.0.0.1:8080", node.internalAddress()); + assertEquals(heartbeat, node.lastHeartbeat()); + assertEquals("BOTH", node.role()); + } + + @Test + void rateLimitDecisionRecordRoundTrips() { + RateLimitStore.RateLimitDecision d = new RateLimitStore.RateLimitDecision(true, 7, 0L); + assertEquals(true, d.allowed()); + assertEquals(7, d.remainingTokens()); + assertEquals(0L, d.nanosToWaitForRefill()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/ClusterConfigValidationTest.java b/app/common/src/test/java/stirling/software/common/cluster/ClusterConfigValidationTest.java new file mode 100644 index 0000000000..d87e2e8bbe --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/ClusterConfigValidationTest.java @@ -0,0 +1,65 @@ +package stirling.software.common.cluster; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Cluster; + +class ClusterConfigValidationTest { + + @Test + void validationPassesWhenDisabled() { + ApplicationProperties props = new ApplicationProperties(); + ClusterConfig config = new ClusterConfig(props); + assertDoesNotThrow(() -> invokeValidate(config)); + } + + @Test + void validationFailsWhenValkeyEnabledWithoutUrl() { + ApplicationProperties props = new ApplicationProperties(); + Cluster cluster = props.getCluster(); + cluster.setEnabled(true); + cluster.setBackplane("valkey"); + ClusterConfig config = new ClusterConfig(props); + assertThrows(IllegalStateException.class, () -> invokeValidate(config)); + } + + @Test + void validationPassesWhenValkeyEnabledWithUrl() { + ApplicationProperties props = new ApplicationProperties(); + Cluster cluster = props.getCluster(); + cluster.setEnabled(true); + cluster.setBackplane("valkey"); + cluster.getValkey().setUrl("redis://localhost:6379"); + ClusterConfig config = new ClusterConfig(props); + assertDoesNotThrow(() -> invokeValidate(config)); + } + + @Test + void validationPassesWhenInProcessEnabled() { + ApplicationProperties props = new ApplicationProperties(); + Cluster cluster = props.getCluster(); + cluster.setEnabled(true); + cluster.setBackplane("inprocess"); + ClusterConfig config = new ClusterConfig(props); + assertDoesNotThrow(() -> invokeValidate(config)); + } + + private void invokeValidate(ClusterConfig config) throws Exception { + Method m = ClusterConfig.class.getDeclaredMethod("validate"); + m.setAccessible(true); + try { + m.invoke(config); + } catch (java.lang.reflect.InvocationTargetException ex) { + if (ex.getCause() instanceof RuntimeException re) { + throw re; + } + throw ex; + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/ClusterPropertiesTest.java b/app/common/src/test/java/stirling/software/common/cluster/ClusterPropertiesTest.java new file mode 100644 index 0000000000..8237a2252a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/ClusterPropertiesTest.java @@ -0,0 +1,62 @@ +package stirling.software.common.cluster; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Cluster; + +class ClusterPropertiesTest { + + @Test + void defaultsAreDisabledAndInprocess() { + Cluster props = new ApplicationProperties().getCluster(); + assertFalse(props.isEnabled()); + assertEquals("inprocess", props.getBackplane()); + assertEquals("local", props.getArtifactStore()); + assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole()); + assertEquals("", props.getValkey().getUrl()); + assertFalse(props.getValkey().getTls().isSkipCertVerification()); + assertEquals("both", props.getNode().getRole()); + assertEquals("http", props.getNode().getScheme()); + assertEquals(5000L, props.getNode().getHeartbeatIntervalMs()); + } + + @Test + void resolvedRoleParsesCaseInsensitively() { + Cluster props = new ApplicationProperties().getCluster(); + props.getNode().setRole("WEB"); + assertEquals(Cluster.NodeRole.WEB, props.resolvedRole()); + + props.getNode().setRole("web"); + assertEquals(Cluster.NodeRole.WEB, props.resolvedRole()); + + props.getNode().setRole("Worker"); + assertEquals(Cluster.NodeRole.WORKER, props.resolvedRole()); + + props.getNode().setRole("garbage"); + assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole()); + + props.getNode().setRole(null); + assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole()); + } + + @Test + void resolvedNodeIdIsStableAcrossCalls() { + Cluster props = new ApplicationProperties().getCluster(); + String first = props.resolvedNodeId(); + String second = props.resolvedNodeId(); + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void resolvedNodeIdHonoursExplicitId() { + Cluster props = new ApplicationProperties().getCluster(); + props.getNode().setId("abc"); + assertEquals("abc", props.resolvedNodeId()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/InProcessConfigurationConditionalTest.java b/app/common/src/test/java/stirling/software/common/cluster/InProcessConfigurationConditionalTest.java new file mode 100644 index 0000000000..4a5a73ce37 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/InProcessConfigurationConditionalTest.java @@ -0,0 +1,90 @@ +package stirling.software.common.cluster; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import stirling.software.common.cluster.inprocess.InProcessClusterConfiguration; +import stirling.software.common.model.ApplicationProperties; + +/** + * Verifies the {@link InProcessClusterConfiguration} conditional wiring: in-process beans wire when + * cluster mode is off or {@code backplane=inprocess}, and are skipped when {@code + * backplane=valkey}. + */ +class InProcessConfigurationConditionalTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + org.springframework.boot.autoconfigure.AutoConfigurations.of( + PropertyPlaceholderAutoConfiguration.class)) + .withUserConfiguration( + TestAppPropertiesConfig.class, + ClusterConfig.class, + InProcessClusterConfiguration.class); + + @Test + void inProcessBeansWireWhenClusterDisabled() { + runner.run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(ClusterBackplane.class) + .hasSingleBean(JobStore.class) + .hasSingleBean(RateLimitStore.class) + .hasSingleBean(DistributedLock.class) + .hasSingleBean(KeyValueCache.class) + .hasSingleBean(InstanceRegistry.class)); + } + + @Test + void inProcessBeansWireWhenEnabledWithInProcessBackplane() { + runner.withPropertyValues("cluster.enabled=true", "cluster.backplane=inprocess") + .run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(ClusterBackplane.class) + .hasSingleBean(JobStore.class) + .hasSingleBean(RateLimitStore.class) + .hasSingleBean(DistributedLock.class) + .hasSingleBean(KeyValueCache.class) + .hasSingleBean(InstanceRegistry.class)); + } + + @Test + void inProcessBeansSkippedWhenEnabledWithDistributedBackplane() { + runner.withPropertyValues( + "cluster.enabled=true", + "cluster.backplane=valkey", + "cluster.valkey.url=redis://localhost:6379") + .run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(ClusterBackplane.class) + .doesNotHaveBean(JobStore.class) + .doesNotHaveBean(RateLimitStore.class) + .doesNotHaveBean(DistributedLock.class) + .doesNotHaveBean(KeyValueCache.class) + .doesNotHaveBean(InstanceRegistry.class)); + } + + /** + * Hand-rolled {@link ApplicationProperties} bean: the production class loads YAML at startup + * via a {@code @PostConstruct} hook that isn't appropriate for the slice runner, so we wire a + * defaults-only instance here. + */ + @Configuration + static class TestAppPropertiesConfig { + @Bean + ApplicationProperties applicationProperties() { + return new ApplicationProperties(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessDistributedLockTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessDistributedLockTest.java new file mode 100644 index 0000000000..30b738a3b9 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessDistributedLockTest.java @@ -0,0 +1,167 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.DistributedLock; + +class InProcessDistributedLockTest { + + @Test + void acquireReleaseAcquire() { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + h1.release(); + assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent()); + } + + @Test + void reentryFromSameThreadFails() { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + Optional reentry = lock.tryAcquire("k", Duration.ofSeconds(30)); + assertFalse(reentry.isPresent(), "in-process lock must be non-reentrant"); + h1.release(); + // After release, anyone can acquire again. + assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent()); + } + + @Test + void secondAcquireFromAnotherThreadFails() throws InterruptedException { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + + CountDownLatch done = new CountDownLatch(1); + AtomicBoolean acquired = new AtomicBoolean(true); + Thread t = + new Thread( + () -> { + Optional attempt = + lock.tryAcquire("k", Duration.ofSeconds(30)); + acquired.set(attempt.isPresent()); + attempt.ifPresent(DistributedLock.LockHandle::release); + done.countDown(); + }); + t.start(); + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertFalse(acquired.get()); + h1.release(); + } + + @Test + void leaseExpiryAllowsTakeoverEvenWithoutRelease() throws InterruptedException { + // Acquire with a short lease, never call release, then try to acquire again after the + // lease has elapsed. Matches Redis SET-NX-EX semantics - the second caller gets the lock + // because the first lease auto-expired. 250ms lease + 350ms wait gives CI generous slack. + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(250)).orElseThrow(); + Thread.sleep(350); + Optional takeover = + lock.tryAcquire("k", Duration.ofSeconds(30)); + assertTrue( + takeover.isPresent(), + "expired lease must release the lock so a new caller can take over"); + // Calling release() on the original handle after takeover must be a no-op (token check). + h1.release(); + // The takeover holder is still the legitimate owner. + assertFalse(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent()); + takeover.get().release(); + } + + @Test + void renewExtendsLease() throws InterruptedException { + // Acquire with a short lease, renew it before it expires, then verify the lock is still + // held past the original expiry point. 200ms initial + renew to 2s + wait 350ms. + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(200)).orElseThrow(); + assertTrue(h1.renew(Duration.ofSeconds(2)), "renew on a held lease must succeed"); + Thread.sleep(350); + assertFalse( + lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent(), + "renew should have pushed expiry well past the original 200ms"); + h1.release(); + } + + @Test + void renewAfterReleaseFails() { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + h1.release(); + assertFalse(h1.renew(Duration.ofSeconds(30)), "renew on a released handle must fail"); + } + + /** + * Concurrency stress: many threads contending on the same key with each holder respecting the + * lease (hold << lease). The lock behaves as a strict mutex in this regime so asserting + * mutual exclusion is meaningful. A separate test ({@link + * #leaseExpiryAllowsTakeoverEvenWithoutRelease}) covers the takeover-across-expiry branch, + * which legitimately allows two holders momentarily and is split-brain behaviour inherent to + * any lease-based lock. + */ + @Test + void concurrentContentionPreservesMutualExclusion() throws InterruptedException { + DistributedLock lock = new InProcessDistributedLock(); + int threads = 16; + int attemptsPerThread = 200; + // Lease far exceeds any plausible hold time, so the takeover branch never triggers in + // this test and the lock acts as a strict mutex. + Duration lease = Duration.ofSeconds(5); + java.util.concurrent.atomic.AtomicInteger concurrentHolders = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicInteger maxConcurrent = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicInteger acquires = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicReference firstFailure = + new java.util.concurrent.atomic.AtomicReference<>(); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + new Thread( + () -> { + try { + start.await(); + for (int j = 0; j < attemptsPerThread; j++) { + Optional h = + lock.tryAcquire("hot", lease); + if (h.isPresent()) { + int now = concurrentHolders.incrementAndGet(); + maxConcurrent.accumulateAndGet(now, Math::max); + acquires.incrementAndGet(); + // Trivial critical section; well within lease. + concurrentHolders.decrementAndGet(); + h.get().release(); + } + } + } catch (Throwable t) { + firstFailure.compareAndSet(null, t); + } finally { + done.countDown(); + } + }, + "lock-stress-" + i) + .start(); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS), "stress workers must finish in time"); + org.junit.jupiter.api.Assertions.assertNull(firstFailure.get(), "no worker may throw"); + org.junit.jupiter.api.Assertions.assertEquals( + 1, + maxConcurrent.get(), + "mutual exclusion violated: more than one holder observed simultaneously"); + assertTrue( + acquires.get() > 0, + "at least some acquires must succeed under contention (saw " + + acquires.get() + + ")"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistryTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistryTest.java new file mode 100644 index 0000000000..c19eb444f7 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistryTest.java @@ -0,0 +1,28 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.ClusterNode; + +class InProcessInstanceRegistryTest { + + @Test + void registerThenLookupAndActiveNodes() { + InProcessInstanceRegistry registry = new InProcessInstanceRegistry(); + ClusterNode node = new ClusterNode("node-1", "127.0.0.1:8080", Instant.now(), "BOTH"); + registry.register(node, Duration.ofSeconds(30)); + + assertTrue(registry.lookup("node-1").isPresent()); + assertEquals("node-1", registry.lookup("node-1").get().nodeId()); + assertEquals(1, registry.activeNodes().size()); + + registry.deregister("node-1"); + assertTrue(registry.lookup("node-1").isEmpty()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessJobStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessJobStoreTest.java new file mode 100644 index 0000000000..33eaebe35c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessJobStoreTest.java @@ -0,0 +1,91 @@ +package stirling.software.common.cluster.inprocess; + +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.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.JobStoreEntry; + +class InProcessJobStoreTest { + + private final InProcessJobStore store = new InProcessJobStore(); + + @Test + void putGetDeleteExistsRoundTrip() { + JobStoreEntry entry = entry("job-1"); + store.put(entry, Duration.ofMinutes(30)); + + assertTrue(store.exists("job-1")); + assertEquals(entry, store.get("job-1").orElseThrow()); + + store.delete("job-1"); + assertFalse(store.exists("job-1")); + } + + @Test + void ttlExpiry() throws InterruptedException { + store.put(entry("job-2"), Duration.ofMillis(50)); + Thread.sleep(100); + assertFalse(store.get("job-2").isPresent()); + } + + @Test + void purgeExpiredRemovesOnlyStaleEntries() throws InterruptedException { + store.put(entry("job-fresh"), Duration.ofMinutes(30)); + store.put(entry("job-stale"), Duration.ofMillis(20)); + Thread.sleep(80); + + int removed = store.purgeExpired(); + assertEquals(1, removed); + assertTrue(store.exists("job-fresh")); + } + + @Test + void findJobIdByFileIdReturnsTheRightJob() { + store.put( + new JobStoreEntry( + "job-a", + JobStoreEntry.JobState.COMPLETE, + "node-1", + Instant.now(), + Instant.now(), + null, + List.of("file-1", "file-2"), + Map.of()), + Duration.ofMinutes(30)); + store.put( + new JobStoreEntry( + "job-b", + JobStoreEntry.JobState.COMPLETE, + "node-1", + Instant.now(), + Instant.now(), + null, + List.of("file-3"), + Map.of()), + Duration.ofMinutes(30)); + + assertEquals("job-a", store.findJobIdByFileId("file-1").orElseThrow()); + assertEquals("job-b", store.findJobIdByFileId("file-3").orElseThrow()); + assertFalse(store.findJobIdByFileId("missing").isPresent()); + } + + private JobStoreEntry entry(String id) { + return new JobStoreEntry( + id, + JobStoreEntry.JobState.PENDING, + "node-1", + Instant.now(), + null, + null, + List.of(), + Map.of()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCacheTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCacheTest.java new file mode 100644 index 0000000000..f0afc97890 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCacheTest.java @@ -0,0 +1,41 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.KeyValueCache; + +class InProcessKeyValueCacheTest { + + @Test + void putGetEvict() { + KeyValueCache cache = new InProcessKeyValueCache(); + cache.put("apikey", "a", "userA", Duration.ofMinutes(1)); + assertEquals("userA", cache.get("apikey", "a").orElseThrow()); + + cache.evict("apikey", "a"); + assertFalse(cache.get("apikey", "a").isPresent()); + } + + @Test + void ttlExpiry() throws InterruptedException { + KeyValueCache cache = new InProcessKeyValueCache(); + cache.put("ns", "k", "v", Duration.ofMillis(40)); + Thread.sleep(80); + assertFalse(cache.get("ns", "k").isPresent()); + } + + @Test + void evictNamespace() { + KeyValueCache cache = new InProcessKeyValueCache(); + cache.put("ns", "a", "1", Duration.ofMinutes(1)); + cache.put("ns", "b", "2", Duration.ofMinutes(1)); + cache.evictNamespace("ns"); + assertFalse(cache.get("ns", "a").isPresent()); + assertFalse(cache.get("ns", "b").isPresent()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStoreTest.java new file mode 100644 index 0000000000..8aecf259bc --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStoreTest.java @@ -0,0 +1,56 @@ +package stirling.software.common.cluster.inprocess; + +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.time.Duration; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.RateLimitStore; +import stirling.software.common.cluster.RateLimitStore.RateLimitDecision; + +class InProcessRateLimitStoreTest { + + @Test + void firstNConsumesAllowed() { + RateLimitStore store = new InProcessRateLimitStore(); + for (int i = 0; i < 5; i++) { + assertTrue(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed(), "i=" + i); + } + assertFalse(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed()); + } + + @Test + void remainingTokensDecrements() { + RateLimitStore store = new InProcessRateLimitStore(); + RateLimitDecision d1 = store.tryConsume("k", 5, Duration.ofSeconds(60)); + RateLimitDecision d2 = store.tryConsume("k", 5, Duration.ofSeconds(60)); + assertTrue(d1.allowed()); + assertTrue(d2.allowed()); + assertEquals(4, d1.remainingTokens()); + assertEquals(3, d2.remainingTokens()); + } + + @Test + void refillRestoresTokens() throws InterruptedException { + RateLimitStore store = new InProcessRateLimitStore(); + // Capacity 2 with smooth refill over 100 ms -> ~1 token per 50 ms. + for (int i = 0; i < 2; i++) { + assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed()); + } + assertFalse(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed()); + Thread.sleep(150); + assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed()); + } + + @Test + void deniedConsumeReportsWaitNanos() { + RateLimitStore store = new InProcessRateLimitStore(); + assertTrue(store.tryConsume("wait", 1, Duration.ofSeconds(10)).allowed()); + RateLimitDecision denied = store.tryConsume("wait", 1, Duration.ofSeconds(10)); + assertFalse(denied.allowed()); + assertTrue(denied.nanosToWaitForRefill() > 0L); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java new file mode 100644 index 0000000000..296d012c8f --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java @@ -0,0 +1,42 @@ +package stirling.software.common.cluster.inprocess; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import stirling.software.common.cluster.FileStore; + +class LocalDiskFileStoreTest { + + @Test + void storeRetrieveSizeDeleteExistsRoundTrip(@TempDir Path dir) throws IOException { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + byte[] payload = "hello-bytes".getBytes(); + + FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "x.txt"); + assertEquals(payload.length, stored.size()); + assertTrue(store.exists(stored.fileId())); + assertEquals(payload.length, store.size(stored.fileId())); + assertArrayEquals(payload, store.retrieveBytes(stored.fileId())); + + assertTrue(store.delete(stored.fileId())); + assertFalse(store.exists(stored.fileId())); + } + + @Test + void traversalIdsAreRejected(@TempDir Path dir) { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + assertThrows(IllegalArgumentException.class, () -> store.resolve("../foo")); + assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b")); + assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b")); + } +} diff --git a/app/common/src/test/java/stirling/software/common/configuration/ConfigInitializerTest.java b/app/common/src/test/java/stirling/software/common/configuration/ConfigInitializerTest.java new file mode 100644 index 0000000000..4ce75d0c62 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/configuration/ConfigInitializerTest.java @@ -0,0 +1,95 @@ +package stirling.software.common.configuration; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.snakeyaml.engine.v2.api.LoadSettings; + +import stirling.software.common.util.YamlHelper; + +class ConfigInitializerTest { + + private static final LoadSettings LOAD_SETTINGS = + LoadSettings.builder() + .setUseMarks(true) + .setMaxAliasesForCollections(Integer.MAX_VALUE) + .setAllowRecursiveKeys(true) + .setParseComments(true) + .build(); + + // Mirrors the proFeatures block of settings.yml.template after the camelCase rename. + private static final String CAMEL_CASE_TEMPLATE = + """ + premium: + proFeatures: + ssoAutoLogin: false + customMetadata: + autoUpdateMetadata: false + author: username + creator: Stirling-PDF + producer: Stirling-PDF + """; + + @Test + void migrateProFeaturesKeyCasing_carriesForwardLegacyPascalCaseValues() { + // An existing install whose settings.yml still uses the old PascalCase keys. + String legacy = + """ + premium: + proFeatures: + SSOAutoLogin: true + CustomMetadata: + autoUpdateMetadata: true + author: alice + creator: bob + producer: carol + """; + YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE); + YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy); + + new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template); + + assertEquals( + "true", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin")); + assertEquals( + "true", + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "autoUpdateMetadata")); + assertEquals( + "alice", + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "author")); + assertEquals( + "bob", + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "creator")); + assertEquals( + "carol", + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "producer")); + } + + @Test + void migrateProFeaturesKeyCasing_withoutLegacyKeys_keepsTemplateDefaults() { + // No PascalCase keys present -> this migration step must be a no-op. + String alreadyCamel = + """ + premium: + proFeatures: + ssoAutoLogin: true + customMetadata: + author: dave + """; + YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE); + YamlHelper existing = new YamlHelper(LOAD_SETTINGS, alreadyCamel); + + new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template); + + assertEquals( + "false", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin")); + assertEquals( + "username", + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "author")); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java new file mode 100644 index 0000000000..a929a15af2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java @@ -0,0 +1,27 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import stirling.software.common.cluster.inprocess.LocalDiskFileStore; + +class FileStorageDelegationTest { + + @Test + void storeBytesThenRetrieveBytesRoundTripsThroughFileStore(@TempDir Path tempDir) + throws IOException { + FileStorage fs = + new FileStorage( + mock(FileOrUploadService.class), + new LocalDiskFileStore(tempDir.toString())); + byte[] payload = "round-trip".getBytes(); + String id = fs.storeBytes(payload, "x.bin"); + assertArrayEquals(payload, fs.retrieveBytes(id)); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java index 5b06747a57..ace0dfa567 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -3,6 +3,7 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -13,29 +14,30 @@ import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.multipart.MultipartFile; +import stirling.software.common.cluster.inprocess.LocalDiskFileStore; + class FileStorageTest { @TempDir Path tempDir; @Mock private FileOrUploadService fileOrUploadService; - @InjectMocks private FileStorage fileStorage; + private FileStorage fileStorage; private MultipartFile mockFile; @BeforeEach - void setUp() { + void setUp() throws IOException { MockitoAnnotations.openMocks(this); - ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString()); + fileStorage = + new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString())); // Create a mock MultipartFile mockFile = mock(MultipartFile.class); @@ -47,17 +49,7 @@ class FileStorageTest { void testStoreFile() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - when(mockFile.getBytes()).thenReturn(fileContent); - - // Set up mock to handle transferTo by writing the file - doAnswer( - invocation -> { - java.io.File file = invocation.getArgument(0); - Files.write(file.toPath(), fileContent); - return null; - }) - .when(mockFile) - .transferTo(any(java.io.File.class)); + when(mockFile.getInputStream()).thenReturn(new ByteArrayInputStream(fileContent)); // Act String fileId = fileStorage.storeFile(mockFile); @@ -65,7 +57,7 @@ class FileStorageTest { // Assert assertNotNull(fileId); assertTrue(Files.exists(tempDir.resolve(fileId))); - verify(mockFile).transferTo(any(java.io.File.class)); + assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId))); } @Test @@ -247,11 +239,11 @@ class FileStorageTest { filesBefore = s.count(); } - // Act + Assert: IOException must propagate out — not be swallowed. + // Act + Assert: IOException must propagate out - not be swallowed. assertThrows( IOException.class, () -> fileStorage.storeFromResource(flakyResource, "n.pdf")); - // Assert: no partial file lingers under the storage directory — the finally + // Assert: no partial file lingers under the storage directory - the finally // branch's deleteIfExists must have cleaned it up. long filesAfter; try (Stream s = Files.list(tempDir)) { diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerJobStoreDelegationTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerJobStoreDelegationTest.java new file mode 100644 index 0000000000..1316c3c137 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerJobStoreDelegationTest.java @@ -0,0 +1,120 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStoreEntry; +import stirling.software.common.cluster.JobStoreEntry.JobState; +import stirling.software.common.cluster.inprocess.InProcessClusterBackplane; +import stirling.software.common.cluster.inprocess.InProcessJobStore; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.JobResult; + +class TaskManagerJobStoreDelegationTest { + + @Mock private FileStorage fileStorage; + + private InProcessJobStore jobStore; + private ClusterBackplane backplane; + private TaskManager taskManager; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + jobStore = spy(new InProcessJobStore()); + backplane = new InProcessClusterBackplane(new ApplicationProperties()); + taskManager = new TaskManager(fileStorage, jobStore, backplane); + ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); + } + + @Test + void createTaskWritesPendingEntry() { + taskManager.createTask("job-1"); + JobStoreEntry entry = jobStore.get("job-1").orElseThrow(); + assertEquals(JobState.PENDING, entry.state()); + assertEquals(backplane.localNodeId(), entry.owningNodeId()); + } + + @Test + void setCompleteFlipsToComplete() { + taskManager.createTask("job-2"); + taskManager.setResult("job-2", "ok"); + taskManager.setComplete("job-2"); + JobStoreEntry entry = jobStore.get("job-2").orElseThrow(); + assertEquals(JobState.COMPLETE, entry.state()); + } + + @Test + void setErrorFlipsToFailed() { + taskManager.createTask("job-3"); + taskManager.setError("job-3", "boom"); + JobStoreEntry entry = jobStore.get("job-3").orElseThrow(); + assertEquals(JobState.FAILED, entry.state()); + assertEquals("boom", entry.error()); + } + + @Test + void cleanupOldJobsIsNoopWhenBackplaneIsNotInProcess() { + ClusterBackplane mockedValkeyBackplane = + new ClusterBackplane() { + @Override + public boolean isHealthy() { + return true; + } + + @Override + public String backplaneType() { + return "valkey"; + } + + @Override + public String localNodeId() { + return "node-1"; + } + + @Override + public boolean shouldRunLocalCleanup() { + return false; + } + }; + TaskManager tm = new TaskManager(fileStorage, jobStore, mockedValkeyBackplane); + ReflectionTestUtils.setField(tm, "jobResultExpiryMinutes", 30); + tm.createTask("job-4"); + tm.setComplete("job-4"); + ageJobPastExpiry(tm, "job-4"); + tm.cleanupOldJobs(); + // cleanup must short-circuit before touching jobStore in cluster mode; the backplane + // TTL owns expiry there. If the gate fired correctly, delete is never called. + verify(jobStore, never()).delete(any()); + } + + @Test + void cleanupOldJobsDeletesFromJobStoreWhenBackplaneIsInProcess() { + taskManager.createTask("job-5"); + taskManager.setComplete("job-5"); + ageJobPastExpiry(taskManager, "job-5"); + taskManager.cleanupOldJobs(); + verify(jobStore).delete("job-5"); + } + + @SuppressWarnings("unchecked") + private static void ageJobPastExpiry(TaskManager tm, String jobId) { + var jobResults = + (java.util.Map) ReflectionTestUtils.getField(tm, "jobResults"); + JobResult result = jobResults.get(jobId); + ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(2)); + ReflectionTestUtils.setField(result, "complete", true); + } +} 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 7a61270d1d..9d880d3451 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 @@ -1,20 +1,28 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import java.time.LocalDateTime; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; 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; 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.cluster.JobStoreEntry; +import stirling.software.common.cluster.JobStoreEntry.JobState; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; import stirling.software.common.model.job.ResultFile; @@ -22,6 +30,8 @@ import stirling.software.common.model.job.ResultFile; class TaskManagerTest { @Mock private FileStorage fileStorage; + @Mock private JobStore jobStore; + @Mock private ClusterBackplane clusterBackplane; @InjectMocks private TaskManager taskManager; @@ -30,6 +40,10 @@ class TaskManagerTest { @BeforeEach void setUp() { closeable = MockitoAnnotations.openMocks(this); + // Treat the backplane as in-process so cleanupOldJobs is not short-circuited. + lenient().when(clusterBackplane.backplaneType()).thenReturn("inprocess"); + lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node"); + lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true); ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); } @@ -270,6 +284,33 @@ class TaskManagerTest { verify(fileStorage).deleteFile("file-id"); } + @Test + void testCleanupOldJobs_NoOpWhenBackplaneOwnsExpiry() { + // When the backplane reports it should NOT run local cleanup (e.g. a distributed + // backplane with its own TTL), the cleanup loop must leave local state untouched. + when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false); + + // Seed an old completed job that would normally be removed. + String oldJobId = "old-job-distributed"; + taskManager.createTask(oldJobId); + JobResult oldJob = taskManager.getJobResult(oldJobId); + ReflectionTestUtils.setField(oldJob, "completedAt", LocalDateTime.now().minusHours(1)); + ReflectionTestUtils.setField(oldJob, "complete", true); + + Map jobResultsMap = + (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); + assertNotNull(jobResultsMap); + assertTrue(jobResultsMap.containsKey(oldJobId)); + + // Act + taskManager.cleanupOldJobs(); + + // 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()); + } + @Test void testShutdown() { // This mainly tests that the shutdown method doesn't throw exceptions @@ -310,4 +351,33 @@ class TaskManagerTest { // Assert assertFalse(result); } + + @Test + void testWriteThroughOnUpdate() { + // Mutating calls must write through to the injected JobStore. + String jobId = "write-through-job"; + taskManager.createTask(jobId); + taskManager.setResult(jobId, "done"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JobStoreEntry.class); + verify(jobStore, atLeast(2)).put(captor.capture(), any()); + + JobStoreEntry last = captor.getValue(); + assertEquals(jobId, last.jobId()); + assertEquals(JobState.COMPLETE, last.state()); + assertEquals("test-node", last.owningNodeId()); + } + + @Test + void testFindJobKeyByFileId_FallsBackToJobStore() { + // When the file id is not in the local map, TaskManager delegates to JobStore. + String fileId = "remote-file-id"; + String expectedJobKey = "remote-job-key"; + when(jobStore.findJobIdByFileId(fileId)).thenReturn(Optional.of(expectedJobKey)); + + String actual = taskManager.findJobKeyByFileId(fileId); + + assertEquals(expectedJobKey, actual); + verify(jobStore).findJobIdByFileId(fileId); + } } diff --git a/app/common/src/test/java/stirling/software/common/util/GeneralUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsTest.java index bf6be29e3a..4a746f7880 100644 --- a/app/common/src/test/java/stirling/software/common/util/GeneralUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsTest.java @@ -2,6 +2,8 @@ package stirling.software.common.util; import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -12,9 +14,47 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import stirling.software.common.configuration.InstallationPathConfig; public class GeneralUtilsTest { + // Regression guard for the SSO auto-login persistence bug: the admin UI writes camelCase + // proFeatures keys, so saveKeyToSettings must match (and persist) them against the camelCase + // settings.yml.template. A case mismatch makes YamlHelper.updateValue silently no-op. + @Test + void saveKeyToSettings_persistsCamelCaseProFeatureKeys(@TempDir Path tempDir) throws Exception { + Path settings = tempDir.resolve("settings.yml"); + Files.writeString( + settings, + """ + premium: + proFeatures: + ssoAutoLogin: false + customMetadata: + author: username + """); + + try (MockedStatic mocked = + Mockito.mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString()); + + GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true); + GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "alice"); + } + + YamlHelper reloaded = new YamlHelper(settings); + assertEquals( + "true", reloaded.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin")); + assertEquals( + "alice", + reloaded.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "author")); + } + @Test void testParsePageListWithAll() { List result = GeneralUtils.parsePageList(new String[] {"all"}, 5, false); 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 f1ee3fa36b..4f0f3e372a 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 @@ -98,6 +98,17 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_filesRouteOwnedByFrontend() { + // /files and /files/ are FileManagerView routes - they + // must fall through to the SPA index.html, not get blocked by the + // backend auth filter. Regression test for direct-nav/refresh on + // the file manager returning a 401 JSON. + assertTrue(RequestUriUtils.isFrontendRoute("", "/files")); + assertTrue( + RequestUriUtils.isFrontendRoute("", "/files/3331910a-4155-4f71-8111-e38c896bc458")); + } + @Test void testIsFrontendRoute_pathWithExtension() { assertFalse(RequestUriUtils.isFrontendRoute("", "/some/file.pdf")); @@ -183,7 +194,7 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareRootNotPublic() { - // Avoid matching bare "/share" or "/share/" — must have a token segment + // Avoid matching bare "/share" or "/share/" - must have a token segment assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share", "")); assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/", "")); } @@ -197,7 +208,7 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareApiStillProtected() { - // Share-link data APIs must NOT be public — they enforce auth + access checks + // Share-link data APIs must NOT be public - they enforce auth + access checks assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/storage/share-links/abc123", "")); assertFalse( RequestUriUtils.isPublicAuthEndpoint( diff --git a/app/core/build.gradle b/app/core/build.gradle index b85b4544f3..54bdd7bd76 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -196,9 +196,13 @@ if (buildPrototypes) { } def frontendBuildTask = "frontend:build:${frontendMode}" +// Workspace root holds package.json and node_modules (shared across editor / +// future portal). Editor-specific paths (src, public, dist, tauri) live one +// level deeper under frontend/editor/. When the portal lands as an embedded +// app, add a sibling frontendPortalDir / frontendPortalDistDir alongside. def frontendDir = file('../../frontend') def frontendEditorDir = file('../../frontend/editor') -def frontendDistDir = file('../../frontend/editor/dist') +def frontendEditorDistDir = file('../../frontend/editor/dist') def resourcesStaticDir = file('src/main/resources/static') def generatedFrontendPaths = [ 'assets', @@ -263,13 +267,13 @@ tasks.register('npmBuild', Exec) { doNotTrackState("Frontend build depends on untracked npmInstall task") enabled = buildWithFrontend group = 'frontend' - description = 'Build frontend application' + description = 'Build editor frontend application' workingDir file('../..') commandLine = ['task', frontendBuildTask] inputs.dir(new File(frontendEditorDir, 'src')) inputs.dir(new File(frontendEditorDir, 'public')) inputs.file(new File(frontendDir, 'package.json')) - outputs.dir(frontendDistDir) + outputs.dir(frontendEditorDistDir) // Show live output standardOutput = System.out @@ -280,17 +284,17 @@ tasks.register('npmBuild', Exec) { environment 'VITE_API_BASE_URL', '/' doFirst { - println "Building frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)" + println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)" } } tasks.register('copyFrontendAssets', Copy) { enabled = buildWithFrontend group = 'frontend' - description = 'Copy frontend build to static resources' + description = 'Copy editor frontend build to static resources' dependsOn npmBuild dependsOn cleanFrontendAssets - from(frontendDistDir) { + from(frontendEditorDistDir) { // Exclude files that conflict with backend static resources exclude 'robots.txt' // Backend already has this exclude 'favicon.ico' // Backend already has this @@ -298,7 +302,7 @@ tasks.register('copyFrontendAssets', Copy) { into resourcesStaticDir duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed doFirst { - println "Copying frontend build from ${frontendDistDir} to ${resourcesStaticDir}..." + println "Copying frontend build from ${frontendEditorDistDir} to ${resourcesStaticDir}..." println "Backend static resources will be preserved" } doLast { @@ -330,7 +334,7 @@ tasks.named('copyFrontendAssets').configure { } if (buildWithFrontend) { - println "Frontend build enabled - JAR will include React frontend (mode=${frontendMode})" + println "Editor frontend build enabled - JAR will include React frontend (mode=${frontendMode})" processResources.dependsOn copyFrontendAssets } else { println "Frontend build disabled - JAR will be backend-only with API landing page" diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 84f4c8f6e6..ec4420fb1f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -32,6 +32,7 @@ import stirling.software.SPDF.model.json.PdfJsonTextElement; import stirling.software.SPDF.service.PdfJsonConversionService; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; +import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.general.EditTextOperation; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -75,7 +76,10 @@ public class EditTextController { new StringToArrayListPropertyEditor<>(EditTextOperation.class)); } - @AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text") + @AutoJobPostMapping( + consumes = "multipart/form-data", + value = "/edit-text", + resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse @Operation( summary = "Edit text in a PDF via find and replace", diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java index 2965870bb1..fb7a55b793 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java @@ -40,7 +40,8 @@ public class ScalePagesController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final TempFileManager tempFileManager; - private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) { + private static PDRectangle getTargetSize( + String targetPDRectangle, String orientation, PDDocument sourceDocument) { if ("KEEP".equals(targetPDRectangle)) { if (sourceDocument.getNumberOfPages() == 0) { throw ExceptionUtils.createInvalidPageSizeException("KEEP"); @@ -57,18 +58,19 @@ public class ScalePagesController { } Map sizeMap = getSizeMap(); - - if (sizeMap.containsKey(targetPDRectangle)) { - return sizeMap.get(targetPDRectangle); + PDRectangle base = sizeMap.get(targetPDRectangle); + if (base == null) { + throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle); } - throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle); + if ("LANDSCAPE".equalsIgnoreCase(orientation)) { + return new PDRectangle(base.getHeight(), base.getWidth()); + } + return base; } private static Map getSizeMap() { Map sizeMap = new HashMap<>(); - - // Portrait sizes (A0-A6) sizeMap.put("A0", PDRectangle.A0); sizeMap.put("A1", PDRectangle.A1); sizeMap.put("A2", PDRectangle.A2); @@ -76,42 +78,8 @@ public class ScalePagesController { sizeMap.put("A4", PDRectangle.A4); sizeMap.put("A5", PDRectangle.A5); sizeMap.put("A6", PDRectangle.A6); - - // Landscape sizes (A0-A6) - sizeMap.put( - "A0_LANDSCAPE", - new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth())); - sizeMap.put( - "A1_LANDSCAPE", - new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth())); - sizeMap.put( - "A2_LANDSCAPE", - new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth())); - sizeMap.put( - "A3_LANDSCAPE", - new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth())); - sizeMap.put( - "A4_LANDSCAPE", - new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth())); - sizeMap.put( - "A5_LANDSCAPE", - new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth())); - sizeMap.put( - "A6_LANDSCAPE", - new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth())); - - // Portrait US sizes sizeMap.put("LETTER", PDRectangle.LETTER); sizeMap.put("LEGAL", PDRectangle.LEGAL); - - // Landscape US sizes - sizeMap.put( - "LETTER_LANDSCAPE", - new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth())); - sizeMap.put( - "LEGAL_LANDSCAPE", - new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth())); - return sizeMap; } @@ -128,13 +96,14 @@ public class ScalePagesController { throws IOException { MultipartFile file = request.getFileInput(); String targetPDRectangle = request.getPageSize(); + String orientation = request.getOrientation(); float scaleFactor = request.getScaleFactor(); try (PDDocument sourceDocument = pdfDocumentFactory.load(file); PDDocument outputDocument = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { - PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument); + PDRectangle targetSize = getTargetSize(targetPDRectangle, orientation, sourceDocument); // Create LayerUtility once outside the loop for better performance LayerUtility layerUtility = new LayerUtility(outputDocument); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java index 0d37cea558..d4516c0ed6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java @@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.HashSet; @@ -30,12 +31,13 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; @GeneralApi @Slf4j @@ -91,10 +93,14 @@ public class SplitPDFController { try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) { if (hasForm) { - writeSplitsViaReload( + // JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary, which + // breaks Fill Forms downstream. Fall back to the PDFBox load-and-remove + // path so the AcroForm (with only fields whose widgets remain on kept + // pages) is preserved. + writeSplitsViaPdfBox( sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut); } else { - writeSplitsViaSharedSource( + writeSplitsViaJpdfium( sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut); } } @@ -109,7 +115,26 @@ public class SplitPDFController { } } - private void writeSplitsViaReload( + private void writeSplitsViaJpdfium( + File source, List pageNumbers, String baseFilename, ZipOutputStream zipOut) + throws IOException { + try (PdfDocument sourceDoc = PdfDocument.open(source.toPath())) { + int previousPageNumber = 0; + for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) { + int splitPoint = pageNumbers.get(splitIndex); + try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) { + try (PdfDocument splitDoc = + PdfSplit.extractPageRange(sourceDoc, previousPageNumber, splitPoint)) { + splitDoc.save(splitTemp.getPath()); + } + writeEntry(zipOut, baseFilename, splitIndex + 1, splitTemp.getPath()); + } + previousPageNumber = splitPoint + 1; + } + } + } + + private void writeSplitsViaPdfBox( File source, List pageNumbers, String baseFilename, ZipOutputStream zipOut) throws IOException { int previousPageNumber = 0; @@ -129,33 +154,15 @@ public class SplitPDFController { } FormUtils.pruneOrphanedFormFields(splitDoc); writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc); - } catch (Exception e) { - ExceptionUtils.logException("document splitting and saving", e); - throw e; } } } - private void writeSplitsViaSharedSource( - File source, List pageNumbers, String baseFilename, ZipOutputStream zipOut) + private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, Path pdfPath) throws IOException { - try (PDDocument sourceDoc = pdfDocumentFactory.load(source)) { - int previousPageNumber = 0; - for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) { - int splitPoint = pageNumbers.get(splitIndex); - try (PDDocument splitDoc = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) { - for (int i = previousPageNumber; i <= splitPoint; i++) { - splitDoc.addPage(sourceDoc.getPage(i)); - } - previousPageNumber = splitPoint + 1; - writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc); - } catch (Exception e) { - ExceptionUtils.logException("document splitting and saving", e); - throw e; - } - } - } + zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + index + ".pdf")); + Files.copy(pdfPath, zipOut); + zipOut.closeEntry(); } private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, PDDocument doc) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java index 27d5330820..6ad85d0c92 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java @@ -1,6 +1,10 @@ package stirling.software.SPDF.controller.api; +import java.io.File; +import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -8,9 +12,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; -import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -35,10 +37,13 @@ import stirling.software.common.model.PdfMetadata; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; @GeneralApi @Slf4j @@ -51,72 +56,36 @@ public class SplitPdfByChaptersController { private final TempFileManager tempFileManager; - private static List extractOutlineItems( - PDDocument sourceDocument, - PDOutlineItem current, - List bookmarks, - PDOutlineItem nextParent, + private static void collectBookmarks( + List source, + List out, int level, - int maxLevel) - throws Exception { - - while (current != null) { - - String currentTitle = current.getTitle().replace("/", ""); - int firstPage = - sourceDocument.getPages().indexOf(current.findDestinationPage(sourceDocument)); - PDOutlineItem child = current.getFirstChild(); - PDOutlineItem nextSibling = current.getNextSibling(); - int endPage; - if (child != null && level < maxLevel) { - endPage = - sourceDocument - .getPages() - .indexOf(child.findDestinationPage(sourceDocument)); - } else if (nextSibling != null) { - endPage = - sourceDocument - .getPages() - .indexOf(nextSibling.findDestinationPage(sourceDocument)); - } else if (nextParent != null) { - - endPage = - sourceDocument - .getPages() - .indexOf(nextParent.findDestinationPage(sourceDocument)); - } else { - endPage = -2; - /* - happens when we have something like this: - Outline Item 2 - Outline Item 2.1 - Outline Item 2.1.1 - Outline Item 2.2 - Outline 2.2.1 - Outline 2.2.2 <--- this item neither has an immediate next parent nor an immediate next sibling - Outline Item 3 - */ + int maxLevel) { + for (stirling.software.jpdfium.doc.Bookmark bm : source) { + if (!bm.isInternal()) { + continue; } - if (!bookmarks.isEmpty() - && bookmarks.get(bookmarks.size() - 1).getEndPage() == -2 - && firstPage - >= bookmarks - .get(bookmarks.size() - 1) - .getStartPage()) { // for handling the above-mentioned case - Bookmark previousBookmark = bookmarks.get(bookmarks.size() - 1); - previousBookmark.setEndPage(firstPage); + String title = bm.title() == null ? "" : bm.title().replace("/", ""); + int firstPage = Math.max(0, bm.pageIndex()); + out.add(new Bookmark(title, firstPage, -2)); + if (bm.hasChildren() && level < maxLevel) { + collectBookmarks(bm.children(), out, level + 1, maxLevel); } - bookmarks.add(new Bookmark(currentTitle, firstPage, endPage)); - - // Recursively process children - if (child != null && level < maxLevel) { - extractOutlineItems( - sourceDocument, child, bookmarks, nextSibling, level + 1, maxLevel); - } - - current = nextSibling; } - return bookmarks; + } + + private static void assignEndPages(List bookmarks, int totalPages) { + for (int i = 0; i < bookmarks.size(); i++) { + Bookmark current = bookmarks.get(i); + int next = -1; + for (int j = i + 1; j < bookmarks.size(); j++) { + if (bookmarks.get(j).getStartPage() >= current.getStartPage()) { + next = bookmarks.get(j).getStartPage(); + break; + } + } + current.setEndPage(next == -1 ? totalPages : next); + } } @AutoJobPostMapping( @@ -134,46 +103,41 @@ public class SplitPdfByChaptersController { MultipartFile file = request.getFileInput(); boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata()); - Integer bookmarkLevel = - request.getBookmarkLevel(); // levels start from 0 (top most bookmarks) + Integer bookmarkLevel = request.getBookmarkLevel(); if (bookmarkLevel < 0) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "bookmark level"); } - try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) { - PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline(); + try (TempFile sourceTempFile = new TempFile(tempFileManager, ".pdf")) { + Files.copy( + file.getInputStream(), + sourceTempFile.getPath(), + StandardCopyOption.REPLACE_EXISTING); - if (outline == null) { - log.warn("No outline found for {}", file.getOriginalFilename()); - throw ExceptionUtils.createIllegalArgumentException( - "error.pdfBookmarksNotFound", "No PDF bookmarks/outline found in document"); - } List bookmarks = new ArrayList<>(); - try { - bookmarks = - extractOutlineItems( - sourceDocument, - outline.getFirstChild(), - bookmarks, - outline.getFirstChild().getNextSibling(), - 0, - bookmarkLevel); - // to handle last page edge case - bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages()); - - } catch (Exception e) { - ExceptionUtils.logException("outline extraction", e); - throw e; + int totalPages; + try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) { + totalPages = sourceDocument.pageCount(); + List roots = sourceDocument.bookmarks(); + if (roots == null || roots.isEmpty()) { + log.warn("No outline found for {}", file.getOriginalFilename()); + throw ExceptionUtils.createIllegalArgumentException( + "error.pdfBookmarksNotFound", + "No PDF bookmarks/outline found in document"); + } + collectBookmarks(roots, bookmarks, 0, bookmarkLevel); + if (bookmarks.isEmpty()) { + log.warn("No outline found for {}", file.getOriginalFilename()); + throw ExceptionUtils.createIllegalArgumentException( + "error.pdfBookmarksNotFound", + "No PDF bookmarks/outline found in document"); + } + assignEndPages(bookmarks, totalPages); } boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates()); if (!allowDuplicates) { - /* - duplicates are generated when multiple bookmarks correspond to the same page, - if the user doesn't want duplicates mergeBookmarksThatCorrespondToSamePage() method will merge the titles of all - the bookmarks that correspond to the same page, and treat them as a single bookmark - */ bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks); } for (Bookmark bookmark : bookmarks) { @@ -184,7 +148,23 @@ public class SplitPdfByChaptersController { bookmark.getEndPage()); } - TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata); + PdfMetadata metadata = null; + boolean hasForm = false; + if (includeMetadata) { + try (PDDocument metaDoc = pdfDocumentFactory.load(sourceTempFile.getFile())) { + metadata = pdfMetadataService.extractMetadataFromPdf(metaDoc); + PDAcroForm acroForm = metaDoc.getDocumentCatalog().getAcroForm(null); + hasForm = acroForm != null; + } + } else { + try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) { + hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null; + } + } + + TempFile zipTempFile = + createZipFile( + sourceTempFile.getFile(), bookmarks, metadata, totalPages, hasForm); String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), ""); return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip"); } @@ -216,43 +196,40 @@ public class SplitPdfByChaptersController { } private TempFile createZipFile( - PDDocument sourceDocument, List bookmarks, boolean includeMetadata) + File sourceFile, + List bookmarks, + PdfMetadata metadata, + int totalPages, + boolean hasForm) throws Exception { - PdfMetadata metadata = - includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null; String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d "; TempFile zipTempFile = new TempFile(tempFileManager, ".zip"); - try { - try (ZipOutputStream zipOut = - new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) { + try (ZipOutputStream zipOut = + new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) { + if (hasForm) { + // JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary. For form + // PDFs, do the per-chapter extract via PDFBox so form fields survive the split. for (int i = 0; i < bookmarks.size(); i++) { - Bookmark bookmark = bookmarks.get(i); - try (PDDocument splitDocument = new PDDocument()) { - boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage()); - - for (int pg = bookmark.getStartPage(); - pg < bookmark.getEndPage() + (isSinglePage ? 1 : 0); - pg++) { - PDPage page = sourceDocument.getPage(pg); - splitDocument.addPage(page); - log.debug("Adding page {} to split document", pg); - } - if (includeMetadata) { - pdfMetadataService.setMetadataToPdf(splitDocument, metadata); - } - - // split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf" - String fileName = - String.format(Locale.ROOT, fileNumberFormatter, i) - + bookmark.getTitle() - + ".pdf"; - zipOut.putNextEntry(new ZipEntry(fileName)); - splitDocument.save(zipOut); - zipOut.closeEntry(); - log.debug("Wrote split document {} to zip file", fileName); - } catch (Exception e) { - ExceptionUtils.logException("document splitting and saving", e); - throw e; + writeChapterViaPdfBox( + sourceFile, + bookmarks.get(i), + i, + fileNumberFormatter, + metadata, + zipOut, + totalPages); + } + } else { + try (PdfDocument sourceDocument = PdfDocument.open(sourceFile.toPath())) { + for (int i = 0; i < bookmarks.size(); i++) { + writeChapterViaJpdfium( + sourceDocument, + bookmarks.get(i), + i, + fileNumberFormatter, + metadata, + zipOut, + totalPages); } } } @@ -265,6 +242,97 @@ public class SplitPdfByChaptersController { throw e; } } + + private void writeChapterViaJpdfium( + PdfDocument sourceDocument, + Bookmark bookmark, + int index, + String fileNumberFormatter, + PdfMetadata metadata, + ZipOutputStream zipOut, + int totalPages) + throws Exception { + int[] range = clampRange(bookmark, totalPages); + int from = range[0]; + int to = range[1]; + try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) { + try (PdfDocument splitDoc = PdfSplit.extractPageRange(sourceDocument, from, to)) { + splitDoc.save(splitTemp.getPath()); + } + Path finalPath = splitTemp.getPath(); + TempFile metaTemp = null; + try { + if (metadata != null) { + metaTemp = new TempFile(tempFileManager, ".pdf"); + try (PDDocument doc = pdfDocumentFactory.load(splitTemp.getFile())) { + pdfMetadataService.setMetadataToPdf(doc, metadata); + doc.save(metaTemp.getFile()); + } + finalPath = metaTemp.getPath(); + } + writeZipEntry(zipOut, fileNumberFormatter, index, bookmark.getTitle(), finalPath); + } finally { + if (metaTemp != null) { + metaTemp.close(); + } + } + } + } + + private void writeChapterViaPdfBox( + File sourceFile, + Bookmark bookmark, + int index, + String fileNumberFormatter, + PdfMetadata metadata, + ZipOutputStream zipOut, + int totalPages) + throws Exception { + int[] range = clampRange(bookmark, totalPages); + int from = range[0]; + int to = range[1]; + try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) { + for (int p = doc.getNumberOfPages() - 1; p >= 0; p--) { + if (p < from || p > to) { + doc.removePage(p); + } + } + FormUtils.pruneOrphanedFormFields(doc); + if (metadata != null) { + pdfMetadataService.setMetadataToPdf(doc, metadata); + } + String fileName = + String.format(Locale.ROOT, fileNumberFormatter, index) + + bookmark.getTitle() + + ".pdf"; + zipOut.putNextEntry(new ZipEntry(fileName)); + doc.save(zipOut); + zipOut.closeEntry(); + log.debug("Wrote split document {} to zip file", fileName); + } + } + + private void writeZipEntry( + ZipOutputStream zipOut, + String fileNumberFormatter, + int index, + String title, + Path pdfPath) + throws IOException { + String fileName = String.format(Locale.ROOT, fileNumberFormatter, index) + title + ".pdf"; + zipOut.putNextEntry(new ZipEntry(fileName)); + Files.copy(pdfPath, zipOut); + zipOut.closeEntry(); + log.debug("Wrote split document {} to zip file", fileName); + } + + private static int[] clampRange(Bookmark bookmark, int totalPages) { + boolean isSinglePage = bookmark.getStartPage() == bookmark.getEndPage(); + int from = Math.min(Math.max(0, bookmark.getStartPage()), totalPages - 1); + int rawEnd = isSinglePage ? bookmark.getEndPage() : bookmark.getEndPage() - 1; + int to = Math.min(Math.max(from, rawEnd), totalPages - 1); + return new int[] {from, to}; + } } @Data diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java index ead2f12e14..e8ff5d9e23 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java @@ -1,9 +1,9 @@ package stirling.software.SPDF.controller.api; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.HashSet; @@ -13,7 +13,6 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -37,6 +36,8 @@ import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; @GeneralApi @Slf4j @@ -75,23 +76,27 @@ public class SplitPdfBySizeController { sourceTempFile.getPath(), StandardCopyOption.REPLACE_EXISTING); - try (PDDocument sourceDocument = - pdfDocumentFactory.load(sourceTempFile.getFile(), true)) { - boolean hasForm = sourceDocument.getDocumentCatalog().getAcroForm(null) != null; - List> ranges = computeRanges(request, sourceDocument); + boolean hasForm; + try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) { + hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null; + } + + try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) { + List ranges = computeRanges(request, sourceDocument); int fileIndex = 1; - for (List range : ranges) { - if (range.isEmpty()) { + for (int[] range : ranges) { + if (range.length == 0) { continue; } - if (hasForm) { - writeRangeViaReload( - sourceTempFile.getFile(), range, zipOut, filename, fileIndex++); - } else { - writeRangeViaSharedSource( - sourceDocument, range, zipOut, filename, fileIndex++); - } + writeRange( + sourceDocument, + sourceTempFile.getFile(), + range, + zipOut, + filename, + fileIndex++, + hasForm); } } } @@ -104,29 +109,54 @@ public class SplitPdfBySizeController { } } - private List> computeRanges( - SplitPdfBySizeOrCountRequest request, PDDocument sourceDocument) throws IOException { + private List computeRanges(SplitPdfBySizeOrCountRequest request, PdfDocument sourceDoc) + throws IOException { int type = request.getSplitType(); String value = request.getSplitValue(); if (type == 0) { - return computeSizeRanges(sourceDocument, GeneralUtils.convertSizeToBytes(value)); + return computeSizeRanges(sourceDoc, GeneralUtils.convertSizeToBytes(value)); } else if (type == 1) { - return computePageCountRanges(sourceDocument, Integer.parseInt(value)); + return computePageCountRanges(sourceDoc, Integer.parseInt(value)); } else if (type == 2) { - return computeDocCountRanges(sourceDocument, Integer.parseInt(value)); + return computeDocCountRanges(sourceDoc, Integer.parseInt(value)); } throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "split type: " + type); } - private void writeRangeViaReload( + private void writeRange( + PdfDocument sourceDoc, File sourceFile, - List keepIndices, + int[] range, + ZipOutputStream zipOut, + String baseFilename, + int fileIndex, + boolean hasForm) + throws IOException { + if (hasForm) { + // JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary, breaking form + // fields downstream. For form-bearing PDFs, do the extract via PDFBox so the + // AcroForm survives (pruneOrphanedFormFields removes references to dropped pages). + writeRangeViaPdfBox(sourceFile, range, zipOut, baseFilename, fileIndex); + } else { + try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) { + extractRangeToFile(sourceDoc, range, splitTemp.getPath()); + writeEntry(zipOut, baseFilename, fileIndex, splitTemp.getPath()); + } + } + } + + private void writeRangeViaPdfBox( + File sourceFile, + int[] range, ZipOutputStream zipOut, String baseFilename, int fileIndex) throws IOException { - Set keep = new HashSet<>(keepIndices); + Set keep = new HashSet<>(); + for (int p : range) { + keep.add(p); + } try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) { for (int i = doc.getNumberOfPages() - 1; i >= 0; i--) { if (!keep.contains(i)) { @@ -134,50 +164,41 @@ public class SplitPdfBySizeController { } } FormUtils.pruneOrphanedFormFields(doc); - writeEntry(zipOut, baseFilename, fileIndex, doc); + zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf")); + doc.save(zipOut); + zipOut.closeEntry(); } } - private void writeRangeViaSharedSource( - PDDocument sourceDocument, - List keepIndices, - ZipOutputStream zipOut, - String baseFilename, - int fileIndex) + private void extractRangeToFile(PdfDocument sourceDoc, int[] range, Path outputPath) throws IOException { - try (PDDocument doc = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { - for (int p : keepIndices) { - doc.addPage(sourceDocument.getPage(p)); - } - writeEntry(zipOut, baseFilename, fileIndex, doc); + int from = range[0]; + int to = range[range.length - 1]; + try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) { + split.save(outputPath); } } private void writeEntry( - ZipOutputStream zipOut, String baseFilename, int fileIndex, PDDocument doc) + ZipOutputStream zipOut, String baseFilename, int fileIndex, Path pdfPath) throws IOException { zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf")); - doc.save(zipOut); + Files.copy(pdfPath, zipOut); zipOut.closeEntry(); } - /** Page-index ranges each output should contain. AcroForm overhead isn't modeled. */ - private List> computeSizeRanges(PDDocument sourceDocument, long maxBytes) - throws IOException { - List> ranges = new ArrayList<>(); - List currentRange = new ArrayList<>(); - int totalPages = sourceDocument.getNumberOfPages(); + /** Returns contiguous page-index ranges fitting within {@code maxBytes}. */ + private List computeSizeRanges(PdfDocument sourceDoc, long maxBytes) throws IOException { + List ranges = new ArrayList<>(); + int totalPages = sourceDoc.pageCount(); int baseCheckFrequency = 5; - - PDDocument scratch = new PDDocument(); - try { + int rangeStart = 0; + int rangeEnd = -1; + try (TempFile probe = new TempFile(tempFileManager, ".pdf")) { + File probeFile = probe.getFile(); for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { - PDPage page = sourceDocument.getPage(pageIndex); - scratch.addPage(new PDPage(page.getCOSObject())); - currentRange.add(pageIndex); - - int pageAdded = currentRange.size(); + rangeEnd = pageIndex; + int pageAdded = rangeEnd - rangeStart + 1; boolean shouldCheckSize = (pageAdded % baseCheckFrequency == 0) || (pageIndex == totalPages - 1) @@ -185,117 +206,110 @@ public class SplitPdfBySizeController { if (!shouldCheckSize) { continue; } - - long actualSize; - try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { - scratch.save(out); - actualSize = out.size(); - } + long actualSize = saveRange(sourceDoc, rangeStart, rangeEnd, probeFile); if (actualSize > maxBytes) { - if (scratch.getNumberOfPages() > 1) { - scratch.removePage(scratch.getNumberOfPages() - 1); - currentRange.remove(currentRange.size() - 1); - pageIndex--; // retry this page in the next chunk + if (pageAdded > 1) { + rangeEnd = pageIndex - 1; + pageIndex--; } - ranges.add(new ArrayList<>(currentRange)); - currentRange.clear(); - scratch.close(); - scratch = new PDDocument(); + ranges.add(buildRange(rangeStart, rangeEnd)); + rangeStart = rangeEnd + 1; + rangeEnd = rangeStart - 1; } else if (pageIndex < totalPages - 1 && actualSize < maxBytes * 0.75) { - int extraPagesAdded = - lookAheadFit(scratch, sourceDocument, pageIndex, maxBytes); - for (int i = 0; i < extraPagesAdded; i++) { - int extra = pageIndex + 1 + i; - scratch.addPage(new PDPage(sourceDocument.getPage(extra).getCOSObject())); - currentRange.add(extra); - } - pageIndex += extraPagesAdded; + int extra = + lookAheadFit( + sourceDoc, + rangeStart, + pageIndex, + maxBytes, + totalPages, + probeFile); + pageIndex += extra; + rangeEnd = pageIndex; } } - - if (!currentRange.isEmpty()) { - ranges.add(new ArrayList<>(currentRange)); - } - } finally { - scratch.close(); + } + if (rangeEnd >= rangeStart) { + ranges.add(buildRange(rangeStart, rangeEnd)); } return ranges; } - /** Speculatively tries up to 5 next pages; returns how many fit under {@code maxBytes}. */ - private int lookAheadFit(PDDocument scratch, PDDocument source, int pageIndex, long maxBytes) + private long saveRange(PdfDocument sourceDoc, int from, int to, File output) throws IOException { - int totalPages = source.getNumberOfPages(); - int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1); - if (pagesToLookAhead == 0) { - return 0; + try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) { + split.save(output.toPath()); } - - int extraPagesAdded = 0; - try (PDDocument testDoc = new PDDocument()) { - for (int i = 0; i < scratch.getNumberOfPages(); i++) { - testDoc.addPage(new PDPage(scratch.getPage(i).getCOSObject())); - } - for (int i = 0; i < pagesToLookAhead; i++) { - testDoc.addPage(new PDPage(source.getPage(pageIndex + 1 + i).getCOSObject())); - long testSize; - try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { - testDoc.save(out); - testSize = out.size(); - } - if (testSize > maxBytes) { - break; - } - extraPagesAdded++; - } - } - return extraPagesAdded; + return output.length(); } - private List> computePageCountRanges(PDDocument sourceDocument, int pageCount) { + private int lookAheadFit( + PdfDocument sourceDoc, + int rangeStart, + int currentEnd, + long maxBytes, + int totalPages, + File probeFile) + throws IOException { + int pagesToLookAhead = Math.min(5, totalPages - currentEnd - 1); + int extra = 0; + for (int i = 0; i < pagesToLookAhead; i++) { + int trialEnd = currentEnd + 1 + i; + long size = saveRange(sourceDoc, rangeStart, trialEnd, probeFile); + if (size > maxBytes) { + break; + } + extra++; + } + return extra; + } + + private List computePageCountRanges(PdfDocument sourceDoc, int pageCount) { if (pageCount <= 0) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "page count: " + pageCount); } - int totalPages = sourceDocument.getNumberOfPages(); - List> ranges = new ArrayList<>(); - List current = new ArrayList<>(pageCount); - for (int i = 0; i < totalPages; i++) { - current.add(i); - if (current.size() == pageCount) { - ranges.add(current); - current = new ArrayList<>(pageCount); - } - } - if (!current.isEmpty()) { - ranges.add(current); + int totalPages = sourceDoc.pageCount(); + List ranges = new ArrayList<>(); + int start = 0; + while (start < totalPages) { + int end = Math.min(start + pageCount - 1, totalPages - 1); + ranges.add(buildRange(start, end)); + start = end + 1; } return ranges; } - private List> computeDocCountRanges( - PDDocument sourceDocument, int documentCount) { + private List computeDocCountRanges(PdfDocument sourceDoc, int documentCount) { if (documentCount <= 0) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "document count: " + documentCount); } - int totalPages = sourceDocument.getNumberOfPages(); + int totalPages = sourceDoc.pageCount(); int pagesPerDocument = totalPages / documentCount; int extraPages = totalPages % documentCount; - - List> ranges = new ArrayList<>(); + List ranges = new ArrayList<>(); int cursor = 0; for (int i = 0; i < documentCount; i++) { int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0); - List range = new ArrayList<>(pagesToAdd); - for (int j = 0; j < pagesToAdd; j++) { - range.add(cursor++); + if (pagesToAdd == 0) { + continue; } - ranges.add(range); + int end = cursor + pagesToAdd - 1; + ranges.add(buildRange(cursor, end)); + cursor = end + 1; } return ranges; } + + private static int[] buildRange(int start, int end) { + int[] range = new int[end - start + 1]; + for (int i = 0; i < range.length; i++) { + range[i] = start + i; + } + return range; + } } 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 19e564418a..1255a2a228 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 @@ -275,7 +275,10 @@ public class ConvertImgPDFController { GeneralUtils.generateFilename(file[0].getOriginalFilename(), "_converted.pdf")); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/cbz/pdf", + resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @Operation( summary = "Convert CBZ comic book archive to PDF", description = @@ -301,7 +304,10 @@ public class ConvertImgPDFController { return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/pdf/cbz", + resourceWeight = ResourceWeight.LARGE_WEIGHT) @Operation( summary = "Convert PDF to CBZ comic book archive", description = @@ -324,7 +330,10 @@ public class ConvertImgPDFController { return WebResponseUtils.zipFileToWebResponse(cbzFile, filename); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/cbr/pdf", + resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @Operation( summary = "Convert CBR comic book archive to PDF", description = @@ -350,7 +359,10 @@ public class ConvertImgPDFController { return WebResponseUtils.bytesToWebResponse(pdfBytes, filename); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/pdf/cbr", + resourceWeight = ResourceWeight.LARGE_WEIGHT) @Operation( summary = "Convert PDF to CBR comic book archive", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java index 924013abad..0e272678c4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java @@ -141,7 +141,8 @@ public class AttachmentController { @AutoJobPostMapping( consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - value = "/extract-attachments") + value = "/extract-attachments", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @Operation( summary = "Extract attachments from PDF", description = @@ -176,7 +177,10 @@ public class AttachmentController { } } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/list-attachments", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @Operation( summary = "List attachments in PDF", description = @@ -193,7 +197,8 @@ public class AttachmentController { @AutoJobPostMapping( consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - value = "/rename-attachment") + value = "/rename-attachment", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse @Operation( summary = "Rename attachment in PDF", @@ -228,7 +233,8 @@ public class AttachmentController { @AutoJobPostMapping( consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - value = "/delete-attachment") + value = "/delete-attachment", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse @Operation( summary = "Delete attachment from PDF", 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 1ea0ddd0aa..1b2fcaddfa 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 @@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.http.HttpServletRequest; + import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointConfiguration; @@ -91,6 +93,63 @@ public class ConfigController { return null; } + /** + * Resolve the frontend URL the client should advertise to phones / share-link recipients. + * Priority: explicit system.frontendUrl, then the Host the user is already using to reach this + * server (works for Docker, reverse proxies, and bare-metal LANs), then a detected site-local + * IPv4, then empty. + */ + // visible for testing + String resolveFrontendUrl(HttpServletRequest request, AppConfig appConfig) { + String configured = applicationProperties.getSystem().getFrontendUrl(); + if (configured != null && !configured.isBlank()) { + return configured; + } + if (request != null) { + String host = request.getServerName(); + if (host != null && !host.isBlank() && !isLoopbackHost(host)) { + String scheme = request.getScheme(); + int port = request.getServerPort(); + boolean defaultPort = + ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port; + } + } + String localIp = GeneralUtils.getLocalNetworkIp(); + if (localIp != null) { + String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http"; + return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig); + } + return ""; + } + + /** + * The port the embedded server is actually listening on. With {@code server.port=0} (an + * ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value + * stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port} + * once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the + * real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT. + */ + // visible for testing + String resolveEffectiveServerPort(AppConfig appConfig) { + String configured = appConfig.getServerPort(); + if (configured == null || "0".equals(configured.trim())) { + String actual = applicationContext.getEnvironment().getProperty("local.server.port"); + if (actual != null && !actual.isBlank()) { + return actual; + } + } + return configured; + } + + private static boolean isLoopbackHost(String host) { + return "localhost".equalsIgnoreCase(host) + || "127.0.0.1".equals(host) + || "::1".equals(host) + || "0:0:0:0:0:0:0:1".equals(host); + } + /** Check if running Enterprise edition dynamically. */ private Boolean isRunningEE() { // Use LicenseService for fresh license status if available @@ -107,7 +166,7 @@ public class ConfigController { } @GetMapping("/app-config") - public ResponseEntity> getAppConfig() { + public ResponseEntity> getAppConfig(HttpServletRequest request) { Map configData = new HashMap<>(); try { @@ -121,20 +180,10 @@ public class ConfigController { // Note: Frontend expects "baseUrl" field name for compatibility configData.put("baseUrl", appConfig.getBackendUrl()); configData.put("contextPath", appConfig.getContextPath()); - configData.put("serverPort", appConfig.getServerPort()); + configData.put("serverPort", resolveEffectiveServerPort(appConfig)); String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); - if ((frontendUrl == null || frontendUrl.isBlank()) - && Boolean.parseBoolean( - System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) { - String localIp = GeneralUtils.getLocalNetworkIp(); - if (localIp != null) { - String scheme = - appConfig.getBackendUrl().startsWith("https") ? "https" : "http"; - frontendUrl = scheme + "://" + localIp + ":" + appConfig.getServerPort(); - } - } - configData.put("frontendUrl", frontendUrl != null ? frontendUrl : ""); + configData.put("frontendUrl", resolveFrontendUrl(request, appConfig)); // Add mobile scanner settings configData.put( @@ -277,6 +326,9 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + // AI Engine settings + configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled()); + // Timestamp TSA settings — single source of truth for presets + admin URLs ApplicationProperties.Security.Timestamp tsConfig = applicationProperties.getSecurity().getTimestamp(); 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 02fa9b8169..7a0779a4e4 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 @@ -160,14 +160,19 @@ public class ReactRoutingController { return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml); } + // `files` was historically a backend static-asset directory and was therefore + // in the exclusion list - removing it lets /files and /files/ + // forward to the SPA index.html, which is what FileManagerView expects. + // (Real storage endpoints live under /api/v1/storage/files, already + // excluded by the leading `api` token in the same regex.) @GetMapping( - "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") + "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") public ResponseEntity forwardRootPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); } @GetMapping( - "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") public ResponseEntity forwardNestedPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java index ffe3fb0f42..6b43b66ff6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java +++ b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.NoHandlerFoundException; import jakarta.servlet.http.HttpServletRequest; @@ -196,12 +197,12 @@ public class GlobalExceptionHandler { /** * Checks whether the given IOException indicates that the client disconnected before the * response could be written (broken pipe, connection reset, etc.). When this happens there is - * no point in serialising a {@link ProblemDetail} body because the socket is already closed — + * no point in serialising a {@link ProblemDetail} body because the socket is already closed - * and attempting to do so may trigger a secondary {@code HttpMessageNotWritableException} if * the response Content-Type was already committed as a non-JSON type (e.g. image/png). */ private static boolean isClientDisconnectException(IOException ex) { - // Walk the causal chain — Jetty/Tomcat may wrap the low-level SocketException + // Walk the causal chain - Jetty/Tomcat may wrap the low-level SocketException Throwable current = ex; while (current != null) { String msg = current.getMessage(); @@ -1040,6 +1041,43 @@ public class GlobalExceptionHandler { * @param request the HTTP servlet request * @return ProblemDetail with appropriate HTTP status */ + /** + * Handle ResponseStatusException explicitly so its embedded HTTP status reaches the client + * instead of being swallowed by the {@code RuntimeException} catch-all (which would downgrade + * every controller-thrown 400/404/409 to a generic 500). Folder/file storage controllers and + * any other code that throws {@code ResponseStatusException} relies on this handler taking + * precedence. + */ + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException( + ResponseStatusException ex, HttpServletRequest request) { + HttpStatus status = + HttpStatus.resolve(ex.getStatusCode().value()) != null + ? HttpStatus.valueOf(ex.getStatusCode().value()) + : HttpStatus.INTERNAL_SERVER_ERROR; + String reason = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase(); + ProblemDetail problemDetail = createBaseProblemDetail(status, reason, request); + problemDetail.setType(URI.create("/errors/" + status.value())); + problemDetail.setTitle(status.getReasonPhrase()); + problemDetail.setProperty("title", status.getReasonPhrase()); + // 5xx is operator-relevant; 4xx is a normal client-rejection - log at the right level. + if (status.is5xxServerError()) { + log.error( + "ResponseStatusException {} at {}: {}", + status.value(), + request.getRequestURI(), + reason, + ex); + } else { + log.debug( + "ResponseStatusException {} at {}: {}", + status.value(), + request.getRequestURI(), + reason); + } + return ResponseEntity.status(status).contentType(PROBLEM_JSON).body(problemDetail); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException( RuntimeException ex, HttpServletRequest request) { diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/PDFWithPageSize.java b/app/core/src/main/java/stirling/software/SPDF/model/api/PDFWithPageSize.java index 83501fb353..a8095ac759 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/PDFWithPageSize.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/PDFWithPageSize.java @@ -18,4 +18,11 @@ public class PDFWithPageSize extends PDFFile { requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL", "KEEP"}) private String pageSize; + + @Schema( + description = + "Orientation to apply to the target page size. Ignored when pageSize is KEEP.", + defaultValue = "PORTRAIT", + allowableValues = {"PORTRAIT", "LANDSCAPE"}) + private String orientation = "PORTRAIT"; } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java index d442334256..ce5a610789 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.config.swagger.MarkdownConversionResponse; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; +import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; import stirling.software.common.util.PDFToFile; import stirling.software.common.util.TempFileManager; @@ -23,7 +24,10 @@ public class ConvertPDFToMarkdown { private final TempFileManager tempFileManager; - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/markdown") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/pdf/markdown", + resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MarkdownConversionResponse @Operation( summary = "Convert PDF to Markdown", diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 2540c2f512..d3684b4dae 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -20,6 +20,7 @@ security: password: "" # initial password for the first login oauth2: enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) + debugLogging: false # set to 'true' to log full ID token and UserInfo claims during OAuth2/OIDC login. Use this to diagnose claim issues (e.g. "Attribute value for 'email' cannot be null" with ADFS). WARNING: writes PII (sub, email, name) to logs; disable after troubleshooting. client: keycloak: issuer: "" # URL of the Keycloak realm's OpenID Connect Discovery endpoint @@ -93,8 +94,8 @@ premium: key: 00000000-0000-0000-0000-000000000000 enabled: false # Enable license key checks for pro/enterprise features proFeatures: - SSOAutoLogin: false - CustomMetadata: + ssoAutoLogin: false + customMetadata: autoUpdateMetadata: false author: username creator: Stirling-PDF @@ -245,6 +246,39 @@ storage: provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage local: basePath: './storage' # base directory for stored files + # ==================================================================================== + # S3-COMPATIBLE OBJECT STORAGE - PRO / ENTERPRISE LICENSE REQUIRED + # storage.provider=s3, storage.provider=database, and cluster.artifactStore=s3 all + # require a valid Pro or Enterprise license. + # ==================================================================================== + # Used when provider=s3 (persistent user uploads) and/or cluster.artifactStore=s3 + # (transient cluster artifacts). The two consumers share this block. + # Vendor cheat sheet (set the highlighted flags to taste): + # AWS S3 -> endpoint='' region='' pathStyleAccess=false + # Cloudflare R2 -> endpoint='https://.r2.cloudflarestorage.com' region='auto' + # pathStyleAccess=false; if uploads fail with 'unsupported header + # x-amz-checksum-*' set requestChecksumCalculation=WHEN_REQUIRED + # Supabase Storage -> endpoint='https://.supabase.co/storage/v1/s3' + # region='' pathStyleAccess=true + # (filenames with non-ASCII display fine - the storage key is opaque) + # MinIO (in-cluster) -> endpoint='http://minio:9000' region='us-east-1' + # pathStyleAccess=true allowPrivateEndpoints=true + # Backblaze B2 -> endpoint='https://s3..backblazeb2.com' + # If on a B2 deployment older than July-2025 and uploads return + # 'Unsupported header x-amz-checksum-crc32', set + # requestChecksumCalculation=WHEN_REQUIRED + # DigitalOcean Spaces -> endpoint='https://.digitaloceanspaces.com' + # Note: 5GB per-object cap (regardless of multipart) + s3: + endpoint: "" # blank = use AWS regional default; otherwise full URL incl. https:// + bucket: "" # required when provider=s3 or cluster.artifactStore=s3 + region: us-east-1 + accessKey: "" # blank = fall back to AWS DefaultCredentialsProvider (env / profile / IMDS) + secretKey: "" + pathStyleAccess: false # true for MinIO and Supabase; false for AWS/R2/most CDNs + allowPrivateEndpoints: false # true required when endpoint resolves to a private/loopback IP (e.g. in-cluster MinIO). SSRF guard - leave false for any internet-facing vendor. + requestChecksumCalculation: WHEN_SUPPORTED # WHEN_SUPPORTED|WHEN_REQUIRED|DISABLED. Set WHEN_REQUIRED if your vendor rejects auto-added x-amz-checksum-* headers (older Backblaze B2, some R2 corner cases). + responseChecksumValidation: WHEN_SUPPORTED # WHEN_SUPPORTED|WHEN_REQUIRED|DISABLED. Set WHEN_REQUIRED if you see false-positive checksum-mismatch errors on GET from a vendor that never returns checksum headers. quotas: maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap @@ -330,6 +364,24 @@ aiEngine: url: http://localhost:5001 # URL of the Python AI engine timeoutSeconds: 120 # Timeout in seconds for AI engine requests +# Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults. +cluster: + enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here. + backplane: inprocess # Backplane implementation: 'inprocess' (single JVM only) or 'valkey' (multi-node via Valkey/Redis) + artifactStore: local # Transient cluster job-artifact backend: 'local' (per-node disk; single-node only) or 's3' (shared object store; required for multi-node). Distinct from 'storage.provider' which controls persistent user uploads - when both are 's3' they share the storage.s3.* credentials block. Multi-node deployments MUST set this to 's3'. + s3: + keyPrefix: transient/ # Bucket key prefix used by the cluster artifact store when artifactStore=s3. Trailing slash recommended. Lets a single bucket host both persistent uploads (storage.s3.*) and transient job artifacts under separate prefixes. + valkey: + url: "" # Valkey/Redis URL, e.g. 'redis://valkey:6379' or 'rediss://...' for TLS. Required when enabled=true and backplane=valkey. + tls: + skipCertVerification: false # set to 'true' to skip TLS certificate verification on Valkey connections (dev/test only) + node: + id: "" # Optional explicit node id. Blank = auto-generated UUID at startup. + role: both # 'web' (serves HTTP), 'worker' (runs jobs), or 'both' (default) + internalAddress: "" # host:port advertised in the instance registry for peer-to-peer cluster traffic. Blank = derived at startup. + scheme: http # 'http' or 'https' - scheme peers use to call this node's /internal/cluster/** endpoints + heartbeatIntervalMs: 5000 # Heartbeat publish interval for the instance registry (ms) + pdfEditor: fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font cache: diff --git a/app/core/src/test/java/stirling/software/SPDF/config/AutoJobPostMappingWeightTest.java b/app/core/src/test/java/stirling/software/SPDF/config/AutoJobPostMappingWeightTest.java new file mode 100644 index 0000000000..2b675650ec --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/AutoJobPostMappingWeightTest.java @@ -0,0 +1,132 @@ +package stirling.software.SPDF.config; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.filter.TypeFilter; + +import stirling.software.common.annotations.AutoJobPostMapping; + +/** + * Build-time guardrail: every {@link AutoJobPostMapping} method must declare an explicit {@code + * resourceWeight}. + * + *

The credits interceptor multiplies {@code resourceWeight} into the per-call charge. An + * endpoint that falls through to the annotation default produces a charge derived from a value + * nobody chose — silently under- or over-billing depending on the endpoint's true cost. Forcing + * each method to pick a value from {@link stirling.software.common.enumeration.ResourceWeight} + * keeps the choice deliberate. + * + *

The annotation's default is {@link Integer#MIN_VALUE} (a sentinel). Runtime readers clamp the + * value into {@code [1, 100]}, so a missed declaration can't crash production — this test is the + * contract, the clamp is the safety net. + * + *

Lives in {@code :stirling-pdf} (core) because that's the module whose compile classpath + * transitively sees every other module's controllers ({@code :common}, {@code :proprietary}, and + * {@code :saas} when enabled). + */ +class AutoJobPostMappingWeightTest { + + private static final String SCAN_BASE_PACKAGE = "stirling.software"; + + @Test + void everyAutoJobPostMappingDeclaresExplicitResourceWeight() throws Exception { + List offenders = findOffendingMethods(); + + assertTrue( + offenders.isEmpty(), + () -> + "The following @AutoJobPostMapping methods do not declare an explicit" + + " resourceWeight. Pick a value from" + + " stirling.software.common.enumeration.ResourceWeight (SMALL," + + " MEDIUM, LARGE, XLARGE) and add it to the annotation:\n - " + + String.join("\n - ", offenders)); + } + + private List findOffendingMethods() throws IOException, ClassNotFoundException { + List offenders = new ArrayList<>(); + for (Class candidate : scanForCandidateClasses()) { + for (Method method : candidate.getDeclaredMethods()) { + AutoJobPostMapping annotation = method.getAnnotation(AutoJobPostMapping.class); + if (annotation == null) { + continue; + } + if (annotation.resourceWeight() == Integer.MIN_VALUE) { + offenders.add(candidate.getName() + "#" + method.getName()); + } + } + } + return offenders; + } + + /** + * Returns every class under {@link #SCAN_BASE_PACKAGE} that has an @AutoJobPostMapping method. + */ + private List> scanForCandidateClasses() throws IOException, ClassNotFoundException { + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver); + + String pattern = "classpath*:" + SCAN_BASE_PACKAGE.replace('.', '/') + "/**/*.class"; + Resource[] resources = resolver.getResources(pattern); + + // Pre-filter by reading annotation metadata from the class file so we don't have to load + // every class on the test classpath just to find the few that are annotated. + TypeFilter mentionsAutoJobPostMapping = + (reader, factory) -> + reader.getAnnotationMetadata() + .getAnnotatedMethods(AutoJobPostMapping.class.getName()) + .size() + > 0; + + List> matches = new ArrayList<>(); + for (Resource resource : resources) { + if (!resource.isReadable()) { + continue; + } + MetadataReader reader = metadataReaderFactory.getMetadataReader(resource); + if (!mentionsAutoJobPostMapping.match(reader, metadataReaderFactory)) { + continue; + } + matches.add(Class.forName(reader.getClassMetadata().getClassName())); + } + return matches; + } + + /** + * Sanity check that the classpath scan returns non-empty; otherwise the main test passes + * vacuously. + */ + @Test + void scannerFindsAtLeastOneAutoJobPostMapping() throws Exception { + long count = + scanForCandidateClasses().stream() + .flatMap(c -> java.util.Arrays.stream(c.getDeclaredMethods())) + .filter(m -> m.isAnnotationPresent(AutoJobPostMapping.class)) + .count(); + + assertTrue( + count > 10, + () -> + "Expected the classpath scan to find many @AutoJobPostMapping methods but" + + " found only " + + count + + ". Scanner regression?"); + } + + @SuppressWarnings("unused") + private static String describeCandidates(List> candidates) { + return candidates.stream().map(Class::getName).collect(Collectors.joining(", ")); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java index 815309b889..c3d0d969e1 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java @@ -237,7 +237,8 @@ class ScalePagesControllerTest { ScalePagesRequest request = new ScalePagesRequest(); request.setFileInput(file); - request.setPageSize("A4_LANDSCAPE"); + request.setPageSize("A4"); + request.setOrientation("LANDSCAPE"); request.setScaleFactor(1.0f); setupFactory(); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java index 49a8d8bfc4..1cceefadca 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java @@ -4,17 +4,28 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; 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.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,8 +34,12 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; import stirling.software.SPDF.model.api.SplitPagesRequest; @@ -32,6 +47,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFileManager; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class SplitPDFControllerTest { @TempDir Path tempDir; @@ -47,6 +63,12 @@ class SplitPDFControllerTest { String suffix = invocation.getArgument(0); return Files.createTempFile(tempDir, "test", suffix).toFile(); }); + lenient() + .when(pdfDocumentFactory.load(any(File.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); } private byte[] createPdf(int numPages) throws IOException { @@ -60,17 +82,79 @@ class SplitPDFControllerTest { } } - private void setupFactory() throws IOException { - when(pdfDocumentFactory.load(any(File.class), eq(true))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.load(any(File.class))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); + private byte[] createPdfWithForm(int numPages) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + for (int i = 0; i < numPages; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDTextField field = new PDTextField(acroForm); + field.setPartialName("text_p" + (i + 1)); + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(100, 700, 200, 20)); + widget.setPage(page); + field.setWidgets(java.util.List.of(widget)); + page.getAnnotations().add(widget); + acroForm.getFields().add(field); + } + Path pdfPath = tempDir.resolve("input.pdf"); + doc.save(pdfPath.toFile()); + return Files.readAllBytes(pdfPath); + } + } + + private List fieldNamesOf(byte[] pdfBytes) throws IOException { + List names = new ArrayList<>(); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null); + if (acroForm == null) { + return names; + } + for (PDField field : acroForm.getFields()) { + names.add(field.getFullyQualifiedName()); + } + } + return names; + } + + private int widgetCountOnPage(byte[] pdfBytes, int pageIndex) throws IOException { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + int count = 0; + for (PDAnnotation a : doc.getPage(pageIndex).getAnnotations()) { + if (a instanceof PDAnnotationWidget) { + count++; + } + } + return count; + } + } + + private List unzip(Resource zipResource) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.add(zis.readAllBytes()); + zis.closeEntry(); + } + } + return entries; + } + + private int[] pageCountsOf(List entries) throws IOException { + int[] counts = new int[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + try (PDDocument doc = Loader.loadPDF(entries.get(i))) { + counts[i] = doc.getNumberOfPages(); + } + } + return counts; } @Test - @DisplayName("Should split 6-page PDF at page 3") + @DisplayName("Should split 6-page PDF at page 3 into 2 parts") void shouldSplitAtPage3() throws Exception { byte[] pdfBytes = createPdf(6); MockMultipartFile file = @@ -81,11 +165,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("3"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(3, 3); } @Test @@ -100,11 +185,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("1,2,3"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1); } @Test @@ -119,15 +205,16 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("1"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(1); + assertThat(pageCountsOf(outputs)).containsExactly(1); } @Test - @DisplayName("Should split with range notation") + @DisplayName("Should split with multiple split points") void shouldSplitWithRange() throws Exception { byte[] pdfBytes = createPdf(10); MockMultipartFile file = @@ -138,11 +225,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("3,7"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(3, 4, 3); } @Test @@ -157,13 +245,14 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("2"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2); } @Test @@ -178,11 +267,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("5"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(1); + assertThat(pageCountsOf(outputs)).containsExactly(5); } @Test @@ -197,29 +287,43 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("all"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1); } @Test - @DisplayName("Should handle file without extension in original name") - void shouldHandleFileWithoutExtension() throws Exception { - byte[] pdfBytes = createPdf(2); + @DisplayName("Should preserve AcroForm and per-page widgets when splitting form PDF") + void shouldSplitFormPdf() throws Exception { + byte[] pdfBytes = createPdfWithForm(4); MockMultipartFile file = new MockMultipartFile( - "fileInput", "no_extension", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); SplitPagesRequest request = new SplitPagesRequest(); request.setFileInput(file); - request.setPageNumbers("1"); + request.setPageNumbers("2"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2); + + assertThat(fieldNamesOf(outputs.get(0))) + .as("first split keeps fields whose widgets are on pages 1-2") + .containsExactlyInAnyOrder("text_p1", "text_p2"); + assertThat(fieldNamesOf(outputs.get(1))) + .as("second split keeps fields whose widgets are on pages 3-4") + .containsExactlyInAnyOrder("text_p3", "text_p4"); + + assertThat(widgetCountOnPage(outputs.get(0), 0)).isEqualTo(1); + assertThat(widgetCountOnPage(outputs.get(0), 1)).isEqualTo(1); + assertThat(widgetCountOnPage(outputs.get(1), 0)).isEqualTo(1); + assertThat(widgetCountOnPage(outputs.get(1), 1)).isEqualTo(1); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java index 39e711b314..c9c9b618f4 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java @@ -4,11 +4,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; 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.io.ByteArrayInputStream; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; @@ -27,10 +35,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest; import stirling.software.common.service.CustomPDFDocumentFactory; @@ -55,6 +64,12 @@ class SplitPdfByChaptersControllerTest { String suffix = inv.getArgument(0); return Files.createTempFile(tempDir, "test", suffix).toFile(); }); + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(File.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); } private byte[] createPdfWithBookmarks(int numPages, String... chapterNames) throws IOException { @@ -83,6 +98,29 @@ class SplitPdfByChaptersControllerTest { } } + private List unzip(Resource zipResource) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.add(zis.readAllBytes()); + zis.closeEntry(); + } + } + return entries; + } + + private int totalPagesOf(List entries) throws IOException { + int total = 0; + for (byte[] data : entries) { + try (PDDocument doc = Loader.loadPDF(data)) { + total += doc.getNumberOfPages(); + } + } + return total; + } + @Test @DisplayName("Should split PDF by chapters") void shouldSplitByChapters() throws Exception { @@ -97,12 +135,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(totalPagesOf(outputs)).isEqualTo(6); } @Test @@ -119,12 +157,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(true); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(totalPagesOf(outputs)).isEqualTo(4); } @Test @@ -163,10 +201,6 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer( - inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request)); } } @@ -185,12 +219,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(1); + assertThat(totalPagesOf(outputs)).isEqualTo(3); } @Test @@ -207,14 +241,15 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(true); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class))) + lenient() + .when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class))) .thenReturn(new stirling.software.common.model.PdfMetadata()); - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(totalPagesOf(outputs)).isEqualTo(4); } @Test @@ -231,12 +266,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(totalPagesOf(outputs)).isEqualTo(6); } @Test @@ -253,11 +288,11 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(true); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(5); + assertThat(totalPagesOf(outputs)).isEqualTo(10); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java index 1e47652185..51523eced2 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java @@ -4,17 +4,27 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; 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.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,6 +33,9 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -33,6 +46,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFileManager; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class SplitPdfBySizeControllerTest { @TempDir Path tempDir; @@ -48,69 +62,189 @@ class SplitPdfBySizeControllerTest { String suffix = invocation.getArgument(0); return Files.createTempFile(tempDir, "test", suffix).toFile(); }); + lenient() + .when(pdfDocumentFactory.load(any(File.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); } - @Test - @DisplayName("Should split by page count successfully") - void shouldSplitByPageCount() throws Exception { - byte[] pdfBytes; + private byte[] createPdf(int numPages) throws IOException { try (PDDocument doc = new PDDocument()) { - for (int i = 0; i < 5; i++) { + for (int i = 0; i < numPages; i++) { doc.addPage(new PDPage(PDRectangle.A4)); } Path pdfPath = tempDir.resolve("input.pdf"); doc.save(pdfPath.toFile()); - pdfBytes = Files.readAllBytes(pdfPath); + return Files.readAllBytes(pdfPath); } + } + private List unzip(Resource zipResource) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.add(zis.readAllBytes()); + zis.closeEntry(); + } + } + return entries; + } + + private int[] pageCountsOf(List entries) throws IOException { + int[] counts = new int[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + try (PDDocument doc = Loader.loadPDF(entries.get(i))) { + counts[i] = doc.getNumberOfPages(); + } + } + return counts; + } + + @Test + @DisplayName("Should split by page count into 2-page chunks") + void shouldSplitByPageCount() throws Exception { + byte[] pdfBytes = createPdf(5); MockMultipartFile file = new MockMultipartFile( "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); request.setFileInput(file); - request.setSplitType(1); // Page count + request.setSplitType(1); request.setSplitValue("2"); - when(pdfDocumentFactory.load(any(File.class), eq(true))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); - - ResponseEntity response = controller.autoSplitPdf(request); + ResponseEntity response = controller.autoSplitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 1); } @Test - @DisplayName("Should split by document count successfully") + @DisplayName("Should split by document count into 3 even documents") void shouldSplitByDocCount() throws Exception { - byte[] pdfBytes; - try (PDDocument doc = new PDDocument()) { - for (int i = 0; i < 6; i++) { - doc.addPage(new PDPage(PDRectangle.A4)); - } - Path pdfPath = tempDir.resolve("input.pdf"); - doc.save(pdfPath.toFile()); - pdfBytes = Files.readAllBytes(pdfPath); - } - + byte[] pdfBytes = createPdf(6); MockMultipartFile file = new MockMultipartFile( "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); request.setFileInput(file); - request.setSplitType(2); // Document count - request.setSplitValue("3"); // Split into 3 docs (2 pages each) + request.setSplitType(2); + request.setSplitValue("3"); - when(pdfDocumentFactory.load(any(File.class), eq(true))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); - - ResponseEntity response = controller.autoSplitPdf(request); + ResponseEntity response = controller.autoSplitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 2); + } + + @Test + @DisplayName("Should split by document count distributing extras") + void shouldSplitByDocCountWithRemainder() throws Exception { + byte[] pdfBytes = createPdf(7); + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); + request.setFileInput(file); + request.setSplitType(2); + request.setSplitValue("3"); + + ResponseEntity response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(3, 2, 2); + } + + private byte[] createPdfWithForm(int numPages) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + for (int i = 0; i < numPages; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDTextField field = new PDTextField(acroForm); + field.setPartialName("text_p" + (i + 1)); + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(100, 700, 200, 20)); + widget.setPage(page); + field.setWidgets(java.util.List.of(widget)); + page.getAnnotations().add(widget); + acroForm.getFields().add(field); + } + Path pdfPath = tempDir.resolve("input.pdf"); + doc.save(pdfPath.toFile()); + return Files.readAllBytes(pdfPath); + } + } + + private List fieldNamesOf(byte[] pdfBytes) throws IOException { + List names = new ArrayList<>(); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null); + if (acroForm == null) { + return names; + } + for (PDField field : acroForm.getFields()) { + names.add(field.getFullyQualifiedName()); + } + } + return names; + } + + @Test + @DisplayName("Should preserve AcroForm when splitting form PDF by page count") + void shouldPreserveFormFieldsWhenSplitting() throws Exception { + byte[] pdfBytes = createPdfWithForm(4); + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); + request.setFileInput(file); + request.setSplitType(1); + request.setSplitValue("2"); + + ResponseEntity response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2); + assertThat(fieldNamesOf(outputs.get(0))).containsExactlyInAnyOrder("text_p1", "text_p2"); + assertThat(fieldNamesOf(outputs.get(1))).containsExactlyInAnyOrder("text_p3", "text_p4"); + } + + @Test + @DisplayName("Should split by size into multiple files") + void shouldSplitBySize() throws Exception { + byte[] pdfBytes = createPdf(20); + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); + request.setFileInput(file); + request.setSplitType(0); + request.setSplitValue("3KB"); + + ResponseEntity response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).isNotEmpty(); + int total = 0; + for (int count : pageCountsOf(outputs)) { + total += count; + } + assertThat(total).isEqualTo(20); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java index 3a1f95d0a6..cab1622c69 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java @@ -15,10 +15,14 @@ import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import jakarta.servlet.http.HttpServletRequest; + import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; +import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.System; import stirling.software.common.service.LicenseServiceInterface; import stirling.software.common.service.ServerCertificateServiceInterface; import stirling.software.common.service.UserServiceInterface; @@ -173,4 +177,119 @@ class ConfigControllerTest { assertEquals(HttpStatus.OK, response.getStatusCode()); verify(endpointConfiguration).getAllEndpoints(); } + + @Test + void resolveFrontendUrl_prefersExplicitConfiguredValue() { + System sys = mock(System.class); + when(applicationProperties.getSystem()).thenReturn(sys); + when(sys.getFrontendUrl()).thenReturn("https://pdf.example.com"); + + // Request would say something else, but configured wins. + HttpServletRequest req = mock(HttpServletRequest.class); + AppConfig appConfig = mock(AppConfig.class); + + assertEquals( + "https://pdf.example.com", configController.resolveFrontendUrl(req, appConfig)); + } + + @Test + void resolveFrontendUrl_usesRequestHostWhenNotConfigured() { + System sys = mock(System.class); + when(applicationProperties.getSystem()).thenReturn(sys); + when(sys.getFrontendUrl()).thenReturn(null); + + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getServerName()).thenReturn("192.168.1.100"); + when(req.getScheme()).thenReturn("http"); + when(req.getServerPort()).thenReturn(8080); + + assertEquals( + "http://192.168.1.100:8080", + configController.resolveFrontendUrl(req, mock(AppConfig.class))); + } + + @Test + void resolveFrontendUrl_elidesDefaultHttpsPort() { + System sys = mock(System.class); + when(applicationProperties.getSystem()).thenReturn(sys); + when(sys.getFrontendUrl()).thenReturn(""); + + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getServerName()).thenReturn("pdf.example.com"); + when(req.getScheme()).thenReturn("https"); + when(req.getServerPort()).thenReturn(443); + + assertEquals( + "https://pdf.example.com", + configController.resolveFrontendUrl(req, mock(AppConfig.class))); + } + + @Test + void resolveFrontendUrl_fallsThroughOnLoopbackHost() { + System sys = mock(System.class); + when(applicationProperties.getSystem()).thenReturn(sys); + when(sys.getFrontendUrl()).thenReturn(null); + + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getServerName()).thenReturn("localhost"); + + AppConfig appConfig = mock(AppConfig.class); + when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080"); + when(appConfig.getServerPort()).thenReturn("8080"); + + // Detected IP (if any) wins over loopback request host. We can't assert the + // exact value (depends on the host running the test) but we can assert it + // never returns "localhost". + String result = configController.resolveFrontendUrl(req, appConfig); + assertNotNull(result); + assertFalse(result.contains("localhost")); + } + + @Test + void resolveFrontendUrl_usesActualPortWhenServerPortIsEphemeral() { + System sys = mock(System.class); + when(applicationProperties.getSystem()).thenReturn(sys); + when(sys.getFrontendUrl()).thenReturn(null); + + // Loopback host forces the detected-LAN-IP branch, which is where an + // ephemeral server.port=0 would otherwise leak through as ":0". + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getServerName()).thenReturn("localhost"); + + AppConfig appConfig = mock(AppConfig.class); + when(appConfig.getBackendUrl()).thenReturn("http://localhost"); + when(appConfig.getServerPort()).thenReturn("0"); + + org.springframework.core.env.Environment environment = + mock(org.springframework.core.env.Environment.class); + when(applicationContext.getEnvironment()).thenReturn(environment); + when(environment.getProperty("local.server.port")).thenReturn("54321"); + + String result = configController.resolveFrontendUrl(req, appConfig); + assertNotNull(result); + assertTrue(result.endsWith(":54321")); + assertFalse(result.contains(":0")); + } + + @Test + void resolveEffectiveServerPort_prefersActualBoundPortWhenConfiguredZero() { + AppConfig appConfig = mock(AppConfig.class); + when(appConfig.getServerPort()).thenReturn("0"); + + org.springframework.core.env.Environment environment = + mock(org.springframework.core.env.Environment.class); + when(applicationContext.getEnvironment()).thenReturn(environment); + when(environment.getProperty("local.server.port")).thenReturn("54321"); + + assertEquals("54321", configController.resolveEffectiveServerPort(appConfig)); + } + + @Test + void resolveEffectiveServerPort_keepsConfiguredNonZeroPort() { + AppConfig appConfig = mock(AppConfig.class); + when(appConfig.getServerPort()).thenReturn("8080"); + + // Non-zero configured port is authoritative; the runtime env is never consulted. + assertEquals("8080", configController.resolveEffectiveServerPort(appConfig)); + } } diff --git a/app/core/src/test/java/stirling/software/common/configuration/ConfigInitializerRestartTest.java b/app/core/src/test/java/stirling/software/common/configuration/ConfigInitializerRestartTest.java new file mode 100644 index 0000000000..467ab01794 --- /dev/null +++ b/app/core/src/test/java/stirling/software/common/configuration/ConfigInitializerRestartTest.java @@ -0,0 +1,93 @@ +package stirling.software.common.configuration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.YamlHelper; + +/** + * End-to-end check of the container-restart path. {@link ConfigInitializer#ensureConfigExists()} is + * what runs on every startup, merging the on-disk settings.yml with the bundled + * settings.yml.template. These tests exercise it against the real template on the classpath to + * prove admin-saved proFeatures values survive a restart - the bug behind "the SSO auto-login + * button resets every time the container resets". + */ +class ConfigInitializerRestartTest { + + private static String read(Path settings, String... keyPath) throws IOException { + return String.valueOf(new YamlHelper(settings).getValueByExactKeyPath(keyPath)); + } + + @Test + void ssoAutoLoginAndCustomMetadata_persistAcrossRestart(@TempDir Path tmp) throws Exception { + Path settings = tmp.resolve("settings.yml"); + Path custom = tmp.resolve("custom_settings.yml"); + + try (MockedStatic paths = + mockStatic(InstallationPathConfig.class)) { + paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString()); + paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString()); + + ConfigInitializer init = new ConfigInitializer(); + + // First boot: settings.yml created from the bundled template (camelCase, default off). + init.ensureConfigExists(); + assertEquals("false", read(settings, "premium", "proFeatures", "ssoAutoLogin")); + + // Admin enables SSO auto-login and edits custom metadata via the exact save path the + // admin settings controller uses. + GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true); + GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "acme"); + + // Container restart: ensureConfigExists merges the saved file with the template again. + init.ensureConfigExists(); + + assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin")); + assertEquals( + "acme", read(settings, "premium", "proFeatures", "customMetadata", "author")); + } + } + + @Test + void legacyPascalCaseConfig_isMigratedAndPreservedOnRestart(@TempDir Path tmp) + throws Exception { + Path settings = tmp.resolve("settings.yml"); + Path custom = tmp.resolve("custom_settings.yml"); + + try (MockedStatic paths = + mockStatic(InstallationPathConfig.class)) { + paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString()); + paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString()); + + ConfigInitializer init = new ConfigInitializer(); + + // Seed a full settings.yml as an OLD install would have written it: PascalCase keys + // with + // SSO auto-login enabled. + init.ensureConfigExists(); + String legacy = + Files.readString(settings) + .replace("ssoAutoLogin: false", "SSOAutoLogin: true") + .replace("customMetadata:", "CustomMetadata:"); + Files.writeString(settings, legacy); + + // Upgrade restart. + init.ensureConfigExists(); + + // Value carried forward onto the new camelCase key; the legacy PascalCase key is gone. + assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin")); + assertNull( + new YamlHelper(settings) + .getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin")); + } + } +} diff --git a/app/proprietary/.gitignore b/app/proprietary/.gitignore index 5156eb74ad..1a5610dc1a 100644 --- a/app/proprietary/.gitignore +++ b/app/proprietary/.gitignore @@ -123,6 +123,8 @@ SwaggerDoc.json *.tar.gz *.rar *.db +# Whitelist the H2 fixtures that feed the version-migration CI smoke test. +!src/test/resources/db-migration-fixtures/*.mv.db /build /app/proprietary/build/ diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index e923ae6229..4c9932d994 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -5,6 +5,8 @@ repositories { ext { jwtVersion = '0.13.0' + awsSdkVersion = '2.44.12' + testcontainersMinioVersion = '1.21.4' } bootRun { @@ -71,6 +73,13 @@ dependencies { implementation('com.coveo:saml-client:5.0.0') { exclude group: 'org.opensaml', module: 'opensaml-core' } + + implementation "software.amazon.awssdk:s3:$awsSdkVersion" + implementation "software.amazon.awssdk:url-connection-client:$awsSdkVersion" + + testImplementation "org.testcontainers:minio:$testcontainersMinioVersion" + testImplementation "org.testcontainers:junit-jupiter:$testcontainersMinioVersion" + testImplementation "org.testcontainers:localstack:$testcontainersMinioVersion" } tasks.register('prepareKotlinBuildScriptModel') {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java new file mode 100644 index 0000000000..eacdaf32c0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java @@ -0,0 +1,200 @@ +package stirling.software.proprietary.cluster.s3; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +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.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/** + * Shared factory for {@link S3Client} and {@link S3Presigner} instances used by both {@code + * S3StorageProvider} and {@code S3FileStore}, so endpoint/region/credentials wiring lives in + * exactly one place. + */ +@Slf4j +public final class S3Clients { + + private S3Clients() {} + + /** Paired client and presigner with coordinated lifecycle. */ + public record Bundle(S3Client client, S3Presigner presigner) implements AutoCloseable { + @Override + public void close() { + try { + presigner.close(); + } catch (Exception e) { + log.warn("Error closing S3 presigner", e); + } + try { + client.close(); + } catch (Exception e) { + log.warn("Error closing S3 client", e); + } + } + } + + /** Build a client+presigner pair from the shared S3 config block. */ + public static Bundle build(ApplicationProperties.Storage.S3 cfg, String usage) { + if (cfg == null) { + throw new IllegalStateException( + usage + " requires storage.s3.* configuration to be set"); + } + if (cfg.getBucket() == null || cfg.getBucket().isBlank()) { + throw new IllegalStateException(usage + " requires storage.s3.bucket to be set"); + } + String region = + cfg.getRegion() == null || cfg.getRegion().isBlank() + ? "us-east-1" + : cfg.getRegion(); + + S3Configuration s3Configuration = + S3Configuration.builder().pathStyleAccessEnabled(cfg.isPathStyleAccess()).build(); + + RequestChecksumCalculation requestChecksum = + parseRequestChecksum(cfg.getRequestChecksumCalculation()); + ResponseChecksumValidation responseChecksum = + parseResponseChecksum(cfg.getResponseChecksumValidation()); + + S3ClientBuilder clientBuilder = + S3Client.builder() + .httpClient(UrlConnectionHttpClient.create()) + .region(Region.of(region)) + .serviceConfiguration(s3Configuration) + .requestChecksumCalculation(requestChecksum) + .responseChecksumValidation(responseChecksum); + + S3Presigner.Builder presignerBuilder = + S3Presigner.builder() + .region(Region.of(region)) + .serviceConfiguration(s3Configuration); + + if (cfg.getEndpoint() != null && !cfg.getEndpoint().isBlank()) { + URI endpoint; + try { + endpoint = new URI(cfg.getEndpoint()); + } catch (URISyntaxException e) { + throw new IllegalStateException( + "Invalid storage.s3.endpoint: " + cfg.getEndpoint(), e); + } + validateEndpointHost(endpoint, cfg.isAllowPrivateEndpoints()); + clientBuilder.endpointOverride(endpoint); + presignerBuilder.endpointOverride(endpoint); + } + + boolean hasStaticCreds = + cfg.getAccessKey() != null + && !cfg.getAccessKey().isBlank() + && cfg.getSecretKey() != null + && !cfg.getSecretKey().isBlank(); + if (hasStaticCreds) { + AwsBasicCredentials credentials = + AwsBasicCredentials.create(cfg.getAccessKey(), cfg.getSecretKey()); + StaticCredentialsProvider provider = StaticCredentialsProvider.create(credentials); + clientBuilder.credentialsProvider(provider); + presignerBuilder.credentialsProvider(provider); + } else { + clientBuilder.credentialsProvider(DefaultCredentialsProvider.create()); + presignerBuilder.credentialsProvider(DefaultCredentialsProvider.create()); + } + + log.debug( + "Configured S3 {}: bucket={}, region={}, endpoint={}, pathStyle={}", + usage, + cfg.getBucket(), + region, + cfg.getEndpoint() == null || cfg.getEndpoint().isBlank() + ? "" + : cfg.getEndpoint(), + cfg.isPathStyleAccess()); + + return new Bundle(clientBuilder.build(), presignerBuilder.build()); + } + + /** + * Block SSRF via the S3 endpoint setting. An admin who can edit config could otherwise point + * the SDK at the cloud metadata service (e.g. {@code http://169.254.169.254/}) and exfiltrate + * instance-role credentials. Reject any endpoint whose host resolves to a loopback, link-local, + * or RFC1918 private address unless the operator has explicitly opted in via {@code + * storage.s3.allow-private-endpoints=true}. + */ + static void validateEndpointHost(URI endpoint, boolean allowPrivate) { + if (allowPrivate) { + return; + } + String host = endpoint.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint); + } + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + throw new IllegalStateException( + "Unable to resolve storage.s3.endpoint host '" + host + "'", e); + } + for (InetAddress address : addresses) { + if (isPrivateOrLocal(address)) { + throw new IllegalStateException( + "storage.s3.endpoint host '" + + host + + "' resolves to private/link-local address " + + address.getHostAddress() + + "; set storage.s3.allow-private-endpoints=true to opt in" + + " (e.g. for MinIO or in-cluster S3)."); + } + } + } + + private static boolean isPrivateOrLocal(InetAddress address) { + return address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isAnyLocalAddress() + || address.isMulticastAddress(); + } + + static RequestChecksumCalculation parseRequestChecksum(String value) { + if (value == null || value.isBlank()) { + return RequestChecksumCalculation.WHEN_SUPPORTED; + } + try { + return RequestChecksumCalculation.valueOf( + value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ex) { + log.warn( + "Unknown storage.s3.request-checksum-calculation value '{}', falling back to WHEN_SUPPORTED", + value); + return RequestChecksumCalculation.WHEN_SUPPORTED; + } + } + + static ResponseChecksumValidation parseResponseChecksum(String value) { + if (value == null || value.isBlank()) { + return ResponseChecksumValidation.WHEN_SUPPORTED; + } + try { + return ResponseChecksumValidation.valueOf( + value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ex) { + log.warn( + "Unknown storage.s3.response-checksum-validation value '{}', falling back to WHEN_SUPPORTED", + value); + return ResponseChecksumValidation.WHEN_SUPPORTED; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java new file mode 100644 index 0000000000..1cff20e6a0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java @@ -0,0 +1,226 @@ +package stirling.software.proprietary.cluster.s3; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.UUID; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.FileStore; + +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * S3-backed {@link FileStore} for transient job-result files. Objects are namespaced under a + * configurable key prefix (default {@code transient/}) and can coexist in the same bucket as {@code + * S3StorageProvider}. + */ +@Slf4j +public class S3FileStore implements FileStore, AutoCloseable { + + public static final String DEFAULT_KEY_PREFIX = "transient/"; + + private final S3Client s3Client; + private final String bucket; + private final String keyPrefix; + private final boolean ownsClient; + + public S3FileStore(S3Client s3Client, String bucket) { + this(s3Client, bucket, DEFAULT_KEY_PREFIX, true); + } + + public S3FileStore(S3Client s3Client, String bucket, String keyPrefix) { + this(s3Client, bucket, keyPrefix, true); + } + + /** + * @param ownsClient when true, {@link #close()} will close the supplied client. Set to false in + * tests that share the client with another consumer. + */ + public S3FileStore(S3Client s3Client, String bucket, String keyPrefix, boolean ownsClient) { + if (bucket == null || bucket.isBlank()) { + throw new IllegalArgumentException("S3 bucket must be configured"); + } + this.s3Client = s3Client; + this.bucket = bucket; + this.keyPrefix = normalizePrefix(keyPrefix); + this.ownsClient = ownsClient; + } + + @Override + public Stored store(InputStream in, String originalName) throws IOException { + String fileId = UUID.randomUUID().toString(); + // S3 PUT requires a known content-length; spool to a temp file first so memory stays + // bounded for large payloads, then stream the file to S3 via RequestBody.fromFile. + Path tempFile = Files.createTempFile("s3-upload-", ".bin"); + long size; + try { + try (InputStream src = in) { + Files.copy(src, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + size = Files.size(tempFile); + PutObjectRequest request = + PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build(); + try { + s3Client.putObject(request, RequestBody.fromFile(tempFile)); + } catch (SdkException e) { + throw new IOException("Failed to upload object to S3", e); + } + } finally { + try { + Files.deleteIfExists(tempFile); + } catch (IOException cleanupError) { + log.warn("Failed to delete S3 upload temp file: {}", tempFile, cleanupError); + } + } + return new Stored(fileId, size); + } + + @Override + public InputStream retrieve(String fileId) throws IOException { + validateFileId(fileId); + GetObjectRequest request = + GetObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build(); + try { + ResponseInputStream stream = s3Client.getObject(request); + return new BufferedInputStream(stream); + } catch (NoSuchKeyException e) { + throw new IOException("File not found with ID: " + fileId, e); + } catch (SdkException e) { + throw new IOException("Failed to load object from S3", e); + } + } + + @Override + public byte[] retrieveBytes(String fileId) throws IOException { + validateFileId(fileId); + GetObjectRequest request = + GetObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build(); + try (ResponseInputStream stream = s3Client.getObject(request)) { + return stream.readAllBytes(); + } catch (NoSuchKeyException e) { + throw new IOException("File not found with ID: " + fileId, e); + } catch (SdkException e) { + throw new IOException("Failed to load object from S3", e); + } + } + + @Override + public long size(String fileId) throws IOException { + validateFileId(fileId); + HeadObjectRequest request = + HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build(); + try { + HeadObjectResponse response = s3Client.headObject(request); + return Optional.ofNullable(response.contentLength()).orElse(0L); + } catch (NoSuchKeyException e) { + throw new IOException("File not found with ID: " + fileId, e); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + throw new IOException("File not found with ID: " + fileId, e); + } + throw new IOException("Failed to head object in S3", e); + } catch (SdkException e) { + throw new IOException("Failed to head object in S3", e); + } + } + + @Override + public boolean delete(String fileId) { + try { + validateFileId(fileId); + } catch (IllegalArgumentException e) { + log.warn("Refusing to delete invalid file id: {}", fileId); + return false; + } + try { + s3Client.deleteObject( + DeleteObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build()); + return true; + } catch (SdkException e) { + log.error("Error deleting file with ID: {}", fileId, e); + return false; + } + } + + @Override + public boolean exists(String fileId) { + try { + validateFileId(fileId); + } catch (IllegalArgumentException e) { + return false; + } + HeadObjectRequest request = + HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build(); + try { + s3Client.headObject(request); + return true; + } catch (NoSuchKeyException e) { + return false; + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return false; + } + log.warn("Error checking existence for file ID: {}", fileId, e); + return false; + } catch (SdkException e) { + log.warn("Error checking existence for file ID: {}", fileId, e); + return false; + } + } + + @Override + public void close() { + if (!ownsClient) { + return; + } + try { + s3Client.close(); + } catch (Exception e) { + log.warn("Error closing S3 client", e); + } + } + + String resolveKey(String fileId) { + return keyPrefix + fileId; + } + + private static void validateFileId(String fileId) { + if (fileId == null || fileId.isBlank()) { + throw new IllegalArgumentException("File ID must not be blank"); + } + if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { + throw new IllegalArgumentException("Invalid file ID"); + } + } + + private static String normalizePrefix(String prefix) { + if (prefix == null || prefix.isBlank()) { + return ""; + } + String trimmed = prefix.trim(); + if (trimmed.startsWith("/")) { + trimmed = trimmed.substring(1); + } + if (!trimmed.isEmpty() && !trimmed.endsWith("/")) { + trimmed = trimmed + "/"; + } + return trimmed; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java new file mode 100644 index 0000000000..b2a516a4e6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.cluster.s3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.FileStore; +import stirling.software.common.model.ApplicationProperties; + +/** Activates the S3-backed transient {@link FileStore} when {@code cluster.artifactStore=s3}. */ +@Slf4j +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "cluster", name = "artifactStore", havingValue = "s3") +public class S3FileStoreConfiguration { + + private final ApplicationProperties applicationProperties; + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + public FileStore fileStore(@Value("${cluster.s3.keyPrefix:transient/}") String keyPrefix) { + ApplicationProperties.Storage.S3 cfg = applicationProperties.getStorage().getS3(); + S3Clients.Bundle bundle = S3Clients.build(cfg, "cluster file store"); + // FileStore has no signed-URL contract; close the unused presigner immediately. + try { + bundle.presigner().close(); + } catch (Exception ignored) { + } + log.info("Cluster FileStore: s3 (bucket={}, keyPrefix={})", cfg.getBucket(), keyPrefix); + return new S3FileStore(bundle.client(), cfg.getBucket(), keyPrefix, true); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java index da327177c4..e574f84b86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java @@ -11,7 +11,7 @@ import jakarta.validation.constraints.NotNull; import lombok.Data; @Data -@Schema(description = "Run an AI workflow against one or more PDF files") +@Schema(description = "Run an AI workflow") public class AiWorkflowRequest { @NotNull diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java index afaef2fbde..4fb6a0de36 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java @@ -49,7 +49,11 @@ public class EEAppConfig { @Profile("security & !saas") @Bean(name = "SSOAutoLogin") public boolean ssoAutoLogin() { - return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin(); + boolean enabled = applicationProperties.getPremium().getProFeatures().isSsoAutoLogin(); + if (enabled) { + licenseKeyChecker.requireProOrEnterprise("premium.proFeatures.ssoAutoLogin=true"); + } + return enabled; } // TODO: Remove post migration diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 0183e58f2b..af9ac192d7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -32,7 +32,10 @@ public class LicenseKeyChecker { private final UserLicenseSettingsService licenseSettingsService; - private License premiumEnabledResult = License.NORMAL; + // volatile: written by evaluateLicense() on the @Scheduled refresh thread, read by request + // threads via getPremiumLicenseEnabledResult() / requireProOrEnterprise(). Ensures readers see + // the latest tier rather than a stale cached value. + private volatile License premiumEnabledResult = License.NORMAL; public LicenseKeyChecker( KeygenLicenseVerifier licenseService, @@ -133,4 +136,16 @@ public class LicenseKeyChecker { public License getPremiumLicenseEnabledResult() { return premiumEnabledResult; } + + /** + * Throws {@link IllegalStateException} if the current license is not Pro or Enterprise. Used by + * boot-time gates to fail fast when an operator enables a premium-only setting without a valid + * license. {@code configuredAs} is the human-readable property path (e.g. {@code + * "storage.provider=s3"}) and appears in the exception message. + */ + public void requireProOrEnterprise(String configuredAs) { + if (premiumEnabledResult != License.SERVER && premiumEnabledResult != License.ENTERPRISE) { + throw new IllegalStateException(configuredAs + " requires a Pro or Enterprise license"); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java index 3fa41907a2..213e08ea0c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; +import stirling.software.common.enumeration.ResourceWeight; import stirling.software.proprietary.security.model.api.Email; import stirling.software.proprietary.security.service.EmailService; @@ -39,7 +40,10 @@ public class EmailController { * attachment. * @return ResponseEntity with success or error message. */ - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/send-email") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/send-email", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @Operation( summary = "Send an email with an attachment", description = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index 6592bc95c0..c1057c7e36 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -1,6 +1,10 @@ package stirling.software.proprietary.security.service; +import java.util.Collections; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; import org.springframework.security.authentication.LockedException; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; @@ -8,6 +12,8 @@ import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser; @@ -39,20 +45,37 @@ public class CustomOAuth2UserService implements OAuth2UserService mergedAttributes, + boolean failure) { + StringBuilder sb = new StringBuilder(); + sb.append("\n========== [OAUTH2 DEBUG] ").append(banner).append(" ==========\n"); + sb.append("Provider registrationId : ").append(registrationId).append('\n'); + sb.append("Configured useAsUsername: ") + .append(oauth2Properties.getUseAsUsername()) + .append(" (looks up claim key '") + .append(usernameAttributeKey) + .append("')\n"); + + if (idToken != null) { + Map idClaims = idToken.getClaims(); + sb.append("\n-- ID token claims (") + .append(idClaims == null ? 0 : idClaims.size()) + .append(") --\n"); + appendClaims(sb, idClaims); + sb.append("ID token issued at : ").append(idToken.getIssuedAt()).append('\n'); + sb.append("ID token expires at: ").append(idToken.getExpiresAt()).append('\n'); + } else { + sb.append("\n-- ID token: --\n"); + } + + if (userInfo != null && userInfo.getClaims() != null) { + sb.append("\n-- UserInfo endpoint claims (") + .append(userInfo.getClaims().size()) + .append(") --\n"); + appendClaims(sb, userInfo.getClaims()); + } else { + sb.append("\n-- UserInfo endpoint claims: none returned --\n"); + } + + if (mergedAttributes != null) { + sb.append("\n-- Merged attribute keys available to useAsUsername: ") + .append(new TreeSet<>(mergedAttributes.keySet())) + .append("\n"); + Object resolved = mergedAttributes.get(usernameAttributeKey); + sb.append("-- Value at '") + .append(usernameAttributeKey) + .append("' : ") + .append(resolved == null ? "" : resolved) + .append('\n'); + + if (resolved == null) { + Set hints = suggestUsernameClaims(mergedAttributes.keySet()); + if (!hints.isEmpty()) { + sb.append( + "-- Hint: the following claim(s) are present and map to a" + + " known UsernameAttribute value — try setting" + + " security.oauth2.useAsUsername to one of: ") + .append(hints) + .append('\n'); + } + } + } + + sb.append( + "\nWARNING: this block contains PII. Set security.oauth2.debugLogging=false once" + + " troubleshooting is complete.\n"); + sb.append("========== [/OAUTH2 DEBUG] =========="); + + if (failure) { + log.error(sb.toString()); + } else { + log.info(sb.toString()); + } + } + + private static void appendClaims(StringBuilder sb, Map claims) { + if (claims == null || claims.isEmpty()) { + sb.append(" (no claims)\n"); + return; + } + // Sort for stable, scannable output + new TreeSet<>(claims.keySet()) + .forEach( + key -> { + Object value = claims.get(key); + sb.append(" ").append(key).append(" = ").append(value).append('\n'); + }); + } + + /** + * Returns the intersection of the claim keys the provider actually returned and the keys that + * {@link UsernameAttribute} accepts — i.e. valid values the operator could put in {@code + * security.oauth2.useAsUsername} to make this login work. + */ + private static Set suggestUsernameClaims(Set availableClaimKeys) { + Set supported = new TreeSet<>(); + for (UsernameAttribute attr : UsernameAttribute.values()) { + if (availableClaimKeys.contains(attr.getName())) { + supported.add(attr.getName()); + } + } + return supported; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/ClusterStorageGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/ClusterStorageGate.java new file mode 100644 index 0000000000..07320a9ce7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/ClusterStorageGate.java @@ -0,0 +1,95 @@ +package stirling.software.proprietary.storage.config; + +import java.util.Locale; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; + +/** + * Fails fast at boot if cluster mode is enabled with node-local storage. Validates both {@code + * storage.provider} (persistent uploads) and {@code cluster.artifactStore} (transient job-result + * files): neither may be {@code local} when {@code cluster.enabled=true}. Additionally enforces + * that any S3-backed configuration ({@code storage.provider=s3} or {@code + * cluster.artifactStore=s3}) is accompanied by a valid Pro / Enterprise license. + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class ClusterStorageGate { + + private final ApplicationProperties applicationProperties; + private final LicenseKeyChecker licenseKeyChecker; + + @Value("${cluster.enabled:false}") + private boolean clusterEnabled; + + @Value("${cluster.artifactStore:local}") + private String clusterArtifactStore; + + @PostConstruct + void validate() { + // License enforcement runs regardless of cluster.enabled: even a single-node setup that + // selects a remote backend must hold a Pro or higher license. + ApplicationProperties.Storage storage = applicationProperties.getStorage(); + if (storage != null && storage.isEnabled()) { + String provider = normalize(storage.getProvider()); + if ("s3".equals(provider) || "database".equals(provider)) { + licenseKeyChecker.requireProOrEnterprise("storage.provider=" + provider); + } + } + if ("s3".equals(normalize(clusterArtifactStore))) { + licenseKeyChecker.requireProOrEnterprise("cluster.artifactStore=s3"); + } + + if (!clusterEnabled) { + return; + } + if (storage != null && storage.isEnabled()) { + validate( + "storage.provider", + storage.getProvider(), + "Local filesystem storage cannot be shared across cluster nodes." + + " Configure storage.provider=s3 (with storage.s3.bucket /" + + " endpoint / credentials) or storage.provider=database before" + + " enabling clustering."); + } + validate( + "cluster.artifactStore", + clusterArtifactStore, + "Per-node disk cannot back transient job-result files in a multi-node" + + " deployment; downloads would 404 whenever the load balancer routes" + + " a follow-up request to a different node. Configure" + + " cluster.artifactStore=s3 (reuses storage.s3.* config)" + + " before enabling clustering."); + } + + private static String normalize(String value) { + return Optional.ofNullable(value).orElse("local").trim().toLowerCase(Locale.ROOT); + } + + private static void validate(String propertyName, String configuredValue, String remediation) { + String normalized = + Optional.ofNullable(configuredValue) + .orElse("local") + .trim() + .toLowerCase(Locale.ROOT); + if ("local".equals(normalized)) { + throw new IllegalStateException( + "Cluster mode (cluster.enabled=true) is incompatible with " + + propertyName + + "=local. " + + remediation); + } + log.info( + "Cluster storage gate: clusterEnabled=true, {}={} -> OK", propertyName, normalized); + } +} 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 d769997fbc..e990fa2ceb 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 @@ -15,8 +15,11 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; import stirling.software.proprietary.storage.provider.DatabaseStorageProvider; import stirling.software.proprietary.storage.provider.LocalStorageProvider; +import stirling.software.proprietary.storage.provider.S3StorageProvider; import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.repository.StoredFileBlobRepository; @@ -27,8 +30,9 @@ public class StorageProviderConfig { private final ApplicationProperties applicationProperties; private final StoredFileBlobRepository storedFileBlobRepository; + private final LicenseKeyChecker licenseKeyChecker; - @Bean + @Bean(destroyMethod = "close") public StorageProvider storageProvider() { boolean storageEnabled = applicationProperties.getStorage().isEnabled(); String providerName = @@ -37,8 +41,13 @@ public class StorageProviderConfig { .trim() .toLowerCase(Locale.ROOT); if ("database".equals(providerName)) { + licenseKeyChecker.requireProOrEnterprise("storage.provider=database"); return new DatabaseStorageProvider(storedFileBlobRepository); } + if ("s3".equals(providerName)) { + licenseKeyChecker.requireProOrEnterprise("storage.provider=s3"); + return buildS3Provider(applicationProperties.getStorage().getS3()); + } if (!"local".equals(providerName)) { throw new IllegalStateException("Storage provider not supported: " + providerName); } @@ -71,4 +80,9 @@ public class StorageProviderConfig { } return new LocalStorageProvider(basePath); } + + private S3StorageProvider buildS3Provider(ApplicationProperties.Storage.S3 cfg) { + S3Clients.Bundle bundle = S3Clients.build(cfg, "storage provider"); + return new S3StorageProvider(bundle.client(), bundle.presigner(), cfg.getBucket()); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java new file mode 100644 index 0000000000..b2f2cd0933 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java @@ -0,0 +1,91 @@ +package stirling.software.proprietary.storage.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.storage.service.FolderService; + +/** + * Folder placement endpoints for existing stored files. Thin adapter: validates the request shape, + * delegates the transaction to {@link FolderService}, then maps the result onto the HTTP status. + * Authentication, storage-gate, ownership checks, and the bulk cap all live on the service (where + * {@code @Transactional} also lives) so the JDBC connection isn't held through JSON serialization. + */ +@RestController +@RequestMapping("/api/v1/storage/files") +@RequiredArgsConstructor +public class FileFolderPlacementController { + + private static final int BULK_MOVE_MAX_FILES = 1000; + + private final FolderService folderService; + + /** Move a single file to a folder (or to root when folderId is null). */ + @PatchMapping("/{fileId}/folder") + public ResponseEntity moveFileToFolder( + @PathVariable Long fileId, @Valid @RequestBody FolderPlacement body) { + folderService.moveFileToFolder(fileId, body.getFolderId()); + return ResponseEntity.noContent().build(); + } + + /** + * Bulk move - fewer round-trips than calling the single endpoint N times. Returns 200 on full + * success, 207 (Multi-Status) when some files were skipped (typically because they don't belong + * to the caller). + */ + @PatchMapping("/folder") + public ResponseEntity bulkMove(@Valid @RequestBody BulkMoveRequest body) { + FolderService.BulkMoveResult result = + folderService.bulkMoveFilesToFolder(body.getFolderId(), body.getFileIds()); + HttpStatus status = + result.skippedFileIds().isEmpty() ? HttpStatus.OK : HttpStatus.MULTI_STATUS; + return ResponseEntity.status(status) + .body(new BulkMoveResponse(result.movedFileIds(), result.skippedFileIds())); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class FolderPlacement { + private UUID folderId; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class BulkMoveRequest { + private UUID folderId; + + @NotNull + @Size( + min = 1, + max = BULK_MOVE_MAX_FILES, + message = "fileIds must contain between 1 and 1000 entries") + private List fileIds; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class BulkMoveResponse { + private List movedFileIds; + private List skippedFileIds; + } +} 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 6027721a28..4eb299cd24 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 @@ -1,7 +1,11 @@ package stirling.software.proprietary.storage.controller; +import java.io.IOException; +import java.net.URI; +import java.time.Duration; import java.util.List; import java.util.Locale; +import java.util.Optional; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; @@ -25,6 +29,7 @@ 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.proprietary.security.model.User; import stirling.software.proprietary.storage.model.FileShare; @@ -35,17 +40,22 @@ import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse import stirling.software.proprietary.storage.model.api.ShareLinkResponse; import stirling.software.proprietary.storage.model.api.ShareWithUserRequest; import stirling.software.proprietary.storage.model.api.StoredFileResponse; +import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.service.FileStorageService; @RestController @RequestMapping("/api/v1/storage") @RequiredArgsConstructor +@Slf4j @Tag( name = "File Storage", description = "Stored file management, sharing, and share link operations") public class FileStorageController { + private static final Duration SIGNED_URL_TTL = Duration.ofMinutes(5); + private final FileStorageService fileStorageService; + private final StorageProvider storageProvider; @PostMapping( value = "/files", @@ -91,7 +101,9 @@ public class FileStorageController { User user = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getAccessibleFile(user, fileId); fileStorageService.requireReadAccess(user, file); - return buildFileResponse(file, inline); + Optional> redirect = + tryRedirectToSignedUrl(file, inline); + return redirect.orElseGet(() -> buildFileResponse(file, inline)); } @DeleteMapping("/files/{fileId}") @@ -189,7 +201,9 @@ public class FileStorageController { fileStorageService.requireReadAccess(share); fileStorageService.recordShareAccess(share, authentication, inline); StoredFile file = share.getFile(); - return buildFileResponse(file, inline); + Optional> redirect = + tryRedirectToSignedUrl(file, inline); + return redirect.orElseGet(() -> buildFileResponse(file, inline)); } @GetMapping("/share-links/{token}/metadata") @@ -272,4 +286,34 @@ public class FileStorageController { && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal()); } + + private Optional> tryRedirectToSignedUrl( + StoredFile file, boolean inline) { + if (file == null || file.getStorageKey() == null || file.getStorageKey().isBlank()) { + return Optional.empty(); + } + try { + Optional signed = + storageProvider.signedDownloadUrl( + file.getStorageKey(), + SIGNED_URL_TTL, + inline, + file.getOriginalFilename()); + if (signed.isEmpty()) { + return Optional.empty(); + } + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(signed.get()); + ResponseEntity response = + ResponseEntity.status(HttpStatus.FOUND).headers(headers).build(); + return Optional.of(response); + } catch (IOException e) { + log.warn( + "Failed to create signed download URL for file {} (key: {}), falling back to streaming", + file.getId(), + file.getStorageKey(), + e); + return Optional.empty(); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java new file mode 100644 index 0000000000..3b4485a086 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java @@ -0,0 +1,70 @@ +package stirling.software.proprietary.storage.controller; + +import java.net.URI; +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.model.api.FolderResponse; +import stirling.software.proprietary.storage.model.api.UpdateFolderRequest; +import stirling.software.proprietary.storage.service.FolderService; + +/** + * REST endpoints for user-owned folders. Phase A - no folder-level sharing yet (Phase 3). + * + *

All operations are scoped to the authenticated user; existing single-file storage endpoints in + * {@link FileStorageController} are left alone so the cert-signing and standard upload flows are + * unaffected. + */ +@RestController +@RequestMapping("/api/v1/storage/folders") +@RequiredArgsConstructor +public class FolderController { + + private final FolderService folderService; + + @GetMapping + public List listFolders() { + return folderService.listFolders(); + } + + @PostMapping + public ResponseEntity createFolder( + @Valid @RequestBody CreateFolderRequest request) { + FolderResponse response = folderService.createFolder(request); + // 201 Created with Location header - conventional REST. The idempotent re-return path + // (same id resubmitted) also lands here; treating it as 201 keeps wire semantics simple. + return ResponseEntity.status(HttpStatus.CREATED) + .location(URI.create("/api/v1/storage/folders/" + response.id())) + .body(response); + } + + @PatchMapping("/{folderId}") + public ResponseEntity updateFolder( + @PathVariable UUID folderId, @Valid @RequestBody UpdateFolderRequest request) { + return ResponseEntity.ok(folderService.updateFolder(folderId, request)); + } + + @DeleteMapping("/{folderId}") + public ResponseEntity deleteFolder(@PathVariable UUID folderId) { + List removed = folderService.deleteFolder(folderId); + return ResponseEntity.ok(new DeleteFolderResponse(removed)); + } + + public record DeleteFolderResponse(List removedFolderIds) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java new file mode 100644 index 0000000000..5145b756a9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java @@ -0,0 +1,104 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; + +/** + * A user-owned folder used by the file manager UI to organise stored files. Phase A entity - no + * folder-level sharing yet (Phase 3). + * + *

The id is a UUID rather than a numeric auto-increment so it round-trips with the + * client-generated {@code FolderId} and survives cross-device sync without re-keying. + */ +@Entity +@Table( + name = "folders", + indexes = { + @Index(name = "idx_folders_owner", columnList = "owner_id"), + @Index(name = "idx_folders_parent", columnList = "parent_folder_id"), + @Index(name = "idx_folders_owner_parent", columnList = "owner_id, parent_folder_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class Folder implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Dialect-portable UUID column. The previous {@code columnDefinition = "uuid"} was + * Postgres-specific and broke on H2/MariaDB. Hibernate's {@code UUID} mapping picks the right + * native type per dialect (BINARY(16) on H2/MariaDB, uuid on Postgres) when no explicit + * columnDefinition is set. + */ + @Id + @Column(name = "folder_id", nullable = false) + private UUID id; + + /** + * {@code OnDeleteAction.CASCADE} so deleting the owning {@code User} cascades to this row at + * the DB level - UserService.deleteUserRelatedData doesn't enumerate folders today, and leaving + * the FK without an action throws a constraint violation on user delete. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User owner; + + /** + * Parent folder; null = root. {@code OnDeleteAction.CASCADE} so a backend-side parent delete + * cleans children automatically, matching the service-layer recursive-delete contract. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_folder_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private Folder parent; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "color", length = 32) + private String color; + + @Column(name = "icon", length = 64) + private String icon; + + /** + * Optimistic-locking version. Cross-PC sync without this lets last-write-win silently. The + * column is nullable so existing rows from a pre-version deployment can be backfilled by + * Hibernate's update-on-write rather than failing the ddl-auto upgrade. + */ + @Version + @Column(name = "version") + private Long version; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index 0128a2dfdf..cbf5316db6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -6,6 +6,8 @@ import java.util.HashSet; import java.util.Set; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; import jakarta.persistence.CascadeType; @@ -35,7 +37,8 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; name = "stored_files", indexes = { @Index(name = "idx_stored_files_owner", columnList = "owner_id"), - @Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id") + @Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id"), + @Index(name = "idx_stored_files_folder", columnList = "folder_id") }) @NoArgsConstructor @Getter @@ -106,6 +109,20 @@ public class StoredFile implements Serializable { orphanRemoval = true) private Set shares = new HashSet<>(); + /** + * Optional folder placement for the file manager UI. Null = root. Hibernate ddl-auto will add + * this as a nullable column on upgrade so existing records continue to work untouched. + * + *

{@code OnDeleteAction.SET_NULL} so any backend that drops a folder row (admin script, + * future cleanup job, cascading user delete) cleanly orphans files to root rather than leaving + * dangling FK references. The application path ({@code FolderRepository.clearFolderForFiles}) + * still runs first as a belt-and-braces. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + private Folder folder; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java new file mode 100644 index 0000000000..1dafdf1c98 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.storage.model.api; + +import java.util.UUID; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateFolderRequest { + + /** + * Client-generated UUID - lets the caller round-trip the same id it stored locally. Optional; + * the server generates one when missing. + */ + private UUID id; + + @NotBlank + @Size(max = 255) + private String name; + + private UUID parentFolderId; + + /** Hex colour string (#rrggbb or #rrggbbaa) - matches the frontend palette format. */ + @Size(max = 32) + @Pattern( + regexp = "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$", + message = "color must be a #RRGGBB or #RRGGBBAA hex value") + private String color; + + /** Icon identifier - lowercase alphanumerics, hyphens, underscores only. */ + @Size(max = 64) + @Pattern( + regexp = "^[a-z0-9_-]+$", + message = "icon must be a lowercase id (a-z, 0-9, '-' or '_')") + private String icon; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java new file mode 100644 index 0000000000..d1892b6612 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; +import java.util.UUID; + +import stirling.software.proprietary.storage.model.Folder; + +/** + * Outbound DTO for folder responses. Records are immutable, value-equality-based, and far less + * accident-prone than a {@code @Data} class with public setters. + */ +public record FolderResponse( + UUID id, + String name, + UUID parentFolderId, + String color, + String icon, + Long version, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + + public static FolderResponse from(Folder folder) { + // {@code folder.getParent().getId()} on a lazy proxy returns the FK value cached at the + // join column WITHOUT initialising the proxy under standard Hibernate, so this does + // not N+1. If a future Hibernate update changes that, switch the JPQL list query to a + // constructor projection. + UUID parentId = folder.getParent() == null ? null : folder.getParent().getId(); + return new FolderResponse( + folder.getId(), + folder.getName(), + parentId, + folder.getColor(), + folder.getIcon(), + folder.getVersion(), + folder.getCreatedAt(), + folder.getUpdatedAt()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java index 1cf6a20c8e..1d92745d6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.storage.model.api; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import lombok.Builder; import lombok.Getter; @@ -22,4 +23,10 @@ public class StoredFileResponse { private final List sharedUsers; private final List shareLinks; private final String filePurpose; + + /** + * Optional folder placement (Phase A). Null when the file lives at the root or when the server + * build doesn't have the folders feature enabled - existing clients should treat null as root. + */ + private final UUID folderId; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java new file mode 100644 index 0000000000..26f920819a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.storage.model.api; + +import java.util.UUID; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * PATCH-style update - every field is optional. Send only the fields you want to change. + * + *

The {@code reparent} flag distinguishes "do not change parent" from "move to root" since + * {@code parentFolderId == null} alone is ambiguous in a sparse body. We use a boxed {@link + * Boolean} so a missing field deserialises to {@code null} (= "do not reparent") rather than to + * primitive {@code false}, removing a class of "I PATCHed only the name but the server reset my + * parent" footguns. + * + *

When the trimmed name is empty (e.g. {@code " "}) the service rejects the request with HTTP + * 400 - silent drops are too easy to mistake for a successful rename. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateFolderRequest { + + /** When provided, must contain at least one non-whitespace character. */ + @Size(max = 255) + @Pattern(regexp = "\\S.*", message = "name must not be blank") + private String name; + + private Boolean reparent; + private UUID parentFolderId; + + @Size(max = 32) + @Pattern( + regexp = "^(|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$", + message = "color must be empty or a #RRGGBB / #RRGGBBAA hex value") + private String color; + + @Size(max = 64) + @Pattern( + regexp = "^([a-z0-9_-]+)?$", + message = "icon must be a lowercase id (a-z, 0-9, '-' or '_') or empty") + private String icon; + + /** + * Convenience accessor - treats null as "do not reparent". Named differently from the + * Lombok-generated {@code getReparent()} so callers don't accidentally use one for the other + * (the getter is nullable {@code Boolean}; this method collapses to primitive). + */ + @com.fasterxml.jackson.annotation.JsonIgnore + public boolean shouldReparent() { + return Boolean.TRUE.equals(reparent); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java index 5790d43728..75f9eb3fd5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java @@ -24,6 +24,9 @@ public class LocalStorageProvider implements StorageProvider { @Override public StoredObject store(User owner, MultipartFile file) throws IOException { + if (owner == null || owner.getId() == null) { + throw new IllegalArgumentException("owner.id is required for local storage key"); + } String originalFilename = sanitizeFilename(file.getOriginalFilename()); String storageKey = owner.getId() @@ -77,6 +80,7 @@ public class LocalStorageProvider implements StorageProvider { if (filename == null || filename.isBlank()) { return "file"; } - return Paths.get(filename).getFileName().toString(); + String stripped = Paths.get(filename).getFileName().toString().replaceAll("\\p{Cntrl}", ""); + return stripped.isBlank() ? "file" : stripped; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/S3StorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/S3StorageProvider.java new file mode 100644 index 0000000000..a1582458bc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/S3StorageProvider.java @@ -0,0 +1,190 @@ +package stirling.software.proprietary.storage.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.User; + +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +/** {@link StorageProvider} backed by an S3-compatible object store. */ +@Slf4j +public class S3StorageProvider implements StorageProvider, AutoCloseable { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final String bucket; + + public S3StorageProvider(S3Client s3Client, S3Presigner s3Presigner, String bucket) { + if (bucket == null || bucket.isBlank()) { + throw new IllegalArgumentException("S3 bucket must be configured"); + } + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + this.bucket = bucket; + } + + @Override + public StoredObject store(User owner, MultipartFile file) throws IOException { + if (owner == null || owner.getId() == null) { + throw new IllegalArgumentException("owner.id is required for S3 storage key"); + } + String originalFilename = sanitizeFilename(file.getOriginalFilename()); + // Key is opaque ({ownerId}/{uuid}) so non-ASCII filenames don't break vendors that + // restrict key charset (e.g. Supabase Storage returns 400 Invalid key on unicode). + // The display name is preserved in StoredObject.originalFilename and the DB row. + String storageKey = owner.getId() + "/" + UUID.randomUUID(); + + PutObjectRequest.Builder request = + PutObjectRequest.builder().bucket(bucket).key(storageKey); + if (file.getContentType() != null && !file.getContentType().isBlank()) { + request.contentType(file.getContentType()); + } + try (InputStream inputStream = file.getInputStream()) { + s3Client.putObject( + request.build(), RequestBody.fromInputStream(inputStream, file.getSize())); + } catch (SdkException e) { + throw new IOException("Failed to upload object to S3", e); + } + + return StoredObject.builder() + .storageKey(storageKey) + .originalFilename(originalFilename) + .contentType(file.getContentType()) + .sizeBytes(file.getSize()) + .build(); + } + + @Override + public Resource load(String storageKey) throws IOException { + GetObjectRequest request = + GetObjectRequest.builder().bucket(bucket).key(storageKey).build(); + try { + ResponseInputStream stream = s3Client.getObject(request); + long contentLength = + stream.response().contentLength() != null + ? stream.response().contentLength() + : -1; + return new InputStreamResource(stream) { + @Override + public long contentLength() { + return contentLength; + } + }; + } catch (NoSuchKeyException e) { + throw new IOException("File not found", e); + } catch (SdkException e) { + throw new IOException("Failed to load object from S3", e); + } + } + + @Override + public void delete(String storageKey) throws IOException { + try { + s3Client.deleteObject( + DeleteObjectRequest.builder().bucket(bucket).key(storageKey).build()); + } catch (SdkException e) { + throw new IOException("Failed to delete object from S3", e); + } + } + + @Override + public Optional signedDownloadUrl(String storageKey, Duration ttl) throws IOException { + return signedDownloadUrl(storageKey, ttl, false, null); + } + + @Override + public Optional signedDownloadUrl( + String storageKey, Duration ttl, boolean inline, String originalFilename) + throws IOException { + if (storageKey == null || storageKey.isBlank()) { + return Optional.empty(); + } + Duration effectiveTtl = + ttl == null || ttl.isZero() || ttl.isNegative() ? Duration.ofMinutes(5) : ttl; + try { + GetObjectRequest.Builder getBuilder = + GetObjectRequest.builder().bucket(bucket).key(storageKey); + String disposition = buildContentDisposition(inline, originalFilename); + if (disposition != null) { + getBuilder.responseContentDisposition(disposition); + } + GetObjectPresignRequest presignRequest = + GetObjectPresignRequest.builder() + .signatureDuration(effectiveTtl) + .getObjectRequest(getBuilder.build()) + .build(); + PresignedGetObjectRequest presigned = s3Presigner.presignGetObject(presignRequest); + return Optional.of(presigned.url().toURI()); + } catch (SdkException | URISyntaxException e) { + log.warn("Failed to create presigned S3 GET URL for key {}", storageKey, e); + return Optional.empty(); + } + } + + // Returns null when originalFilename is blank; S3 falls back to its own default in that case. + static String buildContentDisposition(boolean inline, String originalFilename) { + if (originalFilename == null || originalFilename.isBlank()) { + return null; + } + // Strip CR/LF and other control chars before path parsing (Paths.get throws on them on + // Windows, and they defeat header parsers). + String stripped = originalFilename.replaceAll("\\p{Cntrl}", ""); + // Use only the basename to avoid leaking directory structure into the header. + int lastSeparator = Math.max(stripped.lastIndexOf('/'), stripped.lastIndexOf('\\')); + if (lastSeparator >= 0) { + stripped = stripped.substring(lastSeparator + 1); + } + if (stripped.isBlank()) { + return null; + } + // Escape per RFC 6266 quoted-string rules. + String escaped = stripped.replace("\\", "\\\\").replace("\"", "\\\""); + return (inline ? "inline" : "attachment") + "; filename=\"" + escaped + "\""; + } + + @Override + public void close() { + try { + s3Presigner.close(); + } catch (Exception e) { + log.warn("Error closing S3 presigner", e); + } + try { + s3Client.close(); + } catch (Exception e) { + log.warn("Error closing S3 client", e); + } + } + + private String sanitizeFilename(String filename) { + if (filename == null || filename.isBlank()) { + return "file"; + } + String stripped = Paths.get(filename).getFileName().toString().replaceAll("\\p{Cntrl}", ""); + return stripped.isBlank() ? "file" : stripped; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java index 5433e68ba4..cc659c1b9f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java @@ -1,16 +1,45 @@ package stirling.software.proprietary.storage.provider; import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; import org.springframework.core.io.Resource; import org.springframework.web.multipart.MultipartFile; import stirling.software.proprietary.security.model.User; -public interface StorageProvider { +public interface StorageProvider extends AutoCloseable { StoredObject store(User owner, MultipartFile file) throws IOException; Resource load(String storageKey) throws IOException; void delete(String storageKey) throws IOException; + + /** + * Releases any backend-specific resources. Default no-op so {@link LocalStorageProvider} and + * {@link DatabaseStorageProvider} (which hold no closeable handles) satisfy Spring's + * {@code @Bean(destroyMethod = "close")} signature requirement without ceremony. {@code + * S3StorageProvider} overrides this to close the underlying SDK client + presigner. + */ + @Override + default void close() {} + + /** + * Returns a presigned download URL valid for {@code ttl}, or {@link Optional#empty()} if the + * provider does not support signed URLs (callers fall back to {@link #load(String)}). + */ + default Optional signedDownloadUrl(String storageKey, Duration ttl) throws IOException { + return signedDownloadUrl(storageKey, ttl, false, null); + } + + /** + * Like {@link #signedDownloadUrl(String, Duration)} with explicit Content-Disposition control. + */ + default Optional signedDownloadUrl( + String storageKey, Duration ttl, boolean inline, String originalFilename) + throws IOException { + return Optional.empty(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java new file mode 100644 index 0000000000..141223d92a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.storage.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; + +public interface FolderRepository extends JpaRepository { + + Optional findByIdAndOwner(UUID id, User owner); + + List findAllByOwnerOrderByName(User owner); + + long countByOwner(User owner); + + /** + * Clear the folder reference on every file currently inside any of the given folders. Used when + * a folder subtree is deleted - files fall back to the root rather than dangling. + * + *

{@code flushAutomatically + clearAutomatically} forces Hibernate to flush any cached dirty + * {@code StoredFile} entities before the bulk UPDATE runs, and clears the persistence context + * afterwards so a subsequent {@code deleteAllByIdInBatch} on the parent folders doesn't see + * stale entity state referencing the about-to-be-deleted folder. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE StoredFile sf SET sf.folder = null WHERE sf.folder.id IN :folderIds") + void clearFolderForFiles(@Param("folderIds") List folderIds); +} 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 bebe65c684..47545ab58a 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 @@ -59,6 +59,13 @@ public interface StoredFileRepository extends JpaRepository { List findAllByOwner(User owner); + /** + * Bulk lookup used by the folder-placement controller. Returns only files owned by {@code + * owner}; ids that don't exist or that belong to another user are silently dropped so the + * caller can compute the "skipped" set by subtraction. + */ + List findAllByIdInAndOwner(List ids, User owner); + @Modifying @Transactional @Query( 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 ba94cab79d..03c3a9178f 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 @@ -459,6 +459,7 @@ public class FileStorageService { file.getPurpose() != null ? file.getPurpose().name().toLowerCase(Locale.ROOT) : null) + .folderId(file.getFolder() != null ? file.getFolder().getId() : null) .build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java new file mode 100644 index 0000000000..c0f07f8ceb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java @@ -0,0 +1,417 @@ +package stirling.software.proprietary.storage.service; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.model.api.FolderResponse; +import stirling.software.proprietary.storage.model.api.UpdateFolderRequest; +import stirling.software.proprietary.storage.repository.FolderRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +/** + * Phase A folder operations. Each call is scoped to the authenticated user - folders are private to + * their owner. Folder-level sharing is a Phase 3 feature. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FolderService { + + /** + * Hard cap on folders per user. Beyond this {@link #createFolder} rejects with 409 - guards + * against per-account folder-explosion DoS and bounds the in-memory subtree walk in {@link + * #deleteFolder}. + */ + private static final long MAX_FOLDERS_PER_USER = 5_000L; + + /** + * Hard cap on chain depth from the root to any folder. Bounds the lazy-proxy walk in {@link + * #enforceDepthAndCycle} - otherwise a user could build a chain up to MAX_FOLDERS_PER_USER deep + * and force one Hibernate SELECT per ancestor on every reparent (5,000+ SELECTs == seconds of + * DB time per request, per-account weaponizable as DoS). + */ + private static final int MAX_FOLDER_DEPTH = 64; + + /** + * Hard cap on bulk-move payload size, mirroring the request-validation cap on {@code + * FileFolderPlacementController.BulkMoveRequest.fileIds}. Re-asserted at the service layer + * because controller-level @Valid bounds aren't enforced when the service is called directly + * (e.g. by future internal callers or tests). + */ + private static final int BULK_MOVE_MAX_FILES = 1000; + + private final FolderRepository folderRepository; + private final StoredFileRepository storedFileRepository; + private final ApplicationProperties applicationProperties; + + /** + * Gate every public method on storage being enabled, mirroring {@code + * FileStorageService.ensureStorageEnabled}. Without this, folder CRUD still works when {@code + * storage.enabled=false} or {@code security.enableLogin=false}, defeating the operator's intent + * to disable storage end-to-end. + */ + private void ensureStorageEnabled() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Storage requires login to be enabled"); + } + if (!applicationProperties.getStorage().isEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled"); + } + } + + /** List every folder owned by the current user, alphabetical. */ + @Transactional(readOnly = true) + public List listFolders() { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + return folderRepository.findAllByOwnerOrderByName(user).stream() + .map(FolderResponse::from) + .toList(); + } + + @Transactional + public FolderResponse createFolder(CreateFolderRequest request) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + // Reject self-parenting up-front. Without this, a client posting + // {id: X, parentFolderId: X} for a folder X they already own would silently + // get the existing folder back (idempotent path) and never learn that the + // parentFolderId they sent was ignored. For new ids the parent lookup would + // 404, but the message is misleading. + if (request.getId() != null && request.getId().equals(request.getParentFolderId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + } + Folder parent = resolveParent(request.getParentFolderId(), user, null); + + UUID id = request.getId() != null ? request.getId() : UUID.randomUUID(); + + // Idempotent: if this user already owns a folder with the supplied id, return it + // unchanged. Single fetch (the previous code did findByIdAndOwner twice with a race + // window between the two lookups). + java.util.Optional existing = folderRepository.findByIdAndOwner(id, user); + if (existing.isPresent()) { + return FolderResponse.from(existing.get()); + } + + // The id is a global primary key. If the id exists for a *different* user, surfacing 500 + // with a constraint-violation stack trace leaks far too much; convert to 409 Conflict so + // the caller can pick a fresh id. + if (folderRepository.existsById(id)) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "A folder with this id already exists; choose a different id"); + } + + if (folderRepository.countByOwner(user) >= MAX_FOLDERS_PER_USER) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Folder limit reached (max " + MAX_FOLDERS_PER_USER + " per user)"); + } + + Folder folder = new Folder(); + folder.setId(id); + folder.setOwner(user); + folder.setParent(parent); + folder.setName(request.getName().trim()); + folder.setColor(request.getColor()); + folder.setIcon(request.getIcon()); + + // saveAndFlush forces the INSERT now so @CreationTimestamp populates + // createdAt/updatedAt before we build the response. Plain save defers + // the SQL until @Transactional commit, and the response would carry + // null timestamps that the frontend trust-boundary parser then rejects. + Folder saved = folderRepository.saveAndFlush(folder); + log.info( + "Folder created: user={} id={} parent={}", + user.getId(), + saved.getId(), + parent == null ? "root" : parent.getId()); + return FolderResponse.from(saved); + } + + @Transactional + public FolderResponse updateFolder(UUID id, UpdateFolderRequest request) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + Folder folder = requireOwnedFolder(id, user); + + if (request.getName() != null) { + String trimmed = request.getName().trim(); + if (trimmed.isEmpty()) { + // Bean validation should already catch this via @Pattern, but be explicit so + // an empty-after-trim payload reaches the user as a 400 instead of being + // silently dropped. + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder name cannot be blank"); + } + folder.setName(trimmed); + } + + if (request.shouldReparent()) { + Folder newParent = resolveParent(request.getParentFolderId(), user, folder.getId()); + folder.setParent(newParent); + } + + if (request.getColor() != null) { + folder.setColor(request.getColor().isEmpty() ? null : request.getColor()); + } + + if (request.getIcon() != null) { + folder.setIcon(request.getIcon().isEmpty() ? null : request.getIcon()); + } + + // saveAndFlush so @UpdateTimestamp populates updatedAt before the + // response is serialized (same reason as createFolder). + return FolderResponse.from(folderRepository.saveAndFlush(folder)); + } + + /** + * Recursive delete. Returns the ids of every folder that was removed so the caller can purge + * them from its local cache. Files inside those folders are detached (folder_id set to null) - + * never deleted. + */ + @Transactional + public List deleteFolder(UUID id) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + Folder folder = requireOwnedFolder(id, user); + + // Build the parent → children map once. Project to id-only via the + // existing entity list (Hibernate already has the column loaded - + // we only access f.getParent().getId() on a managed proxy, which + // does NOT initialize the proxy because Hibernate has the FK + // value cached at the join column). + Map> childIdsByParent = new HashMap<>(); + for (Folder f : folderRepository.findAllByOwnerOrderByName(user)) { + UUID parentId = f.getParent() == null ? null : f.getParent().getId(); + childIdsByParent.computeIfAbsent(parentId, k -> new ArrayList<>()).add(f.getId()); + } + + // Iterative subtree collection - prior recursive form blew the JVM + // stack on deeply nested chains a malicious caller could create. + List removed = new ArrayList<>(); + Set seen = new HashSet<>(); + Deque stack = new ArrayDeque<>(); + stack.push(folder.getId()); + while (!stack.isEmpty()) { + UUID cur = stack.pop(); + if (!seen.add(cur)) continue; + removed.add(cur); + List children = childIdsByParent.get(cur); + if (children != null) { + for (UUID childId : children) stack.push(childId); + } + } + + if (!removed.isEmpty()) { + folderRepository.clearFolderForFiles(removed); + folderRepository.deleteAllByIdInBatch(removed); + log.info( + "Folder subtree deleted: user={} root={} count={}", + user.getId(), + folder.getId(), + removed.size()); + } + + return removed; + } + + /** + * Move a single owned file to a folder (or root when {@code folderId} is null). Owns its + * own @Transactional rather than relying on the caller so the JDBC connection is released as + * soon as the writes commit, not held through controller-side JSON serialization. + */ + @Transactional + public void moveFileToFolder(Long fileId, UUID folderId) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + StoredFile file = + storedFileRepository + .findByIdAndOwner(fileId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "File not found or not owned by current user")); + file.setFolder(resolveOwnedFolder(folderId, user)); + storedFileRepository.save(file); + } + + /** + * Bulk move that returns the moved + skipped split. Skipped == file ids the caller doesn't own + * (or that no longer exist); the controller surfaces this as 207 Multi-Status. + */ + @Transactional + public BulkMoveResult bulkMoveFilesToFolder(UUID folderId, List fileIds) { + ensureStorageEnabled(); + if (fileIds == null || fileIds.isEmpty()) { + return new BulkMoveResult(List.of(), List.of()); + } + if (fileIds.size() > BULK_MOVE_MAX_FILES) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "fileIds must contain between 1 and " + BULK_MOVE_MAX_FILES + " entries"); + } + User user = requireAuthenticatedUser(); + Folder target = resolveOwnedFolder(folderId, user); + + List owned = storedFileRepository.findAllByIdInAndOwner(fileIds, user); + Set ownedIds = new HashSet<>(owned.size()); + for (StoredFile f : owned) { + f.setFolder(target); + ownedIds.add(f.getId()); + } + // If the target folder was deleted concurrently between resolveOwnedFolder and the + // flush, the FK constraint fires as DataIntegrityViolationException. Surface that as + // 409 Conflict so the caller sees an actionable error instead of a 500 stack. + try { + storedFileRepository.saveAll(owned); + storedFileRepository.flush(); + } catch (DataIntegrityViolationException ex) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Target folder no longer exists; refresh and try again", + ex); + } + + List moved = owned.stream().map(StoredFile::getId).toList(); + List skipped = fileIds.stream().filter(id -> !ownedIds.contains(id)).toList(); + if (!skipped.isEmpty()) { + log.warn( + "bulkMove: user {} skipped {} of {} files (not owned or missing)", + user.getId(), + skipped.size(), + fileIds.size()); + } + return new BulkMoveResult(moved, skipped); + } + + /** Result of {@link #bulkMoveFilesToFolder}. Records are immutable + auto-serializable. */ + public record BulkMoveResult(List movedFileIds, List skippedFileIds) {} + + // ─── helpers ──────────────────────────────────────────────────── + + /** + * Resolve a placement-target folder. Distinct from {@link #resolveParent} because move targets + * don't carry the parent-cycle semantics - we only need the folder to exist AND belong to the + * caller. Returns null for null input (root). + */ + private Folder resolveOwnedFolder(UUID folderId, User user) { + if (folderId == null) return null; + return folderRepository + .findByIdAndOwner(folderId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Folder does not exist or is not owned by you")); + } + + private Folder requireOwnedFolder(UUID id, User user) { + return folderRepository + .findByIdAndOwner(id, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Folder not found or not owned by current user")); + } + + private Folder resolveParent(UUID parentId, User user, UUID forbidId) { + if (parentId == null) return null; + if (forbidId != null && parentId.equals(forbidId)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + } + Folder parent = + folderRepository + .findByIdAndOwner(parentId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Parent folder does not exist or is not owned by you")); + // Reject before the child is created/moved if attaching it would push the chain past the + // depth cap. Done in one pass that also returns the cycle answer so we don't walk the + // lazy-proxy chain twice. + enforceDepthAndCycle(parent, user, forbidId); + return parent; + } + + /** + * Single pass that walks the parent chain to root and (a) rejects if attaching a child here + * would exceed MAX_FOLDER_DEPTH, (b) rejects if {@code forbidId} appears in the chain (cycle on + * reparent), (c) rejects on a broken graph, and (d) rejects if any ancestor is owned by a + * different user (defense-in-depth: callers always pass a parent already ownership-checked, but + * the parent chain is followed via lazy proxy without re-checking ownership at each hop, so any + * stray cross-owner edge in the database would otherwise leak ancestor folder ids through the + * cycle error message). The walk is hard-bounded at MAX_FOLDER_DEPTH so a corrupted database + * (chain longer than the API would allow) can never produce an unbounded SELECT loop. + */ + private void enforceDepthAndCycle(Folder candidateParent, User user, UUID forbidId) { + Folder cursor = candidateParent; + Set seen = new HashSet<>(); + int depth = 0; + while (cursor != null) { + if (cursor.getOwner() == null || !cursor.getOwner().getId().equals(user.getId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + } + if (forbidId != null && cursor.getId().equals(forbidId)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Cannot move a folder inside one of its descendants"); + } + if (!seen.add(cursor.getId())) { + // broken graph (cycle in stored data) + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + } + depth += 1; + // candidateParent is at depth 1 from the new child's perspective. After the walk, + // `depth` equals the number of ancestors including candidateParent, which is the + // depth at which the new child would live. Reject before exceeding the cap. + if (depth >= MAX_FOLDER_DEPTH) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Folder nesting limit reached (max " + MAX_FOLDER_DEPTH + " levels)"); + } + cursor = cursor.getParent(); + } + } + + private User requireAuthenticatedUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof User user)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + return user; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3ClientsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3ClientsTest.java new file mode 100644 index 0000000000..50fb7ef184 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3ClientsTest.java @@ -0,0 +1,147 @@ +package stirling.software.proprietary.cluster.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; + +class S3ClientsTest { + + @Test + void validateEndpointHost_publicAwsHost_passes() { + assertThatCode( + () -> + S3Clients.validateEndpointHost( + URI.create("https://s3.us-east-1.amazonaws.com"), false)) + .doesNotThrowAnyException(); + } + + @Test + void validateEndpointHost_metadataServiceIp_rejected() { + assertThatThrownBy( + () -> + S3Clients.validateEndpointHost( + URI.create("http://169.254.169.254/"), false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("allow-private-endpoints"); + } + + @Test + void validateEndpointHost_loopback_rejected() { + assertThatThrownBy( + () -> + S3Clients.validateEndpointHost( + URI.create("http://127.0.0.1:9000/"), false)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void validateEndpointHost_rfc1918Private_rejected() { + assertThatThrownBy( + () -> + S3Clients.validateEndpointHost( + URI.create("http://10.0.0.5:9000/"), false)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void validateEndpointHost_allowPrivateOptIn_bypassesCheck() { + assertThatCode( + () -> + S3Clients.validateEndpointHost( + URI.create("http://169.254.169.254/"), true)) + .doesNotThrowAnyException(); + assertThatCode( + () -> + S3Clients.validateEndpointHost( + URI.create("http://127.0.0.1:9000/"), true)) + .doesNotThrowAnyException(); + } + + @Test + void validateEndpointHost_missingHost_rejected() { + assertThatThrownBy( + () -> + S3Clients.validateEndpointHost( + URI.create("file:///etc/passwd"), false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must include a host"); + } + + @Test + void validateEndpointHost_errorMessageNamesTheFlag() { + assertThat( + catchMessage( + () -> + S3Clients.validateEndpointHost( + URI.create("http://192.168.1.10:9000/"), false))) + .contains("storage.s3.allow-private-endpoints"); + } + + // ----- requestChecksumCalculation parsing ----- + + @Test + void parseRequestChecksum_nullOrBlank_defaultsToWhenSupported() { + assertThat(S3Clients.parseRequestChecksum(null)) + .isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED); + assertThat(S3Clients.parseRequestChecksum("")) + .isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED); + assertThat(S3Clients.parseRequestChecksum(" ")) + .isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED); + } + + @Test + void parseRequestChecksum_caseInsensitive_andTrimmed() { + assertThat(S3Clients.parseRequestChecksum("when_required")) + .isEqualTo(RequestChecksumCalculation.WHEN_REQUIRED); + assertThat(S3Clients.parseRequestChecksum(" WHEN_REQUIRED ")) + .isEqualTo(RequestChecksumCalculation.WHEN_REQUIRED); + assertThat(S3Clients.parseRequestChecksum("When_Supported")) + .isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED); + } + + @Test + void parseRequestChecksum_unknownValue_fallsBackToDefault() { + assertThat(S3Clients.parseRequestChecksum("yes-please")) + .isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED); + assertThat(S3Clients.parseRequestChecksum("disabled-completely")) + .isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED); + } + + // ----- responseChecksumValidation parsing ----- + + @Test + void parseResponseChecksum_nullOrBlank_defaultsToWhenSupported() { + assertThat(S3Clients.parseResponseChecksum(null)) + .isEqualTo(ResponseChecksumValidation.WHEN_SUPPORTED); + assertThat(S3Clients.parseResponseChecksum("")) + .isEqualTo(ResponseChecksumValidation.WHEN_SUPPORTED); + } + + @Test + void parseResponseChecksum_explicitWhenRequired_returnedAsEnum() { + assertThat(S3Clients.parseResponseChecksum("WHEN_REQUIRED")) + .isEqualTo(ResponseChecksumValidation.WHEN_REQUIRED); + } + + @Test + void parseResponseChecksum_unknownValue_fallsBackToDefault() { + assertThat(S3Clients.parseResponseChecksum("nope")) + .isEqualTo(ResponseChecksumValidation.WHEN_SUPPORTED); + } + + private static String catchMessage(Runnable r) { + try { + r.run(); + return ""; + } catch (RuntimeException e) { + return e.getMessage() == null ? "" : e.getMessage(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java new file mode 100644 index 0000000000..b407041bd5 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java @@ -0,0 +1,253 @@ +package stirling.software.proprietary.cluster.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import stirling.software.common.cluster.FileStore; + +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.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +@Testcontainers(disabledWithoutDocker = true) +class S3FileStoreTest { + + private static final String BUCKET = "stirling-test-filestore"; + 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 s3Client; + private static S3FileStore store; + + @BeforeAll + static void setUp() { + URI endpoint = URI.create(minio.getS3URL()); + AwsBasicCredentials creds = AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY); + S3Configuration s3Config = S3Configuration.builder().pathStyleAccessEnabled(true).build(); + + s3Client = + S3Client.builder() + .endpointOverride(endpoint) + .httpClient(UrlConnectionHttpClient.create()) + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create(creds)) + .serviceConfiguration(s3Config) + .build(); + + s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build()); + store = new S3FileStore(s3Client, BUCKET, "transient/", false); + } + + @AfterAll + static void tearDown() { + if (store != null) { + store.close(); + } + if (s3Client != null) { + s3Client.close(); + } + } + + @Test + void blankBucket_constructorRejects() { + assertThatThrownBy(() -> new S3FileStore(s3Client, "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new S3FileStore(s3Client, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void store_thenRetrieve_roundTripsContent() throws IOException { + byte[] payload = "hello cluster s3".getBytes(StandardCharsets.UTF_8); + FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "foo.txt"); + + assertThat(stored.fileId()).isNotBlank(); + assertThat(stored.size()).isEqualTo(payload.length); + + assertThat(store.exists(stored.fileId())).isTrue(); + assertThat(store.size(stored.fileId())).isEqualTo(payload.length); + assertThat(store.retrieveBytes(stored.fileId())).isEqualTo(payload); + + try (InputStream in = store.retrieve(stored.fileId())) { + assertThat(in.readAllBytes()).isEqualTo(payload); + } + } + + @Test + void store_keysUseConfiguredPrefix() throws IOException { + byte[] payload = "prefixed".getBytes(StandardCharsets.UTF_8); + FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "p.txt"); + + String prefixed = store.resolveKey(stored.fileId()); + assertThat(prefixed).startsWith("transient/"); + s3Client.headObject(HeadObjectRequest.builder().bucket(BUCKET).key(prefixed).build()); + + assertThatThrownBy( + () -> + s3Client.headObject( + HeadObjectRequest.builder() + .bucket(BUCKET) + .key(stored.fileId()) + .build())) + .isInstanceOfAny( + NoSuchKeyException.class, + software.amazon.awssdk.services.s3.model.S3Exception.class); + } + + @Test + void emptyPrefix_writesAtBucketRoot() throws IOException { + S3FileStore rootStore = new S3FileStore(s3Client, BUCKET, "", false); + byte[] payload = "no-prefix".getBytes(StandardCharsets.UTF_8); + FileStore.Stored stored = rootStore.store(new ByteArrayInputStream(payload), "r.txt"); + assertThat(rootStore.resolveKey(stored.fileId())).isEqualTo(stored.fileId()); + assertThat(rootStore.retrieveBytes(stored.fileId())).isEqualTo(payload); + assertThat(rootStore.delete(stored.fileId())).isTrue(); + } + + @Test + void delete_removesObject_andReturnsTrue() throws IOException { + FileStore.Stored stored = + store.store(new ByteArrayInputStream(new byte[] {1, 2, 3}), "d.bin"); + assertThat(store.delete(stored.fileId())).isTrue(); + assertThat(store.exists(stored.fileId())).isFalse(); + assertThatThrownBy(() -> store.retrieveBytes(stored.fileId())) + .isInstanceOf(IOException.class); + } + + @Test + void delete_unknownKey_isIdempotentReturnsTrue() { + // S3 DeleteObject is idempotent (returns 204 whether or not the object existed). + // The store reflects S3's behaviour rather than racing a HEAD before each DELETE. + assertThat(store.delete("00000000-0000-0000-0000-000000000000")).isTrue(); + } + + @Test + void retrieve_missingKey_throwsIOException() { + assertThatThrownBy(() -> store.retrieveBytes("does-not-exist")) + .isInstanceOf(IOException.class); + assertThatThrownBy(() -> store.retrieve("does-not-exist")).isInstanceOf(IOException.class); + assertThatThrownBy(() -> store.size("does-not-exist")).isInstanceOf(IOException.class); + } + + @Test + void exists_returnsFalseForBlankOrTraversalIds() { + assertThat(store.exists(null)).isFalse(); + assertThat(store.exists("")).isFalse(); + assertThat(store.exists("..")).isFalse(); + assertThat(store.exists("a/b")).isFalse(); + assertThat(store.exists("a\\b")).isFalse(); + } + + @Test + void delete_traversalId_returnsFalseWithoutCall() { + assertThat(store.delete("../etc/passwd")).isFalse(); + assertThat(store.delete("foo/bar")).isFalse(); + } + + @Test + void store_largePayload_streamsViaTempFileWithoutBufferingInMemory() throws IOException { + long payloadSize = 16L * 1024 * 1024; + Path tempDir = Path.of(System.getProperty("java.io.tmpdir")); + long uploadTempsBefore = countS3UploadTemps(tempDir); + + FileStore.Stored stored; + try (InputStream large = new RepeatingInputStream((byte) 0x42, payloadSize)) { + stored = store.store(large, "big.bin"); + } + + assertThat(stored.size()).isEqualTo(payloadSize); + assertThat(store.size(stored.fileId())).isEqualTo(payloadSize); + assertThat(countS3UploadTemps(tempDir)).isEqualTo(uploadTempsBefore); + store.delete(stored.fileId()); + } + + @Test + void store_uploadFailure_stillDeletesTempFile() { + Path tempDir = Path.of(System.getProperty("java.io.tmpdir")); + long uploadTempsBefore = countS3UploadTemps(tempDir); + + // Non-existent bucket causes putObject to fail after the temp file is written, exercising + // the failure-path cleanup in the finally block. + S3FileStore brokenStore = + new S3FileStore(s3Client, "bucket-that-does-not-exist", "transient/", false); + + assertThatThrownBy( + () -> + brokenStore.store( + new ByteArrayInputStream( + "payload".getBytes(StandardCharsets.UTF_8)), + "x.bin")) + .isInstanceOf(IOException.class); + + assertThat(countS3UploadTemps(tempDir)).isEqualTo(uploadTempsBefore); + } + + private static long countS3UploadTemps(Path tempDir) { + try (Stream entries = Files.list(tempDir)) { + return entries.filter(p -> p.getFileName().toString().startsWith("s3-upload-")).count(); + } catch (IOException e) { + return 0L; + } + } + + /** Generates {@code length} bytes of a single value without buffering them in memory. */ + private static final class RepeatingInputStream extends InputStream { + private final byte value; + private long remaining; + + RepeatingInputStream(byte value, long length) { + this.value = value; + this.remaining = length; + } + + @Override + public int read() { + if (remaining <= 0) { + return -1; + } + remaining--; + return value & 0xFF; + } + + @Override + public int read(byte[] b, int off, int len) { + if (remaining <= 0) { + return -1; + } + int toWrite = (int) Math.min(len, remaining); + for (int i = 0; i < toWrite; i++) { + b[off + i] = value; + } + remaining -= toWrite; + return toWrite; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3VendorComprehensiveTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3VendorComprehensiveTest.java new file mode 100644 index 0000000000..d19c9b171e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3VendorComprehensiveTest.java @@ -0,0 +1,779 @@ +package stirling.software.proprietary.cluster.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.cluster.FileStore; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.provider.S3StorageProvider; +import stirling.software.proprietary.storage.provider.StoredObject; + +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +/** + * Comprehensive live-vendor test against a real S3-compatible endpoint specified via {@code + * S3_SMOKE_*} env vars. Skipped automatically when {@code S3_SMOKE_ENDPOINT} is not set, so CI is + * not affected. Covers: + * + *

    + *
  • {@code S3StorageProvider} CRUD: store / load / delete / presigned URL + *
  • {@code S3FileStore} CRUD (cluster artifact path) + *
  • Folder semantics simulated via key prefixes (matches production usage) + *
  • Negative paths: wrong secret, missing bucket, missing key, traversal IDs + *
  • Edge cases: zero-byte, unicode filename, multi-megabyte streaming + *
  • Configuration guards: SSRF endpoint rejection, bucket validation + *
+ * + * Every uploaded key is tracked and removed in {@link #cleanUp} so re-running against the same + * bucket leaves no residue. + */ +@EnabledIfEnvironmentVariable(named = "S3_SMOKE_ENDPOINT", matches = ".+") +class S3VendorComprehensiveTest { + + private static final String PREFIX = "stirling-comprehensive/" + UUID.randomUUID() + "/"; + + private static ApplicationProperties.Storage.S3 cfg; + private static S3Clients.Bundle bundle; + private static S3StorageProvider provider; + private static String bucket; + private static String vendorLabel; + private static User owner; + + private static final List keysToCleanup = + Collections.synchronizedList(new ArrayList<>()); + + @BeforeAll + static void setUp() { + cfg = configFromEnv(); + bucket = cfg.getBucket(); + vendorLabel = System.getenv().getOrDefault("S3_SMOKE_LABEL", "external"); + bundle = S3Clients.build(cfg, "comprehensive[" + vendorLabel + "]"); + provider = new S3StorageProvider(bundle.client(), bundle.presigner(), bucket); + + owner = new User(); + owner.setId(7L); + owner.setUsername("comprehensive-tester"); + } + + @AfterAll + static void cleanUp() { + if (bundle != null) { + for (String key : keysToCleanup) { + try { + bundle.client().deleteObject(d -> d.bucket(bucket).key(key)); + } catch (Exception e) { + // Best-effort cleanup; ignore. + } + } + try { + provider.close(); + } catch (Exception ignored) { + } + bundle.close(); + } + } + + private static String track(String key) { + keysToCleanup.add(key); + return key; + } + + private static ApplicationProperties.Storage.S3 configFromEnv() { + ApplicationProperties.Storage.S3 c = new ApplicationProperties.Storage.S3(); + c.setEndpoint(System.getenv("S3_SMOKE_ENDPOINT")); + c.setBucket(requireEnv("S3_SMOKE_BUCKET")); + c.setRegion(System.getenv().getOrDefault("S3_SMOKE_REGION", "us-east-1")); + c.setAccessKey(requireEnv("S3_SMOKE_KEY")); + c.setSecretKey(requireEnv("S3_SMOKE_SECRET")); + c.setPathStyleAccess( + Boolean.parseBoolean(System.getenv().getOrDefault("S3_SMOKE_PATHSTYLE", "false"))); + c.setAllowPrivateEndpoints(false); + return c; + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " env var must be set"); + } + return value; + } + + // ========================================================================================== + // FILE CRUD via S3StorageProvider (user-uploaded files) + // ========================================================================================== + + @Test + void provider_store_thenLoad_matchesBytes() throws IOException { + byte[] payload = ("provider-roundtrip-" + vendorLabel).getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = new MockMultipartFile("file", "doc.txt", "text/plain", payload); + + StoredObject obj = provider.store(owner, file); + track(obj.getStorageKey()); + + assertThat(obj.getStorageKey()).isNotBlank(); + assertThat(obj.getSizeBytes()).isEqualTo(payload.length); + assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes()) + .isEqualTo(payload); + } + + @Test + void provider_delete_removesObject() throws IOException { + byte[] payload = "delete-me".getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = new MockMultipartFile("file", "x.txt", "text/plain", payload); + StoredObject obj = provider.store(owner, file); + track(obj.getStorageKey()); + + provider.delete(obj.getStorageKey()); + + assertThatThrownBy(() -> provider.load(obj.getStorageKey())) + .isInstanceOf(IOException.class); + } + + @Test + void provider_load_missingKey_throws() { + assertThatThrownBy(() -> provider.load(PREFIX + "does-not-exist")) + .isInstanceOf(IOException.class); + } + + @Test + void provider_presignedDownload_returnsBytesOverHttp() throws Exception { + byte[] payload = "presign me".getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = new MockMultipartFile("file", "p.txt", "text/plain", payload); + StoredObject obj = provider.store(owner, file); + track(obj.getStorageKey()); + + java.util.Optional url = + provider.signedDownloadUrl(obj.getStorageKey(), Duration.ofMinutes(5)); + assertThat(url).isPresent(); + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(url.get()).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo(payload); + } + + @Test + void provider_store_zeroBytes_isAccepted() throws IOException { + MockMultipartFile empty = + new MockMultipartFile("file", "empty.txt", "text/plain", new byte[0]); + StoredObject obj = provider.store(owner, empty); + track(obj.getStorageKey()); + + assertThat(obj.getSizeBytes()).isZero(); + assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes()) + .isEqualTo(new byte[0]); + } + + @Test + void provider_store_unicodeFilename_yieldsOpaqueAsciiKey_andPreservesNameForDisplay() + throws IOException { + // Regression: pre-fix, the storage key embedded the filename verbatim, which Supabase + // rejected with 400 Invalid key. Post-fix, the key is {ownerId}/{uuid} (ASCII-only) + // and the original unicode name lives on StoredObject.originalFilename. + String unicodeName = "résumé-日本語-é.pdf"; + byte[] payload = "u".getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = + new MockMultipartFile("file", unicodeName, "application/pdf", payload); + + StoredObject obj = provider.store(owner, file); + track(obj.getStorageKey()); + + assertThat(obj.getStorageKey()).matches("[0-9]+/[0-9a-fA-F-]+"); + assertThat(obj.getStorageKey()) + .isEqualTo( + new String( + obj.getStorageKey().getBytes(StandardCharsets.US_ASCII), + StandardCharsets.US_ASCII)); + assertThat(obj.getOriginalFilename()).isEqualTo(unicodeName); + assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes()) + .isEqualTo(payload); + } + + // ========================================================================================== + // Concurrency, overwrite, TTL expiry (added after initial run surfaced the unicode bug) + // ========================================================================================== + + @Test + void provider_concurrent10Uploads_allSucceedWithDistinctKeys() throws Exception { + int n = 10; + java.util.concurrent.ExecutorService pool = + java.util.concurrent.Executors.newFixedThreadPool(n); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < n; i++) { + final int idx = i; + futures.add( + pool.submit( + () -> { + byte[] payload = + ("concurrent-" + idx).getBytes(StandardCharsets.UTF_8); + MockMultipartFile f = + new MockMultipartFile( + "file", + "c-" + idx + ".txt", + "text/plain", + payload); + StoredObject obj = provider.store(owner, f); + track(obj.getStorageKey()); + return obj; + })); + } + + java.util.Set keys = new java.util.HashSet<>(); + for (java.util.concurrent.Future fut : futures) { + StoredObject obj = fut.get(30, java.util.concurrent.TimeUnit.SECONDS); + assertThat(keys.add(obj.getStorageKey())) + .as("distinct key for each parallel upload") + .isTrue(); + assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes()) + .isNotEmpty(); + } + } finally { + pool.shutdownNow(); + } + } + + @Test + void sameKey_overwrite_returnsLatestPayload() { + String key = PREFIX + "overwrite-" + UUID.randomUUID() + ".txt"; + track(key); + + byte[] first = "FIRST".getBytes(StandardCharsets.UTF_8); + byte[] second = "SECOND".getBytes(StandardCharsets.UTF_8); + + bundle.client().putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(first)); + bundle.client().putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(second)); + + assertThat(getRaw(key)).isEqualTo(second); + } + + @Test + void presignedDownload_afterTtlExpiry_returns403() throws Exception { + String key = PREFIX + "presign-expiry-" + UUID.randomUUID() + ".txt"; + byte[] payload = "presign expiry".getBytes(StandardCharsets.UTF_8); + track(putRaw(key, "presign expiry")); + + // 2-second TTL, then wait long enough that any vendor clock skew tolerance is also past. + PresignedGetObjectRequest presigned = + bundle.presigner() + .presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(2)) + .getObjectRequest(g -> g.bucket(bucket).key(key)) + .build()); + + // Confirm it works while valid - rules out unrelated failures. + HttpResponse ok = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(presigned.url().toURI()).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertThat(ok.statusCode()).isEqualTo(200); + assertThat(ok.body()).isEqualTo(payload); + + Thread.sleep(5_000); + + HttpResponse expired = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(presigned.url().toURI()).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertThat(expired.statusCode()) + .as("presigned URL must be rejected after TTL expires") + .isIn(400, 403); + } + + @Test + void provider_store_4MBPayload_streams() throws IOException { + byte[] payload = new byte[4 * 1024 * 1024]; + java.util.Arrays.fill(payload, (byte) 0x42); + MockMultipartFile file = + new MockMultipartFile("file", "big.bin", "application/octet-stream", payload); + + StoredObject obj = provider.store(owner, file); + track(obj.getStorageKey()); + + assertThat(obj.getSizeBytes()).isEqualTo(payload.length); + assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes()) + .isEqualTo(payload); + } + + // ========================================================================================== + // FILE CRUD via S3FileStore (cluster artifact path) + // ========================================================================================== + + @Test + void fileStore_storeAndRetrieve_roundTrip() throws IOException { + S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false); + byte[] payload = "filestore round trip".getBytes(StandardCharsets.UTF_8); + + FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "rt.txt"); + track(store.resolveKey(stored.fileId())); + + assertThat(store.size(stored.fileId())).isEqualTo(payload.length); + assertThat(store.retrieveBytes(stored.fileId())).isEqualTo(payload); + assertThat(store.exists(stored.fileId())).isTrue(); + } + + @Test + void fileStore_delete_returnsTrue_andExistsFalseAfter() throws IOException { + S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false); + FileStore.Stored stored = store.store(new ByteArrayInputStream("x".getBytes()), "del.txt"); + + assertThat(store.delete(stored.fileId())).isTrue(); + assertThat(store.exists(stored.fileId())).isFalse(); + } + + @Test + void fileStore_retrieveBytes_missingKey_throws() { + S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false); + assertThatThrownBy(() -> store.retrieveBytes("does-not-exist")) + .isInstanceOf(IOException.class); + } + + @Test + void fileStore_rejectsTraversalId() { + S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false); + assertThat(store.exists("..")).isFalse(); + assertThat(store.delete("../etc/passwd")).isFalse(); + assertThat(store.exists("a/b")).isFalse(); + assertThat(store.exists("a\\b")).isFalse(); + } + + // ========================================================================================== + // Folder semantics simulated via key prefixes + // ========================================================================================== + + @Test + void folderPrefix_isolatesObjects_andDeleteByPrefixDoesNotTouchRoot() throws IOException { + // Two "folders" + a root object - all reuse the test PREFIX so cleanup catches them. + String folderA = PREFIX + "folder-A/"; + String folderB = PREFIX + "folder-B/"; + String rootObj = PREFIX + "root-" + UUID.randomUUID() + ".txt"; + + track(putRaw(folderA + "file-1.txt", "in-A")); + track(putRaw(folderA + "file-2.txt", "in-A2")); + track(putRaw(folderB + "file-1.txt", "in-B")); + track(putRaw(rootObj, "at-root")); + + // "Delete folder A": delete every key under folderA prefix + deleteAllUnderPrefix(folderA); + + // Verify A is empty, B and root untouched + assertThat(headOrNull(folderA + "file-1.txt")).isNull(); + assertThat(headOrNull(folderB + "file-1.txt")).isNotNull(); + assertThat(headOrNull(rootObj)).isNotNull(); + } + + @Test + void moveBetweenFolders_viaCopyAndDelete_preservesContent() throws Exception { + String oldKey = PREFIX + "move-old/" + UUID.randomUUID() + ".txt"; + String newKey = PREFIX + "move-new/" + UUID.randomUUID() + ".txt"; + byte[] payload = "moveable".getBytes(StandardCharsets.UTF_8); + + track(oldKey); + track(newKey); + bundle.client() + .putObject(p -> p.bucket(bucket).key(oldKey), RequestBody.fromBytes(payload)); + + // Simulate move: server-side copy + delete original. + bundle.client() + .copyObject( + c -> + c.sourceBucket(bucket) + .sourceKey(oldKey) + .destinationBucket(bucket) + .destinationKey(newKey)); + bundle.client().deleteObject(d -> d.bucket(bucket).key(oldKey)); + + assertThat(headOrNull(oldKey)).isNull(); + assertThat(getRaw(newKey)).isEqualTo(payload); + } + + // ========================================================================================== + // Negative: wrong settings / wrong creds + // ========================================================================================== + + @Test + void wrongSecret_throwsOnFirstOperation() { + ApplicationProperties.Storage.S3 bad = configFromEnv(); + bad.setSecretKey("definitely-not-the-real-secret-" + UUID.randomUUID()); + + try (S3Clients.Bundle badBundle = S3Clients.build(bad, "wrong-secret")) { + assertThatThrownBy(() -> badBundle.client().headBucket(h -> h.bucket(bucket))) + .isInstanceOf(S3Exception.class) + .satisfies(e -> assertThat(((S3Exception) e).statusCode()).isIn(401, 403, 400)); + } + } + + @Test + void nonExistentBucket_throwsOnHeadOrPut() { + String fakeBucket = "stirling-no-such-bucket-" + UUID.randomUUID(); + assertThatThrownBy(() -> bundle.client().headBucket(h -> h.bucket(fakeBucket))) + .isInstanceOfAny(NoSuchBucketException.class, S3Exception.class); + } + + @Test + void blankBucket_atBuildTime_throwsIllegalState() { + ApplicationProperties.Storage.S3 bad = configFromEnv(); + bad.setBucket(""); + assertThatThrownBy(() -> S3Clients.build(bad, "blank-bucket")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("bucket"); + } + + @Test + void invalidEndpointUri_atBuildTime_throwsIllegalState() { + ApplicationProperties.Storage.S3 bad = configFromEnv(); + bad.setEndpoint("not a valid uri ::::"); + assertThatThrownBy(() -> S3Clients.build(bad, "bad-uri")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void privateEndpoint_withoutOptIn_atBuildTime_throwsIllegalState() { + ApplicationProperties.Storage.S3 bad = configFromEnv(); + bad.setEndpoint("http://127.0.0.1:9000"); + bad.setAllowPrivateEndpoints(false); + assertThatThrownBy(() -> S3Clients.build(bad, "loopback")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("private"); + } + + @Test + void getMissingKey_returnsNoSuchKey() { + String missing = PREFIX + "missing-" + UUID.randomUUID(); + assertThatThrownBy(() -> bundle.client().getObject(g -> g.bucket(bucket).key(missing))) + .isInstanceOfAny(NoSuchKeyException.class, S3Exception.class); + } + + // ========================================================================================== + // Bundle lifecycle + // ========================================================================================== + + @Test + void bundleClose_isIdempotent() { + ApplicationProperties.Storage.S3 c = configFromEnv(); + S3Clients.Bundle b = S3Clients.build(c, "lifecycle"); + b.close(); + b.close(); // should not throw + } + + // ========================================================================================== + // Internal helpers (using the bundle directly for prefix/folder simulation) + // ========================================================================================== + + private String putRaw(String key, String body) { + bundle.client() + .putObject( + p -> p.bucket(bucket).key(key), + RequestBody.fromBytes(body.getBytes(StandardCharsets.UTF_8))); + return key; + } + + private byte[] getRaw(String key) { + return bundle.client().getObjectAsBytes(g -> g.bucket(bucket).key(key)).asByteArray(); + } + + private Object headOrNull(String key) { + try { + return bundle.client().headObject(h -> h.bucket(bucket).key(key)); + } catch (Exception e) { + return null; + } + } + + private byte[] tryGetBytes(String key) { + try { + return bundle.client().getObjectAsBytes(g -> g.bucket(bucket).key(key)).asByteArray(); + } catch (Exception e) { + return null; + } + } + + private void deleteAllUnderPrefix(String prefix) { + var listing = bundle.client().listObjectsV2(l -> l.bucket(bucket).prefix(prefix)); + for (var obj : listing.contents()) { + bundle.client().deleteObject(d -> d.bucket(bucket).key(obj.key())); + } + } + + // ========================================================================================== + // Key edge cases: leading/trailing/double slash, length, URL-special chars + // ========================================================================================== + + @Test + void key_trailingSlash_storesAsZeroByteFolderMarker() { + String key = PREFIX + "folder-marker-" + UUID.randomUUID() + "/"; + track(key); + + // S3 spec: trailing slash is legal and creates a 0-byte "folder marker" object. + // Some vendors normalize it away; capture either behavior. + bundle.client() + .putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(new byte[0])); + Object head = headOrNull(key); + // Either: vendor accepts the marker (head is non-null) or normalizes to bare key. + assertThat(head != null || headOrNull(key.substring(0, key.length() - 1)) != null) + .as("vendor should either accept trailing-slash marker or normalize to bare key") + .isTrue(); + } + + @Test + void key_doubleSlash_normalizedOrStoredVerbatim() { + String key = PREFIX + "double//slash-" + UUID.randomUUID() + ".txt"; + track(key); + bundle.client() + .putObject( + p -> p.bucket(bucket).key(key), + RequestBody.fromBytes("ds".getBytes(StandardCharsets.UTF_8))); + + // Either GET-with-the-exact-key works, or vendor normalized -> single-slash form works. + String alt = key.replace("//", "/"); + track(alt); + byte[] viaExact = tryGetBytes(key); + byte[] viaNormalized = tryGetBytes(alt); + assertThat(viaExact != null || viaNormalized != null) + .as("either exact double-slash key or normalized single-slash form must return") + .isTrue(); + } + + @Test + void key_200Chars_isStoredAndRetrievable() { + // Stirling production keys are ~45 chars ({ownerId}/{uuid}). 200 chars exceeds that by + // ~5x but stays inside every vendor's documented limit. The S3 spec max is 1024 bytes + // but some vendors (Supabase) impose stricter caps (~250-byte total path including + // bucket prefix - 1000 chars fails with KeyTooLongError). + StringBuilder sb = new StringBuilder(PREFIX + "long/"); + while (sb.length() < 200) { + sb.append("abcdefghij"); + } + String longKey = sb.substring(0, 200); + track(longKey); + + byte[] payload = "long-key".getBytes(StandardCharsets.UTF_8); + bundle.client() + .putObject(p -> p.bucket(bucket).key(longKey), RequestBody.fromBytes(payload)); + assertThat(getRaw(longKey)).isEqualTo(payload); + } + + @Test + void key_safeSpecialChars_areSignedAndRetrievableViaSdk() { + // Restrict to chars every S3-compatible vendor accepts: dot, dash, underscore. + // Stirling's production key format ({ownerId}/{uuid}) is even narrower; this test + // confirms the SDK SigV4 signer copes with slightly more exotic ASCII-safe keys. + // Note: Supabase rejects keys containing space / + / ? / & / # ("400 Invalid key"), + // see documentsVendorKeyRestrictions_tolerantTest for that documentation. + String key = + PREFIX + + "safe-special/" + + UUID.randomUUID() + + "_segment.with-dots.and_underscores.txt"; + track(key); + + bundle.client() + .putObject( + p -> p.bucket(bucket).key(key), + RequestBody.fromBytes("safe".getBytes(StandardCharsets.UTF_8))); + assertThat(getRaw(key)).isEqualTo("safe".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void documentsVendorKeyRestrictions_tolerantTest() { + // Documents - rather than enforces - which key characters cause vendor rejection. + // Stirling production code is safe because S3StorageProvider always emits an + // ASCII-safe UUID-only key. If you ever change that, this test becomes a canary. + // AWS S3 and MinIO accept all of these; Supabase rejects all of them with 400. + String[] suspiciousKeys = { + PREFIX + "with space.txt", + PREFIX + "with+plus.txt", + PREFIX + "with#hash.txt", + PREFIX + "with?question.txt", + PREFIX + "with&.txt", + }; + int accepted = 0; + int rejected = 0; + for (String k : suspiciousKeys) { + track(k); + try { + bundle.client() + .putObject( + p -> p.bucket(bucket).key(k), + RequestBody.fromBytes("x".getBytes(StandardCharsets.UTF_8))); + accepted++; + } catch (S3Exception e) { + assertThat(e.statusCode()) + .as("vendor rejection must be a clean 4xx, not a signature mismatch") + .isBetween(400, 499); + rejected++; + } + } + assertThat(accepted + rejected).isEqualTo(suspiciousKeys.length); + } + + // ========================================================================================== + // Presigned-URL: TTL bounds + Content-Disposition behavior (Stirling uses this for shares) + // ========================================================================================== + + @Test + void presignedGet_ttlExceeding7Days_isRejectedAtSigningTime() { + String key = PREFIX + "ttl-overflow-" + UUID.randomUUID() + ".txt"; + track(putRaw(key, "x")); + + // SigV4 caps presigned URL TTL at 7 days. SDK should refuse to sign anything larger. + assertThatThrownBy( + () -> + bundle.presigner() + .presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofDays(8)) + .getObjectRequest( + g -> g.bucket(bucket).key(key)) + .build())) + .isInstanceOfAny(IllegalArgumentException.class, RuntimeException.class); + } + + @Test + void provider_signedDownloadUrl_attachmentDisposition_endsWithAttachmentHeader() + throws Exception { + byte[] payload = "attach me".getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = + new MockMultipartFile("file", "report.pdf", "application/pdf", payload); + StoredObject obj = provider.store(owner, file); + track(obj.getStorageKey()); + + java.util.Optional url = + provider.signedDownloadUrl( + obj.getStorageKey(), Duration.ofMinutes(2), false, "report.pdf"); + assertThat(url).isPresent(); + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(url.get()).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(resp.statusCode()).isEqualTo(200); + // Supabase + AWS both honor response-content-disposition query param. + assertThat(resp.headers().firstValue("content-disposition").orElse("")) + .as("vendor must honor response-content-disposition override in presigned URL") + .startsWith("attachment"); + } + + @Test + void provider_signedDownloadUrl_inlineDisposition_endsWithInlineHeader() throws Exception { + byte[] payload = "inline".getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = + new MockMultipartFile("file", "preview.pdf", "application/pdf", payload); + StoredObject obj = provider.store(owner, file); + track(obj.getStorageKey()); + + java.util.Optional url = + provider.signedDownloadUrl( + obj.getStorageKey(), Duration.ofMinutes(2), true, "preview.pdf"); + assertThat(url).isPresent(); + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(url.get()).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.headers().firstValue("content-disposition").orElse("")) + .as("inline=true must set 'inline' disposition") + .startsWith("inline"); + } + + // ========================================================================================== + // List pagination + HEAD missing semantics + // ========================================================================================== + + @Test + void listObjectsV2_paginationWithMaxKeys_returnsContinuationToken() { + // Stage 3 objects under a unique sub-prefix. + String prefix = PREFIX + "page-" + UUID.randomUUID() + "/"; + for (int i = 0; i < 3; i++) { + track(putRaw(prefix + "obj-" + i, "p" + i)); + } + + var first = bundle.client().listObjectsV2(l -> l.bucket(bucket).prefix(prefix).maxKeys(1)); + assertThat(first.contents()).hasSize(1); + assertThat(first.isTruncated()).isTrue(); + assertThat(first.nextContinuationToken()).isNotBlank(); + + var second = + bundle.client() + .listObjectsV2( + l -> + l.bucket(bucket) + .prefix(prefix) + .maxKeys(2) + .continuationToken(first.nextContinuationToken())); + assertThat(second.contents()).hasSize(2); + assertThat(second.isTruncated()).isFalse(); + } + + @Test + void headObject_missingKey_throwsNoSuchKeyOr404() { + String missing = PREFIX + "head-missing-" + UUID.randomUUID(); + assertThatThrownBy(() -> bundle.client().headObject(h -> h.bucket(bucket).key(missing))) + .isInstanceOf(S3Exception.class) + .satisfies(e -> assertThat(((S3Exception) e).statusCode()).isEqualTo(404)); + } + + /** + * Presigned-URL test scaffolding for parity with the smoke test (covers the SDK presign path). + */ + @Test + void presignGetObject_independentOfProvider_returnsBytes() throws Exception { + String key = PREFIX + "presign-direct-" + UUID.randomUUID() + ".txt"; + byte[] payload = "direct presign".getBytes(StandardCharsets.UTF_8); + track(putRaw(key, "direct presign")); + + PresignedGetObjectRequest presigned = + bundle.presigner() + .presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(2)) + .getObjectRequest(g -> g.bucket(bucket).key(key)) + .build()); + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(presigned.url().toURI()).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo(payload); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3VendorSmokeTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3VendorSmokeTest.java new file mode 100644 index 0000000000..f7178578e4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3VendorSmokeTest.java @@ -0,0 +1,161 @@ +package stirling.software.proprietary.cluster.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import stirling.software.common.cluster.FileStore; +import stirling.software.common.model.ApplicationProperties; + +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +/** + * End-to-end smoke against the full {@link S3Clients#build} path. Defaults to a LocalStack + * container so it runs in CI; if {@code S3_SMOKE_ENDPOINT} is set, swaps in a real vendor (AWS / + * Supabase / R2 / MinIO over network) to validate live signing + DNS. + */ +@Testcontainers(disabledWithoutDocker = true) +class S3VendorSmokeTest { + + private static LocalStackContainer localstack; + private static S3Clients.Bundle bundle; + private static String bucket; + private static String vendorLabel; + + @BeforeAll + static void setUp() { + ApplicationProperties.Storage.S3 cfg = new ApplicationProperties.Storage.S3(); + String envEndpoint = System.getenv("S3_SMOKE_ENDPOINT"); + + if (envEndpoint != null && !envEndpoint.isBlank()) { + vendorLabel = System.getenv().getOrDefault("S3_SMOKE_LABEL", "external"); + cfg.setEndpoint(envEndpoint); + cfg.setBucket(requireEnv("S3_SMOKE_BUCKET")); + cfg.setRegion(System.getenv().getOrDefault("S3_SMOKE_REGION", "us-east-1")); + cfg.setAccessKey(requireEnv("S3_SMOKE_KEY")); + cfg.setSecretKey(requireEnv("S3_SMOKE_SECRET")); + cfg.setPathStyleAccess( + Boolean.parseBoolean( + System.getenv().getOrDefault("S3_SMOKE_PATHSTYLE", "false"))); + cfg.setAllowPrivateEndpoints( + Boolean.parseBoolean( + System.getenv().getOrDefault("S3_SMOKE_ALLOWPRIVATE", "false"))); + } else { + vendorLabel = "localstack"; + localstack = + new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8")) + .withServices(LocalStackContainer.Service.S3); + localstack.start(); + cfg.setEndpoint( + localstack.getEndpointOverride(LocalStackContainer.Service.S3).toString()); + cfg.setBucket("stirling-smoke"); + cfg.setRegion(localstack.getRegion()); + cfg.setAccessKey(localstack.getAccessKey()); + cfg.setSecretKey(localstack.getSecretKey()); + // Exercise virtual-hosted addressing where possible. LocalStack supports both; + // path-style remains covered by the MinIO suite. + cfg.setPathStyleAccess(false); + // Required: localhost is a loopback address and would otherwise be rejected. + cfg.setAllowPrivateEndpoints(true); + } + + bundle = S3Clients.build(cfg, "vendor-smoke[" + vendorLabel + "]"); + bucket = cfg.getBucket(); + ensureBucketExists(bucket); + } + + @AfterAll + static void tearDown() { + if (bundle != null) { + bundle.close(); + } + if (localstack != null) { + localstack.stop(); + } + } + + @Test + void s3FileStore_roundTripsContentAgainstVendor() throws Exception { + S3FileStore store = new S3FileStore(bundle.client(), bucket, "smoke/", false); + byte[] payload = ("hello from " + vendorLabel).getBytes(StandardCharsets.UTF_8); + + FileStore.Stored stored = + store.store(new ByteArrayInputStream(payload), "smoke-payload.txt"); + try { + assertThat(stored.size()).isEqualTo(payload.length); + assertThat(store.exists(stored.fileId())).isTrue(); + assertThat(store.size(stored.fileId())).isEqualTo(payload.length); + assertThat(store.retrieveBytes(stored.fileId())).isEqualTo(payload); + } finally { + assertThat(store.delete(stored.fileId())).isTrue(); + assertThat(store.exists(stored.fileId())).isFalse(); + } + } + + @Test + void presignedGet_downloadsContentOverHttp() throws Exception { + String key = "smoke/presign-" + System.currentTimeMillis() + ".txt"; + byte[] payload = ("presigned by " + vendorLabel).getBytes(StandardCharsets.UTF_8); + + bundle.client().putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(payload)); + try { + PresignedGetObjectRequest presigned = + bundle.presigner() + .presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(5)) + .getObjectRequest(g -> g.bucket(bucket).key(key)) + .build()); + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(presigned.url().toURI()).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo(payload); + } finally { + bundle.client().deleteObject(d -> d.bucket(bucket).key(key)); + } + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException( + name + " env var must be set when S3_SMOKE_ENDPOINT is set"); + } + return value; + } + + private static void ensureBucketExists(String b) { + try { + bundle.client().headBucket(h -> h.bucket(b)); + } catch (S3Exception e) { + if (e.statusCode() == 404 || e.statusCode() == 301 || e.statusCode() == 400) { + try { + bundle.client().createBucket(c -> c.bucket(b)); + } catch (S3Exception ignored) { + // Bucket already exists or vendor disallows runtime create (Supabase/R2 often + // require pre-create). Caller is expected to have pre-created it in that case. + } + } + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/EEAppConfigTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/EEAppConfigTest.java new file mode 100644 index 0000000000..9e5f4165ba --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/EEAppConfigTest.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.security.configuration.ee; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; + +class EEAppConfigTest { + + @Test + void ssoAutoLogin_disabled_returnsFalse_andDoesNotConsultLicense() { + ApplicationProperties props = new ApplicationProperties(); + props.getPremium().getProFeatures().setSsoAutoLogin(false); + LicenseKeyChecker checker = mock(LicenseKeyChecker.class); + + EEAppConfig cfg = new EEAppConfig(props, checker); + + assertThat(cfg.ssoAutoLogin()).isFalse(); + verifyNoInteractions(checker); + } + + @Test + void ssoAutoLogin_enabled_withProLicense_returnsTrue() { + ApplicationProperties props = new ApplicationProperties(); + props.getPremium().getProFeatures().setSsoAutoLogin(true); + LicenseKeyChecker checker = mock(LicenseKeyChecker.class); + when(checker.getPremiumLicenseEnabledResult()) + .thenReturn(KeygenLicenseVerifier.License.SERVER); + + EEAppConfig cfg = new EEAppConfig(props, checker); + + assertThat(cfg.ssoAutoLogin()).isTrue(); + } + + @Test + void ssoAutoLogin_enabled_withoutLicense_throwsAtBootTime() { + ApplicationProperties props = new ApplicationProperties(); + props.getPremium().getProFeatures().setSsoAutoLogin(true); + LicenseKeyChecker checker = mock(LicenseKeyChecker.class); + // Real LicenseKeyChecker.requireProOrEnterprise throws on NORMAL; mock that behavior here. + org.mockito.Mockito.doThrow( + new IllegalStateException( + "premium.proFeatures.ssoAutoLogin=true requires a Pro or Enterprise license")) + .when(checker) + .requireProOrEnterprise("premium.proFeatures.ssoAutoLogin=true"); + + EEAppConfig cfg = new EEAppConfig(props, checker); + + assertThatThrownBy(cfg::ssoAutoLogin) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "premium.proFeatures.ssoAutoLogin=true requires a Pro or Enterprise license"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java index b30b7fdbd9..ae4fa2162d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.security.configuration.ee; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -86,4 +88,43 @@ class LicenseKeyCheckerTest { assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult()); verifyNoInteractions(verifier); } + + // ----- requireProOrEnterprise: shared boot-time gate for premium features ----- + + @Test + void requireProOrEnterprise_normalLicense_throwsWithFeatureName() { + LicenseKeyChecker checker = checkerWithLicense(License.NORMAL); + assertThatThrownBy(() -> checker.requireProOrEnterprise("storage.provider=s3")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("storage.provider=s3 requires a Pro or Enterprise license"); + } + + @Test + void requireProOrEnterprise_serverLicense_passes() { + LicenseKeyChecker checker = checkerWithLicense(License.SERVER); + assertThatCode(() -> checker.requireProOrEnterprise("any.feature=true")) + .doesNotThrowAnyException(); + } + + @Test + void requireProOrEnterprise_enterpriseLicense_passes() { + LicenseKeyChecker checker = checkerWithLicense(License.ENTERPRISE); + assertThatCode(() -> checker.requireProOrEnterprise("any.feature=true")) + .doesNotThrowAnyException(); + } + + private LicenseKeyChecker checkerWithLicense(License level) { + ApplicationProperties props = new ApplicationProperties(); + if (level == License.NORMAL) { + props.getPremium().setEnabled(false); + } else { + props.getPremium().setEnabled(true); + props.getPremium().setKey("any"); + when(verifier.verifyLicense("any")).thenReturn(level); + } + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); + return checker; + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/CustomOAuth2UserServiceDebugLoggingTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/CustomOAuth2UserServiceDebugLoggingTest.java new file mode 100644 index 0000000000..b22aa41f23 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/CustomOAuth2UserServiceDebugLoggingTest.java @@ -0,0 +1,221 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; + +import stirling.software.common.model.ApplicationProperties; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +/** + * Verifies the opt-in OAuth2/OIDC claim-dump diagnostic logging added to {@link + * CustomOAuth2UserService} for troubleshooting provider misconfiguration (e.g. ADFS not emitting an + * {@code email} claim). + */ +@ExtendWith(MockitoExtension.class) +class CustomOAuth2UserServiceDebugLoggingTest { + + @Mock private UserService userService; + @Mock private LoginAttemptService loginAttemptService; + @Mock private OidcUserRequest userRequest; + + private ListAppender appender; + private Logger serviceLogger; + + @BeforeEach + void attachLogCapture() { + serviceLogger = (Logger) LoggerFactory.getLogger(CustomOAuth2UserService.class); + appender = new ListAppender<>(); + appender.start(); + serviceLogger.addAppender(appender); + // Make sure INFO-level dumps reach the appender even if the default config is WARN+. + serviceLogger.setLevel(Level.DEBUG); + } + + @AfterEach + void detachLogCapture() { + serviceLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void whenDebugLoggingOff_failureProducesNoClaimDump() throws Exception { + ApplicationProperties.Security.OAUTH2 props = oauthProps("email", false); + CustomOAuth2UserService service = + new CustomOAuth2UserService(props, userService, loginAttemptService); + // Provider gave us claims, but no "email" — same shape as the ADFS bug report. + Map claims = baseClaims(); + claims.put("upn", "jdoe@demarest.com.br"); + replaceDelegateWithStub(service, claims); + lenient() + .when(userRequest.getIdToken()) + .thenReturn(new OidcIdToken("token", Instant.now(), Instant.MAX, claims)); + lenient().when(userRequest.getClientRegistration()).thenReturn(stubRegistration()); + + assertThrows(OAuth2AuthenticationException.class, () -> service.loadUser(userRequest)); + + assertThat(appender.list) + .as("no debug dump should appear when debugLogging=false") + .noneMatch(e -> e.getFormattedMessage().contains("[OAUTH2 DEBUG]")); + } + + @Test + void whenDebugLoggingOn_failureDumpsClaimsAndSuggestsAlternative() throws Exception { + ApplicationProperties.Security.OAUTH2 props = oauthProps("email", true); + CustomOAuth2UserService service = + new CustomOAuth2UserService(props, userService, loginAttemptService); + Map claims = adfsStyleClaims(); + // ADFS-style: no `email`, but `preferred_username` IS a valid UsernameAttribute value. + claims.put("preferred_username", "jdoe@demarest.com.br"); + // `upn` is NOT in UsernameAttribute, so it must NOT appear in the suggestion hint. + claims.put("upn", "jdoe@demarest.com.br"); + replaceDelegateWithStub(service, claims); + lenient() + .when(userRequest.getIdToken()) + .thenReturn(new OidcIdToken("token", Instant.now(), Instant.MAX, claims)); + lenient().when(userRequest.getClientRegistration()).thenReturn(stubRegistration()); + + assertThrows(OAuth2AuthenticationException.class, () -> service.loadUser(userRequest)); + + List dumps = + appender.list.stream() + .filter(e -> e.getFormattedMessage().contains("[OAUTH2 DEBUG]")) + .toList(); + assertThat(dumps).as("expected at least one debug-dump log line").isNotEmpty(); + + String combined = + String.join("\n", dumps.stream().map(ILoggingEvent::getFormattedMessage).toList()); + assertThat(combined) + .contains("Provider registrationId : demarest") + .contains("Configured useAsUsername: email") + .contains("preferred_username") + .contains("upn = jdoe@demarest.com.br") + .contains(""); + // The hint must include 'preferred_username' (a valid UsernameAttribute value present + // in the claims) and MUST NOT include 'upn' (not in the UsernameAttribute enum). + String hintLine = + combined.lines() + .filter(l -> l.contains("Hint:")) + .findFirst() + .orElseThrow(() -> new AssertionError("no Hint: line in dump")); + assertThat(hintLine).contains("preferred_username").doesNotContain("upn"); + } + + @Test + void invalidUseAsUsername_isWrappedAsOAuth2AuthenticationException() { + // Regression: an earlier draft moved UsernameAttribute.valueOf(...) outside the try/catch, + // so a typo'd or null useAsUsername leaked as a raw IllegalArgumentException instead of + // being wrapped, breaking Spring's authentication exception handling. This test pins the + // post-fix behaviour: valueOf() failures stay inside the guarded section. + ApplicationProperties.Security.OAUTH2 props = oauthProps("not_a_real_attribute", true); + CustomOAuth2UserService service = + new CustomOAuth2UserService(props, userService, loginAttemptService); + lenient().when(userRequest.getClientRegistration()).thenReturn(stubRegistration()); + // No need to stub the OIDC delegate — control flow shouldn't reach it. + + OAuth2AuthenticationException thrown = + assertThrows( + OAuth2AuthenticationException.class, () -> service.loadUser(userRequest)); + assertThat(thrown.getCause()).isInstanceOf(IllegalArgumentException.class); + // We deliberately do NOT emit the claim dump in this case (we have no resolved + // usernameAttributeKey to compare against, and the IllegalArgumentException message + // already explains the misconfiguration). + assertThat(appender.list) + .as("no claim dump when useAsUsername itself is invalid") + .noneMatch(e -> e.getFormattedMessage().contains("[OAUTH2 DEBUG]")); + } + + // ---------- helpers ---------- + + private static ApplicationProperties.Security.OAUTH2 oauthProps( + String useAsUsername, boolean debugLogging) { + ApplicationProperties.Security.OAUTH2 p = new ApplicationProperties.Security.OAUTH2(); + p.setEnabled(true); + p.setUseAsUsername(useAsUsername); + p.setDebugLogging(debugLogging); + return p; + } + + private static Map baseClaims() { + Map claims = new LinkedHashMap<>(); + claims.put(IdTokenClaimNames.SUB, "abc-123"); + claims.put(IdTokenClaimNames.ISS, "https://sts.example.com/adfs"); + claims.put(IdTokenClaimNames.AUD, Collections.singletonList("client-id")); + claims.put(IdTokenClaimNames.IAT, Instant.now()); + claims.put(IdTokenClaimNames.EXP, Instant.now().plusSeconds(3600)); + claims.put("given_name", "Jane"); + claims.put("family_name", "Doe"); + return claims; + } + + /** + * ADFS-style claim set with {@code given_name}/{@code family_name} removed, so the suggestion + * hint test isolates a single expected UsernameAttribute value. + */ + private static Map adfsStyleClaims() { + Map claims = baseClaims(); + claims.remove("given_name"); + claims.remove("family_name"); + return claims; + } + + private static ClientRegistration stubRegistration() { + return ClientRegistration.withRegistrationId("demarest") + .clientId("client-id") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("https://app.example.com/login/oauth2/code/demarest") + .authorizationUri("https://sts.example.com/adfs/oauth2/authorize") + .tokenUri("https://sts.example.com/adfs/oauth2/token") + .jwkSetUri("https://sts.example.com/adfs/discovery/keys") + .build(); + } + + /** + * Swap the private {@code delegate} field on {@link CustomOAuth2UserService} for a stub that + * returns a {@link DefaultOidcUser} built from the supplied claims. Lets us drive the test + * without standing up a real OIDC provider. + */ + private void replaceDelegateWithStub( + CustomOAuth2UserService service, Map claims) throws Exception { + OidcIdToken idToken = + new OidcIdToken("raw-token", Instant.now(), Instant.MAX, new HashMap<>(claims)); + DefaultOidcUser delegateUser = + new DefaultOidcUser(Collections.emptyList(), idToken, IdTokenClaimNames.SUB); + OidcUserService delegateMock = org.mockito.Mockito.mock(OidcUserService.class); + when(delegateMock.loadUser(any())).thenReturn(delegateUser); + Field f = CustomOAuth2UserService.class.getDeclaredField("delegate"); + f.setAccessible(true); + f.set(service, delegateMock); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/ClusterStorageGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/ClusterStorageGateTest.java new file mode 100644 index 0000000000..ee7dc1da35 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/ClusterStorageGateTest.java @@ -0,0 +1,250 @@ +package stirling.software.proprietary.storage.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; + +class ClusterStorageGateTest { + + @Test + void clusterDisabled_localStorage_passes() { + ClusterStorageGate gate = newGate(false, true, "local", "local"); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void clusterDisabled_s3Storage_passes() { + ClusterStorageGate gate = newGate(false, true, "s3", "local"); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void clusterEnabled_storageDisabled_butArtifactStoreLocal_fails() { + ClusterStorageGate gate = newGate(true, false, "local", "local"); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cluster.artifactStore=local"); + } + + @Test + void clusterEnabled_storageDisabled_artifactStoreS3_passes() { + ClusterStorageGate gate = newGate(true, false, "local", "s3"); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void clusterEnabled_localStorage_fails() { + ClusterStorageGate gate = newGate(true, true, "local", "s3"); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("storage.provider=local") + .hasMessageContaining("storage.provider=s3") + .hasMessageContaining("storage.provider=database"); + } + + @Test + void clusterEnabled_localStorage_caseInsensitive_fails() { + ClusterStorageGate gate = newGate(true, true, "LOCAL", "s3"); + assertThatThrownBy(gate::validate).isInstanceOf(IllegalStateException.class); + } + + @Test + void clusterEnabled_nullProvider_treatedAsLocal_fails() { + ClusterStorageGate gate = newGate(true, true, null, "s3"); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("storage.provider=local"); + } + + @Test + void clusterEnabled_s3Storage_andArtifactStoreS3_passes() { + ClusterStorageGate gate = newGate(true, true, "s3", "s3"); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void clusterEnabled_databaseStorage_andArtifactStoreS3_passes() { + ClusterStorageGate gate = newGate(true, true, "database", "s3"); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void clusterEnabled_s3Storage_butLocalArtifactStore_fails() { + ClusterStorageGate gate = newGate(true, true, "s3", "local"); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cluster.artifactStore=local"); + } + + @Test + void clusterEnabled_localArtifactStore_caseInsensitive_fails() { + ClusterStorageGate gate = newGate(true, true, "s3", "LOCAL"); + assertThatThrownBy(gate::validate).isInstanceOf(IllegalStateException.class); + } + + @Test + void clusterEnabled_nullArtifactStore_treatedAsLocal_fails() { + ClusterStorageGate gate = newGate(true, true, "s3", null); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cluster.artifactStore=local"); + } + + @Test + void clusterEnabled_nullStorageObject_passesProviderCheck_butArtifactStoreStillEvaluated() { + ApplicationProperties props = new ApplicationProperties(); + props.setStorage(null); + ClusterStorageGate gate = new ClusterStorageGate(props, mockLicenseChecker(License.SERVER)); + setClusterEnabled(gate, true); + setClusterArtifactStore(gate, "s3"); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + // ----- License gating for premium storage backends ----- + + @Test + void storageProviderS3_withoutProLicense_throws() { + ClusterStorageGate gate = newGate(false, true, "s3", "local", License.NORMAL); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("storage.provider=s3 requires a Pro or Enterprise license"); + } + + @Test + void storageProviderDatabase_withoutProLicense_throws() { + ClusterStorageGate gate = newGate(false, true, "database", "local", License.NORMAL); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "storage.provider=database requires a Pro or Enterprise license"); + } + + @Test + void storageProviderS3_withServerLicense_passes() { + ClusterStorageGate gate = newGate(false, true, "s3", "local", License.SERVER); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void storageProviderS3_withEnterpriseLicense_passes() { + ClusterStorageGate gate = newGate(false, true, "s3", "local", License.ENTERPRISE); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void storageProviderDatabase_withServerLicense_passes() { + ClusterStorageGate gate = newGate(false, true, "database", "local", License.SERVER); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void clusterArtifactStoreS3_withoutProLicense_throws() { + ClusterStorageGate gate = newGate(false, false, "local", "s3", License.NORMAL); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "cluster.artifactStore=s3 requires a Pro or Enterprise license"); + } + + @Test + void clusterArtifactStoreS3_withServerLicense_passes() { + ClusterStorageGate gate = newGate(false, false, "local", "s3", License.SERVER); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void localOnly_normalLicense_passes_licenseNotChecked() { + ClusterStorageGate gate = newGate(false, true, "local", "local", License.NORMAL); + assertThatCode(gate::validate).doesNotThrowAnyException(); + } + + @Test + void storageDisabled_butArtifactStoreS3_withoutLicense_stillThrows() { + ClusterStorageGate gate = newGate(false, false, "local", "s3", License.NORMAL); + assertThatThrownBy(gate::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cluster.artifactStore=s3"); + } + + private static ClusterStorageGate newGate( + boolean clusterEnabled, + boolean storageEnabled, + String provider, + String clusterArtifactStore) { + // Default to a SERVER license so existing tests (which assert clustering / artifact-store + // rules independently of license) continue to pass. License-specific tests below build + // gates with explicit license tiers. + return newGate( + clusterEnabled, storageEnabled, provider, clusterArtifactStore, License.SERVER); + } + + private static ClusterStorageGate newGate( + boolean clusterEnabled, + boolean storageEnabled, + String provider, + String clusterArtifactStore, + License license) { + ApplicationProperties props = new ApplicationProperties(); + ApplicationProperties.Storage storage = new ApplicationProperties.Storage(); + storage.setEnabled(storageEnabled); + storage.setProvider(provider); + props.setStorage(storage); + LicenseKeyChecker checker = mockLicenseChecker(license); + ClusterStorageGate gate = new ClusterStorageGate(props, checker); + setClusterEnabled(gate, clusterEnabled); + setClusterArtifactStore(gate, clusterArtifactStore); + return gate; + } + + private static LicenseKeyChecker mockLicenseChecker(License license) { + LicenseKeyChecker checker = mock(LicenseKeyChecker.class); + when(checker.getPremiumLicenseEnabledResult()).thenReturn(license); + if (license == License.SERVER || license == License.ENTERPRISE) { + doNothing().when(checker).requireProOrEnterprise(anyString()); + } else { + // Mirror real LicenseKeyChecker.requireProOrEnterprise so message assertions match. + org.mockito.Mockito.doAnswer( + inv -> { + throw new IllegalStateException( + inv.getArgument(0) + + " requires a Pro or Enterprise license"); + }) + .when(checker) + .requireProOrEnterprise(anyString()); + } + return checker; + } + + private static void setClusterEnabled(ClusterStorageGate gate, boolean enabled) { + try { + Field f = ClusterStorageGate.class.getDeclaredField("clusterEnabled"); + f.setAccessible(true); + f.setBoolean(gate, enabled); + assertThat(f.getBoolean(gate)).isEqualTo(enabled); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to set clusterEnabled via reflection", e); + } + } + + private static void setClusterArtifactStore(ClusterStorageGate gate, String value) { + try { + Field f = ClusterStorageGate.class.getDeclaredField("clusterArtifactStore"); + f.setAccessible(true); + f.set(gate, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to set clusterArtifactStore via reflection", e); + } + } +} 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 new file mode 100644 index 0000000000..ddd67db020 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java @@ -0,0 +1,116 @@ +package stirling.software.proprietary.storage.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; +import stirling.software.proprietary.storage.provider.LocalStorageProvider; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.repository.StoredFileBlobRepository; + +/** + * Verifies the Pro/Enterprise license gate on the S3 storage backend without touching real S3 + * clients (and without needing Docker). Provider-specific construction is delegated to the existing + * provider tests. + */ +class StorageProviderConfigTest { + + @Test + void provider_local_normalLicense_buildsLocalProviderWithoutLicenseCheck() { + StorageProviderConfig cfg = newConfig("local", License.NORMAL); + + StorageProvider provider = cfg.storageProvider(); + assertThat(provider).isInstanceOf(LocalStorageProvider.class); + } + + @Test + void provider_s3_normalLicense_throwsBeforeBuildingClient() { + StorageProviderConfig cfg = newConfig("s3", License.NORMAL); + + // License check must throw BEFORE S3Clients.build tries to validate endpoint / bucket. + // Otherwise an empty config would surface as a confusing "bucket must be set" error. + assertThatThrownBy(cfg::storageProvider) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("storage.provider=s3 requires a Pro or Enterprise license"); + } + + @Test + void provider_database_normalLicense_throws() { + StorageProviderConfig cfg = newConfig("database", License.NORMAL); + + assertThatThrownBy(cfg::storageProvider) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "storage.provider=database requires a Pro or Enterprise license"); + } + + @Test + void provider_database_serverLicense_buildsDatabaseProvider() { + StorageProviderConfig cfg = newConfig("database", License.SERVER); + assertThatCode(cfg::storageProvider).doesNotThrowAnyException(); + } + + @Test + void provider_s3_serverLicense_passesLicenseCheck_thenFailsOnEmptyConfig() { + StorageProviderConfig cfg = newConfig("s3", License.SERVER); + + // Valid license, but no bucket/endpoint configured - so we expect a CONFIG error, + // not a license error. The error message must not mention the license. + assertThatThrownBy(cfg::storageProvider) + .isInstanceOf(IllegalStateException.class) + .hasMessageNotContaining("Pro or Enterprise license"); + } + + @Test + void provider_s3_enterpriseLicense_passesLicenseCheck_thenFailsOnEmptyConfig() { + StorageProviderConfig cfg = newConfig("s3", License.ENTERPRISE); + + assertThatThrownBy(cfg::storageProvider) + .isInstanceOf(IllegalStateException.class) + .hasMessageNotContaining("Pro or Enterprise license"); + } + + @Test + void provider_unknown_normalLicense_throwsUnsupportedProvider_notLicense() { + StorageProviderConfig cfg = newConfig("magic", License.NORMAL); + + assertThatThrownBy(cfg::storageProvider) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Storage provider not supported: magic") + .hasMessageNotContaining("license"); + } + + private static StorageProviderConfig newConfig(String provider, License license) { + ApplicationProperties props = new ApplicationProperties(); + props.getStorage().setProvider(provider); + props.getStorage() + .setEnabled(false); // local-fallback path skips dir creation when disabled + StoredFileBlobRepository repo = mock(StoredFileBlobRepository.class); + LicenseKeyChecker checker = mock(LicenseKeyChecker.class); + when(checker.getPremiumLicenseEnabledResult()).thenReturn(license); + if (license == License.SERVER || license == License.ENTERPRISE) { + doNothing().when(checker).requireProOrEnterprise(anyString()); + } else { + // Mirror real LicenseKeyChecker.requireProOrEnterprise so message assertions match. + doAnswer( + inv -> { + throw new IllegalStateException( + inv.getArgument(0) + + " requires a Pro or Enterprise license"); + }) + .when(checker) + .requireProOrEnterprise(anyString()); + } + return new StorageProviderConfig(props, repo, checker); + } +} 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 new file mode 100644 index 0000000000..79033abc2d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerTest.java @@ -0,0 +1,130 @@ +package stirling.software.proprietary.storage.controller; + +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.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.net.URI; +import java.time.Duration; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +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.security.model.User; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.service.FileStorageService; + +@ExtendWith(MockitoExtension.class) +class FileStorageControllerTest { + + private static final String SIGNED_URL = + "https://test-bucket.s3.example.com/signed-blob?X-Amz-Signature=abc"; + + @Mock private FileStorageService fileStorageService; + @Mock private StorageProvider storageProvider; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + FileStorageController controller = + new FileStorageController(fileStorageService, storageProvider); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void downloadFile_whenProviderReturnsSignedUrl_returns302RedirectWithoutSessionCredentials() + throws Exception { + StoredFile file = newStoredFile(); + + when(fileStorageService.requireAuthenticatedUser()).thenReturn(file.getOwner()); + when(fileStorageService.getAccessibleFile(file.getOwner(), 77L)).thenReturn(file); + when(storageProvider.signedDownloadUrl( + eq("11/abc-doc.pdf"), any(Duration.class), anyBoolean(), anyString())) + .thenReturn(Optional.of(URI.create(SIGNED_URL))); + + MvcResult result = + mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L)) + .andExpect(status().is(HttpStatus.FOUND.value())) + .andExpect(header().string(HttpHeaders.LOCATION, SIGNED_URL)) + .andExpect(redirectedUrl(SIGNED_URL)) + .andReturn(); + + // Regression fence: signed URLs delegate auth to the URL itself, so the redirect + // response must NOT carry any session credentials forward. + assertThat(result.getResponse().getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + assertThat(result.getResponse().getHeader(HttpHeaders.COOKIE)).isNull(); + assertThat(result.getResponse().getHeader(HttpHeaders.SET_COOKIE)).isNull(); + } + + @Test + void downloadFile_inlineFalse_forwardsAttachmentDispositionToSignedUrl() throws Exception { + StoredFile file = newStoredFile(); + + when(fileStorageService.requireAuthenticatedUser()).thenReturn(file.getOwner()); + when(fileStorageService.getAccessibleFile(file.getOwner(), 77L)).thenReturn(file); + when(storageProvider.signedDownloadUrl( + eq("11/abc-doc.pdf"), any(Duration.class), eq(false), eq("doc.pdf"))) + .thenReturn(Optional.of(URI.create(SIGNED_URL))); + + mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L)) + .andExpect(status().is(HttpStatus.FOUND.value())) + .andExpect(header().string(HttpHeaders.LOCATION, SIGNED_URL)); + + verify(storageProvider) + .signedDownloadUrl( + eq("11/abc-doc.pdf"), any(Duration.class), eq(false), eq("doc.pdf")); + } + + @Test + void downloadFile_inlineTrue_forwardsInlineDispositionToSignedUrl() throws Exception { + StoredFile file = newStoredFile(); + + when(fileStorageService.requireAuthenticatedUser()).thenReturn(file.getOwner()); + when(fileStorageService.getAccessibleFile(file.getOwner(), 77L)).thenReturn(file); + when(storageProvider.signedDownloadUrl( + eq("11/abc-doc.pdf"), any(Duration.class), eq(true), eq("doc.pdf"))) + .thenReturn(Optional.of(URI.create(SIGNED_URL))); + + mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L).param("inline", "true")) + .andExpect(status().is(HttpStatus.FOUND.value())) + .andExpect(header().string(HttpHeaders.LOCATION, SIGNED_URL)); + + verify(storageProvider) + .signedDownloadUrl( + eq("11/abc-doc.pdf"), any(Duration.class), eq(true), eq("doc.pdf")); + } + + private static StoredFile newStoredFile() { + User user = new User(); + user.setId(11L); + user.setUsername("alice"); + + StoredFile file = new StoredFile(); + file.setId(77L); + file.setOwner(user); + file.setOriginalFilename("doc.pdf"); + file.setContentType("application/pdf"); + file.setSizeBytes(123L); + file.setStorageKey("11/abc-doc.pdf"); + return file; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/provider/S3StorageProviderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/provider/S3StorageProviderTest.java new file mode 100644 index 0000000000..e3fde2cf47 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/provider/S3StorageProviderTest.java @@ -0,0 +1,282 @@ +package stirling.software.proprietary.storage.provider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.Resource; +import org.springframework.mock.web.MockMultipartFile; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import stirling.software.proprietary.security.model.User; + +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.presigner.S3Presigner; + +@Testcontainers(disabledWithoutDocker = true) +class S3StorageProviderTest { + + private static final String BUCKET = "stirling-test-bucket"; + 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 s3Client; + private static S3Presigner s3Presigner; + private static S3StorageProvider provider; + + @BeforeAll + static void setUp() { + URI endpoint = URI.create(minio.getS3URL()); + AwsBasicCredentials creds = AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY); + S3Configuration s3Config = S3Configuration.builder().pathStyleAccessEnabled(true).build(); + + s3Client = + S3Client.builder() + .endpointOverride(endpoint) + .httpClient(UrlConnectionHttpClient.create()) + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create(creds)) + .serviceConfiguration(s3Config) + .build(); + + s3Presigner = + S3Presigner.builder() + .endpointOverride(endpoint) + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create(creds)) + .serviceConfiguration(s3Config) + .build(); + + s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build()); + provider = new S3StorageProvider(s3Client, s3Presigner, BUCKET); + } + + @AfterAll + static void tearDown() { + if (provider != null) { + provider.close(); + } + } + + @Test + void blankBucket_constructorRejects() { + assertThatThrownBy(() -> new S3StorageProvider(s3Client, s3Presigner, "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new S3StorageProvider(s3Client, s3Presigner, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void store_thenLoad_roundTripsContent() throws Exception { + User owner = new User(); + owner.setId(42L); + byte[] content = "hello s3 round trip".getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = + new MockMultipartFile("file", "sample.pdf", "application/pdf", content); + + StoredObject stored = provider.store(owner, file); + + // Key is intentionally opaque ({ownerId}/{uuid}) - the filename is preserved on + // StoredObject.originalFilename for display, never in the S3 key, so vendors that + // restrict key charset (e.g. Supabase: ASCII only) accept any filename. + assertThat(stored.getStorageKey()) + .matches( + "42/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + assertThat(stored.getStorageKey()).doesNotContain("sample.pdf"); + assertThat(stored.getOriginalFilename()).isEqualTo("sample.pdf"); + assertThat(stored.getContentType()).isEqualTo("application/pdf"); + assertThat(stored.getSizeBytes()).isEqualTo(content.length); + + Resource loaded = provider.load(stored.getStorageKey()); + try (InputStream in = loaded.getInputStream()) { + assertThat(in.readAllBytes()).isEqualTo(content); + } + } + + @Test + void load_unknownKey_throwsIOException() { + assertThatThrownBy(() -> provider.load("does/not/exist.txt")) + .isInstanceOf(IOException.class); + } + + @Test + void store_unicodeFilename_yieldsAsciiOnlyKey_andPreservesOriginalName() throws Exception { + // Regression: Supabase Storage rejects S3 keys containing non-ASCII chars (400 + // Invalid key). Locking in that the storage key never embeds the filename so any + // unicode display name still uploads successfully. + User owner = new User(); + owner.setId(99L); + String unicodeName = "résumé-日本語-é.pdf"; + byte[] payload = "u".getBytes(StandardCharsets.UTF_8); + MockMultipartFile file = + new MockMultipartFile("file", unicodeName, "application/pdf", payload); + + StoredObject stored = provider.store(owner, file); + + assertThat(stored.getStorageKey()) + .matches( + "99/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + assertThat(stored.getOriginalFilename()).isEqualTo(unicodeName); + try (InputStream in = provider.load(stored.getStorageKey()).getInputStream()) { + assertThat(in.readAllBytes()).isEqualTo(payload); + } + } + + @Test + void delete_removesObject() throws Exception { + User owner = new User(); + owner.setId(7L); + MockMultipartFile file = + new MockMultipartFile( + "file", "todelete.bin", "application/octet-stream", new byte[] {1, 2, 3}); + + StoredObject stored = provider.store(owner, file); + provider.delete(stored.getStorageKey()); + + assertThatThrownBy(() -> provider.load(stored.getStorageKey())) + .isInstanceOf(IOException.class); + } + + @Test + void delete_unknownKey_isNoOp() { + assertThat(catchIOException(() -> provider.delete("never-existed"))).isNull(); + } + + @Test + void signedDownloadUrl_returnsWorkingPresignedGet() throws Exception { + User owner = new User(); + owner.setId(99L); + byte[] content = "presigned payload".getBytes(StandardCharsets.UTF_8); + StoredObject stored = + provider.store( + owner, new MockMultipartFile("file", "presign.txt", "text/plain", content)); + + Optional signed = + provider.signedDownloadUrl(stored.getStorageKey(), Duration.ofMinutes(2)); + + assertThat(signed).isPresent(); + URI uri = signed.get(); + assertThat(uri.getScheme()).isIn("http", "https"); + assertThat(uri.getRawQuery()).contains("X-Amz-Signature"); + + HttpURLConnection conn = (HttpURLConnection) new URL(uri.toString()).openConnection(); + try { + assertThat(conn.getResponseCode()).isEqualTo(200); + try (InputStream in = conn.getInputStream()) { + assertThat(in.readAllBytes()).isEqualTo(content); + } + } finally { + conn.disconnect(); + } + } + + @Test + void signedDownloadUrl_nullKey_returnsEmpty() throws Exception { + assertThat(provider.signedDownloadUrl(null, Duration.ofMinutes(1))).isEmpty(); + assertThat(provider.signedDownloadUrl(" ", Duration.ofMinutes(1))).isEmpty(); + } + + @Test + void signedDownloadUrl_nullOrZeroTtl_appliesDefault() throws Exception { + User owner = new User(); + owner.setId(3L); + StoredObject stored = + provider.store( + owner, + new MockMultipartFile( + "file", + "ttl.txt", + "text/plain", + "x".getBytes(StandardCharsets.UTF_8))); + + assertThat(provider.signedDownloadUrl(stored.getStorageKey(), null)).isPresent(); + assertThat(provider.signedDownloadUrl(stored.getStorageKey(), Duration.ZERO)).isPresent(); + assertThat(provider.signedDownloadUrl(stored.getStorageKey(), Duration.ofSeconds(-5))) + .isPresent(); + } + + @Test + void signedDownloadUrl_inlineFlagEncodesResponseContentDispositionInQuery() throws Exception { + User owner = new User(); + owner.setId(55L); + StoredObject stored = + provider.store( + owner, + new MockMultipartFile( + "file", + "stored-name.pdf", + "application/pdf", + "payload".getBytes(StandardCharsets.UTF_8))); + + URI attached = + provider.signedDownloadUrl( + stored.getStorageKey(), Duration.ofMinutes(2), false, "report.pdf") + .orElseThrow(); + String attachedQuery = + java.net.URLDecoder.decode(attached.getRawQuery(), StandardCharsets.UTF_8); + assertThat(attachedQuery) + .contains("response-content-disposition=attachment; filename=\"report.pdf\""); + + URI inline = + provider.signedDownloadUrl( + stored.getStorageKey(), Duration.ofMinutes(2), true, "report.pdf") + .orElseThrow(); + String inlineQuery = + java.net.URLDecoder.decode(inline.getRawQuery(), StandardCharsets.UTF_8); + assertThat(inlineQuery) + .contains("response-content-disposition=inline; filename=\"report.pdf\""); + + URI bare = + provider.signedDownloadUrl( + stored.getStorageKey(), Duration.ofMinutes(2), false, null) + .orElseThrow(); + assertThat(bare.getRawQuery()).doesNotContain("response-content-disposition"); + } + + @Test + void buildContentDisposition_escapesQuotesAndStripsControlChars() { + assertThat(S3StorageProvider.buildContentDisposition(true, "ev\"il\r\nname.pdf")) + .isEqualTo("inline; filename=\"ev\\\"ilname.pdf\""); + assertThat(S3StorageProvider.buildContentDisposition(false, null)).isNull(); + assertThat(S3StorageProvider.buildContentDisposition(false, " ")).isNull(); + } + + private static IOException catchIOException(IOAction action) { + try { + action.run(); + return null; + } catch (IOException e) { + return e; + } + } + + @FunctionalInterface + private interface IOAction { + void run() throws IOException; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java new file mode 100644 index 0000000000..13bcfbfdac --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java @@ -0,0 +1,298 @@ +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.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.repository.FolderRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +/** + * Unit tests for {@link FolderService}. Covers the regressions Connor flagged in PR #6383: + * + *
    + *
  • storage-enabled gate must be enforced (added in the same PR) + *
  • cross-user folder access must 404, not leak existence + *
  • cycle detection on reparent must 400 + *
  • depth cap must reject chains past MAX_FOLDER_DEPTH + *
  • per-user folder count cap must 409 + *
+ * + * Hibernate is mocked: this is a pure-Mockito unit test, not a slice test. Adequate for the + * service-layer behaviors above; full DB integration belongs in a separate {@code @DataJpaTest}. + */ +@ExtendWith(MockitoExtension.class) +class FolderServiceTest { + + @Mock private FolderRepository folderRepository; + @Mock private StoredFileRepository storedFileRepository; + @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.Security security; + @Mock private ApplicationProperties.Storage storage; + + private FolderService service; + private User user; + + @BeforeEach + void setUp() { + // Default to "storage enabled" so the unrelated tests don't have to repeat the wiring. + // Individual tests override with disabled state. + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(applicationProperties.getStorage()).thenReturn(storage); + lenient().when(security.isEnableLogin()).thenReturn(true); + lenient().when(storage.isEnabled()).thenReturn(true); + + service = new FolderService(folderRepository, storedFileRepository, applicationProperties); + + user = new User(); + user.setId(42L); + user.setUsername("alice"); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, java.util.List.of())); + SecurityContextHolder.setContext(ctx); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void listFolders_rejects_when_login_disabled() { + when(security.isEnableLogin()).thenReturn(false); + assertThatThrownBy(() -> service.listFolders()) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403)); + } + + @Test + void listFolders_rejects_when_storage_disabled() { + when(storage.isEnabled()).thenReturn(false); + assertThatThrownBy(() -> service.listFolders()) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403)); + } + + @Test + void createFolder_under_unknown_parent_returns_400_without_leaking_existence() { + // Parent UUID exists for ANOTHER user; current-user lookup misses it. The repository + // returns Optional.empty() and the service must surface a generic 400, not a 404 that + // could be used to probe for existence by id-guessing. + UUID foreignParentId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(foreignParentId), eq(user))) + .thenReturn(Optional.empty()); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Child"); + req.setParentFolderId(foreignParentId); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).doesNotContain(foreignParentId.toString()); + }); + } + + @Test + void createFolder_409_when_user_at_folder_cap() { + // Stub out an existing-id miss so we reach the cap check (no Mockito unnecessary-stub + // warnings from the OTHER paths because we exit at the cap before the existsById call). + UUID newId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(newId), eq(user))).thenReturn(Optional.empty()); + when(folderRepository.existsById(eq(newId))).thenReturn(false); + when(folderRepository.countByOwner(eq(user))).thenReturn(5_000L); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Overflow"); + req.setId(newId); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(409)); + } + + @Test + void resolveParent_rejects_when_chain_exceeds_depth_cap() { + // Build a chain Hibernate-proxy-style: 64 ancestor stubs reachable via getParent(). The + // 65th createFolder attempt under the deepest existing folder should be rejected with + // 400 before any further work. + Folder root = makeFolder(UUID.randomUUID(), null); + Folder cursor = root; + for (int i = 0; i < 63; i++) { + Folder child = makeFolder(UUID.randomUUID(), cursor); + cursor = child; + } + // cursor is at depth 64 from root. Attempting to add another folder under cursor pushes + // the new child to depth 65 - past the cap. resolveParent walks cursor->root counting + // ancestors, which is exactly 64, and rejects. + Folder deepest = cursor; + when(folderRepository.findByIdAndOwner(eq(deepest.getId()), eq(user))) + .thenReturn(Optional.of(deepest)); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Too deep"); + req.setParentFolderId(deepest.getId()); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).containsIgnoringCase("nesting limit"); + }); + } + + @Test + void updateFolder_rejects_cycle_on_reparent() { + // A -> B -> C. Attempt to reparent A under C (i.e. set A.parent = C). C's chain to root + // includes B which includes A, so the cycle check must fire with 400. + Folder a = makeFolder(UUID.randomUUID(), null); + Folder b = makeFolder(UUID.randomUUID(), a); + Folder c = makeFolder(UUID.randomUUID(), b); + + when(folderRepository.findByIdAndOwner(eq(a.getId()), eq(user))).thenReturn(Optional.of(a)); + when(folderRepository.findByIdAndOwner(eq(c.getId()), eq(user))).thenReturn(Optional.of(c)); + + stirling.software.proprietary.storage.model.api.UpdateFolderRequest req = + new stirling.software.proprietary.storage.model.api.UpdateFolderRequest(); + req.setParentFolderId(c.getId()); + // shouldReparent() requires the explicit reparent flag - without it the + // parent change is silently skipped (PATCH-style semantics). + req.setReparent(true); + + assertThatThrownBy(() -> service.updateFolder(a.getId(), req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).containsIgnoringCase("descendants"); + }); + } + + @Test + void updateFolder_rejects_when_folder_not_owned() { + // Owner mismatch surfaces as 404, NOT 403 - 403 would confirm the folder exists, leaking + // ids to probing users. Stays consistent with the createFolder-under-unknown-parent test + // above. + UUID foreignId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(foreignId), eq(user))) + .thenReturn(Optional.empty()); + + stirling.software.proprietary.storage.model.api.UpdateFolderRequest req = + new stirling.software.proprietary.storage.model.api.UpdateFolderRequest(); + req.setName("Renamed"); + + assertThatThrownBy(() -> service.updateFolder(foreignId, req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404)); + } + + @Test + void moveFileToFolder_rejects_when_target_folder_not_owned() { + // File belongs to current user but target folder belongs to someone else. Service must + // 400, not move the file. + UUID foreignFolderId = UUID.randomUUID(); + stirling.software.proprietary.storage.model.StoredFile file = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(storedFileRepository.findByIdAndOwner(eq(100L), eq(user))) + .thenReturn(Optional.of(file)); + when(folderRepository.findByIdAndOwner(eq(foreignFolderId), eq(user))) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.moveFileToFolder(100L, foreignFolderId)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400)); + } + + @Test + void bulkMove_rejects_oversized_payload() { + // Bypass the @Valid bound by calling the service directly - the cap must hold here too, + // not just at the controller's request validator. + java.util.List tooMany = new java.util.ArrayList<>(); + for (int i = 0; i < 1001; i++) tooMany.add((long) i); + + assertThatThrownBy(() -> service.bulkMoveFilesToFolder(null, tooMany)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400)); + } + + @Test + void bulkMove_returns_moved_and_skipped_split() { + // Ownership filter on the repository returns a subset; the rest land in skippedFileIds. + Folder target = makeFolder(UUID.randomUUID(), null); + when(folderRepository.findByIdAndOwner(eq(target.getId()), eq(user))) + .thenReturn(Optional.of(target)); + + stirling.software.proprietary.storage.model.StoredFile fileA = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(fileA.getId()).thenReturn(1L); + stirling.software.proprietary.storage.model.StoredFile fileB = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(fileB.getId()).thenReturn(2L); + when(storedFileRepository.findAllByIdInAndOwner(any(), eq(user))) + .thenReturn(java.util.List.of(fileA, fileB)); + + FolderService.BulkMoveResult result = + service.bulkMoveFilesToFolder(target.getId(), java.util.List.of(1L, 2L, 3L, 4L)); + + assertThat(result.movedFileIds()).containsExactly(1L, 2L); + assertThat(result.skippedFileIds()).containsExactly(3L, 4L); + } + + // ─── helpers ──────────────────────────────────────────────────────────────── + + private Folder makeFolder(UUID id, Folder parent) { + Folder f = new Folder(); + f.setId(id); + f.setOwner(user); + f.setName("f-" + id.toString().substring(0, 8)); + f.setParent(parent); + return f; + } +} diff --git a/app/proprietary/src/test/resources/db-migration-fixtures/README.md b/app/proprietary/src/test/resources/db-migration-fixtures/README.md new file mode 100644 index 0000000000..25450cf7f7 --- /dev/null +++ b/app/proprietary/src/test/resources/db-migration-fixtures/README.md @@ -0,0 +1,110 @@ +# DB migration test fixtures + +These `.mv.db` files are H2 databases captured from past Stirling-PDF releases. +They feed the CI smoke test that verifies a fresh build can still boot and +authenticate against a database created by an older version. + +| File | Source release | Tables | Notes | +|---|---|---|---| +| `stirling-pdf-v2.0.0.mv.db` | [v2.0.0](https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v2.0.0) | users, authorities, teams, sessions, audit_events, persistent_logins, invite_tokens, user_license_settings, user_settings | Pre-storage/workflow schema. | +| `stirling-pdf-v2.5.0.mv.db` | [v2.5.0](https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v2.5.0) | same as v2.0.0 | Schema unchanged from v2.0.0; intentionally kept as a separate fixture to exercise the "skip every other minor" upgrade path. | +| `stirling-pdf-v2.10.0.mv.db` | [v2.10.0](https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v2.10.0) | v2.5.0 tables + file_shares, file_share_accesses, stored_files, stored_file_blobs, storage_cleanup_entries, user_server_certificates, workflow_sessions, workflow_participants, participant_notifications | Adds the file-sharing and workflow signing schema. | + +All three were generated against H2 `2.3.232` and use the same on-disk file +format, so the runtime driver can open any of them without conversion. + +## What's in each fixture + +* `admin` user with the default password `stirling` (BCrypt `$2a$10$...`). +* The internal API user `STIRLING-PDF-BACKEND-API-USER`. +* `ROLE_ADMIN` authority row for the admin user. +* `Default` and `Internal` teams. +* `user_license_settings` row (singleton). + +`audit_events`, `sessions`, and `user_settings` are empty in the OSS-flavored +fixtures: those tables are written only on Enterprise builds (audit) or +require an HTTP-session-creating flow (sessions / settings) that the OSS form +login no longer exposes. The migration test only depends on the admin user +existing, so leaving these empty is intentional. + +## What the CI test checks + +`.github/workflows/db-migration-test.yml` runs `scripts/db-migration/run-migration-test.sh`, +which for each fixture: + +1. Copies the fixture into `configs/stirling-pdf-DB-2.3.232.mv.db` of a clean + working directory. +2. Boots the current `:stirling-pdf:bootJar` against it on a free port. +3. Waits for Spring to start (no `SchemaManagementException` in the log). +4. POSTs `{"username":"admin","password":"stirling"}` to `/api/v1/auth/login` + and asserts the response is `200 OK`. + +A red CI on this job means a schema change in the PR is not backwards +compatible with an existing user database. Common causes: + +* Adding a non-nullable column without a default. +* Renaming a column (Hibernate's `update` strategy adds the new column and + leaves the old one orphaned with the data still in it). +* Changing a column type in an incompatible way. +* Dropping or renaming a foreign-key target. + +## Regenerating fixtures + +There's no automated regenerator script - fixtures are rare to refresh and the +manual steps are short. For each version you want to capture: + +```bash +# 1. Download the JAR for that release (requires `gh` authenticated against +# github.com/Stirling-Tools/Stirling-PDF). +gh release download v2.10.0 \ + --repo Stirling-Tools/Stirling-PDF \ + --pattern 'Stirling-PDF-with-login.jar' \ + --output /tmp/stirling-v2.10.0.jar + +# 2. Boot the JAR in a clean working directory. DB_CLOSE_ON_EXIT=TRUE is +# the only override that matters - it makes the H2 file flush on JVM exit +# even if you Ctrl-C instead of going through a graceful shutdown. +workdir=$(mktemp -d) +mkdir -p "$workdir/configs" +cd "$workdir" +java -jar /tmp/stirling-v2.10.0.jar \ + --server.port=8089 \ + --spring.datasource.url='jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL' \ + & + +# 3. Wait until http://localhost:8089/login responds, then log in once to +# materialize whatever rows the app writes on first boot. +curl -sf -X POST -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"stirling"}' \ + http://localhost:8089/api/v1/auth/login + +# 4. Shut it down (any kill works - DB_CLOSE_ON_EXIT=TRUE handles the flush). +kill -TERM %1 && wait %1 + +# 5. Copy the .mv.db here, renamed for the version. +cp "$workdir/configs/stirling-pdf-DB-2.3.232.mv.db" \ + app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.10.0.mv.db +``` + +Requirements: Java 21+ (the historical JARs target Java 17 / 21). + +## Adding a new fixture + +When a new minor release ships, repeat the steps above for the new tag and +add a row to the table at the top of this file. Keep the historical fixtures - +the test gets stronger with each schema generation it covers. + +## Inspecting a fixture by hand + +The H2 driver bundled with the build ships an interactive shell: + +```bash +h2_jar=$(find ~/.gradle/caches/modules-2 -name 'h2-2.3.232.jar' | head -1) +cd app/proprietary/src/test/resources/db-migration-fixtures +java -cp "$h2_jar" org.h2.tools.Shell \ + -url 'jdbc:h2:file:./stirling-pdf-v2.10.0;ACCESS_MODE_DATA=r;MODE=PostgreSQL' \ + -user sa +``` + +`ACCESS_MODE_DATA=r` keeps the inspection read-only so you can't accidentally +mutate a committed fixture. diff --git a/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.0.0.mv.db b/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.0.0.mv.db new file mode 100644 index 0000000000..5402eb0375 Binary files /dev/null and b/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.0.0.mv.db differ diff --git a/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.10.0.mv.db b/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.10.0.mv.db new file mode 100644 index 0000000000..56c0040556 Binary files /dev/null and b/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.10.0.mv.db differ diff --git a/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.5.0.mv.db b/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.5.0.mv.db new file mode 100644 index 0000000000..1032bf42d9 Binary files /dev/null and b/app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.5.0.mv.db differ diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java index 1589b44075..3c6b0d14df 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java @@ -5,18 +5,24 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -/** Registers the {@code :saas} module's entities and repositories with Spring Data JPA. */ +/** + * Registers the {@code :saas} module's entities and repositories with Spring Data JPA. Any new + * package holding {@code @Repository} or {@code @Entity} classes must be added here, or the beans + * won't wire at startup. + */ @Configuration @Profile("saas") @EnableJpaRepositories( basePackages = { "stirling.software.saas.repository", "stirling.software.saas.billing.repository", - "stirling.software.saas.ai.repository" + "stirling.software.saas.ai.repository", + "stirling.software.saas.payg.repository" }) @EntityScan({ "stirling.software.saas.model", "stirling.software.saas.billing.model", - "stirling.software.saas.ai.model" + "stirling.software.saas.ai.model", + "stirling.software.saas.payg" }) public class SaasJpaConfig {} diff --git a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java b/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java index 6f3bb9b25c..4221643432 100644 --- a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java +++ b/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java @@ -73,6 +73,13 @@ public class TeamMembership implements Serializable { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + /** + * Optional per-member spend cap inside the team's wallet, in doc units. NULL means the member + * is bounded only by the team-wide cap. + */ + @Column(name = "cap_units") + private Long capUnits; + public boolean isLeader() { return role == TeamRole.LEADER; } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java new file mode 100644 index 0000000000..a46d975878 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java @@ -0,0 +1,173 @@ +package stirling.software.saas.payg.docs; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.saas.payg.policy.PricingPolicy; + +/** + * Reads pages via jpdfium for PDF inputs; treats every other content type as bytes-only. + * + *

For PDFs, units are the larger of {@code ceil(pages / docPagesPerUnit)} and {@code ceil(bytes + * / docBytesPerUnit)}. For non-PDFs, only the bytes axis contributes. A single file is clamped to + * {@code [1, policy.fileUnitCap]}; a multi-file group is clamped to {@code [1, policy.fileUnitCap * + * file_count]} applied to the sum of raw per-file units. Malformed/encrypted PDFs fall back to + * bytes-only. + * + *

{@code policy.minChargeUnits} is applied by the charge service, not here. The classifier only + * enforces an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on + * "non-empty input → at least 1 unit". + */ +@Slf4j +@Component +@Profile("saas") +@RequiredArgsConstructor +public class DefaultDocumentClassifier implements DocumentClassifier { + + private static final String PDF_CONTENT_TYPE = "application/pdf"; + private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; + + /** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */ + private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; + + private final TempFileManager tempFileManager; + + @Override + public DocumentMetrics classify(MultipartFile file, PricingPolicy policy) { + Objects.requireNonNull(file, "file"); + Objects.requireNonNull(policy, "policy"); + + FileFacts facts = inspect(file); + long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy); + // toIntExact: fail loud on overflow rather than silently wrapping a billing number. + int units = + Math.toIntExact( + Math.max( + MIN_UNITS_PER_NONEMPTY_FILE, + Math.min(policy.getFileUnitCap(), rawUnits))); + return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units); + } + + @Override + public DocumentMetrics classify(List files, PricingPolicy policy) { + Objects.requireNonNull(files, "files"); + Objects.requireNonNull(policy, "policy"); + if (files.isEmpty()) { + throw new IllegalArgumentException("files must not be empty"); + } + + int totalPages = 0; + long totalBytes = 0; + long rawUnitsSum = 0; + String firstContentType = null; + + for (MultipartFile file : files) { + FileFacts facts = inspect(file); + // Sum the *raw* (unclamped) per-file units so the group cap below can actually bind. + // Per-file clamping in this loop would make the group cap a no-op. + rawUnitsSum = + saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy)); + totalPages = saturatedAdd(totalPages, facts.pages); + totalBytes = saturatedAdd(totalBytes, facts.bytes); + if (firstContentType == null) { + firstContentType = facts.contentType; + } + } + + long groupCap = (long) policy.getFileUnitCap() * files.size(); + // toIntExact: fail loud on overflow rather than silently wrapping. + int totalUnits = + Math.toIntExact( + Math.max( + (long) MIN_UNITS_PER_NONEMPTY_FILE, + Math.min(groupCap, rawUnitsSum))); + + return new DocumentMetrics( + totalPages, + totalBytes, + firstContentType != null ? firstContentType : DEFAULT_CONTENT_TYPE, + totalUnits); + } + + private FileFacts inspect(MultipartFile file) { + long bytes = file.getSize(); + String contentType = + file.getContentType() != null ? file.getContentType() : DEFAULT_CONTENT_TYPE; + int pages = isPdf(contentType, file.getOriginalFilename()) ? readPageCount(file) : 0; + return new FileFacts(pages, bytes, contentType); + } + + private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) { + long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L; + long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit()); + return Math.max(pageUnits, byteUnits); + } + + private static long ceilDiv(long numerator, long divisor) { + if (numerator <= 0) { + return 0; + } + return (numerator + divisor - 1) / divisor; + } + + private static boolean isPdf(String contentType, String filename) { + if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) { + return true; + } + return filename != null && filename.toLowerCase().endsWith(".pdf"); + } + + /** + * Materialises the upload to a managed temp file and asks jpdfium for the page count. Returns 0 + * if the file can't be parsed — the byte-derived axis still produces a charge. + */ + private int readPageCount(MultipartFile file) { + try (TempFile temp = tempFileManager.createManagedTempFile(".pdf")) { + try (InputStream in = file.getInputStream(); + OutputStream out = Files.newOutputStream(temp.getPath())) { + in.transferTo(out); + } + try (PdfDocument doc = PdfDocument.open(temp.getPath())) { + return doc.pageCount(); + } + } catch (IOException | RuntimeException e) { + log.debug( + "Could not read PDF page count for {} ({}); falling back to bytes-only units", + file.getOriginalFilename(), + e.getClass().getSimpleName()); + return 0; + } + } + + private static int saturatedAdd(int a, int b) { + long sum = (long) a + b; + if (sum > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + return (int) sum; + } + + private static long saturatedAdd(long a, long b) { + try { + return Math.addExact(a, b); + } catch (ArithmeticException e) { + return Long.MAX_VALUE; + } + } + + private record FileFacts(int pages, long bytes, String contentType) {} +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentClassifier.java new file mode 100644 index 0000000000..c7af9deaf4 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentClassifier.java @@ -0,0 +1,25 @@ +package stirling.software.saas.payg.docs; + +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.saas.payg.policy.PricingPolicy; + +/** + * Computes the doc-unit cost of an uploaded file (or multi-file input) under a given policy. + * + *

Returns {@code docUnits} with an absolute floor of 1 for non-empty input. {@code + * policy.minChargeUnits} is applied at charge time, not here. + */ +public interface DocumentClassifier { + + /** Classify a single uploaded file. Returns at least 1 unit, capped at {@code fileUnitCap}. */ + DocumentMetrics classify(MultipartFile file, PricingPolicy policy); + + /** + * Classify a multi-file input (e.g. a merge or overlay). Returns the sum of each file's raw + * units, capped at {@code fileUnitCap × files.size()} and floored at 1. + */ + DocumentMetrics classify(List files, PricingPolicy policy); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentMetrics.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentMetrics.java new file mode 100644 index 0000000000..ea0d01083a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentMetrics.java @@ -0,0 +1,12 @@ +package stirling.software.saas.payg.docs; + +/** + * Output of {@link DocumentClassifier#classify}. {@code pages} is {@code 0} for non-PDF inputs. + * + * @param pages page count (0 for non-PDFs and for files whose page count couldn't be read) + * @param bytes raw byte length of the file + * @param contentType MIME type as reported by the upload, or {@code "application/octet-stream"} + * when unknown + * @param docUnits computed unit cost, clamped to the policy's {@code fileUnitCap} + */ +public record DocumentMetrics(int pages, long bytes, String contentType, int docUnits) {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshot.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshot.java new file mode 100644 index 0000000000..872aea3901 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshot.java @@ -0,0 +1,112 @@ +package stirling.software.saas.payg.entitlement; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** + * Cached entitlement state for the team (one row with {@code user_id = 0}, the team-wide sentinel) + * plus optional per-member rows when a member sub-cap is configured. Read on the hot path by the + * entitlement guard. + * + *

Composite PK {@code (team_id, user_id)} uses 0 as the team-wide sentinel because Postgres + * treats {@code NULL} as not-equal-to-NULL in unique constraints — 0 keeps the PK well-defined. + * + *

No {@code @Version} — rows are produced by full-row recompute, no read-modify-write race. + */ +@Entity +@Table(name = "wallet_entitlement_snapshot") +@NoArgsConstructor +@Getter +@Setter +public class WalletEntitlementSnapshot implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final long TEAM_WIDE_USER_ID = 0L; + + @EmbeddedId private WalletEntitlementSnapshotId id; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + @Column(name = "period_end", nullable = false) + private LocalDateTime periodEnd; + + @Column(name = "period_spend_units", nullable = false) + private Long periodSpendUnits = 0L; + + @Column(name = "period_cap_units") + private Long periodCapUnits; + + @Enumerated(EnumType.STRING) + @Column(name = "state", nullable = false, length = 16) + private EntitlementState state = EntitlementState.FULL; + + @Enumerated(EnumType.STRING) + @Column(name = "feature_set", nullable = false, length = 32) + private FeatureSet featureSet = FeatureSet.FULL; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "enabled_gates", columnDefinition = "jsonb", nullable = false) + private List enabledGates = new ArrayList<>(); + + @CreationTimestamp + @Column(name = "computed_at", nullable = false, updatable = false) + private LocalDateTime computedAt; + + @Embeddable + @NoArgsConstructor + @Getter + @Setter + public static class WalletEntitlementSnapshotId implements Serializable { + + private static final long serialVersionUID = 1L; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** Use {@link #TEAM_WIDE_USER_ID} for the team-wide row. */ + @Column(name = "user_id", nullable = false) + private Long userId; + + public WalletEntitlementSnapshotId(Long teamId, Long userId) { + this.teamId = teamId; + this.userId = userId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof WalletEntitlementSnapshotId other)) return false; + return Objects.equals(teamId, other.teamId) && Objects.equals(userId, other.userId); + } + + @Override + public int hashCode() { + return Objects.hash(teamId, userId); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobArtifactHash.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobArtifactHash.java new file mode 100644 index 0000000000..c1b864a547 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobArtifactHash.java @@ -0,0 +1,82 @@ +package stirling.software.saas.payg.job; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.ArtifactKind; + +/** + * Per-step input/output content hash. Used by the lineage detector to decide whether a tool call + * joins an open process (matching an earlier input or output) or opens a new one. + */ +@Entity +@Table(name = "job_artifact_hash") +@NoArgsConstructor +@Getter +@Setter +public class JobArtifactHash implements Serializable { + + private static final long serialVersionUID = 1L; + + @EmbeddedId private JobArtifactHashId id; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Embeddable + @NoArgsConstructor + @Getter + @Setter + public static class JobArtifactHashId implements Serializable { + + private static final long serialVersionUID = 1L; + + @Column(name = "job_id", nullable = false) + private UUID jobId; + + /** {@code "type:value"} signature key; 128 chars fits SHA-256 plus future schemes. */ + @Column(name = "content_hash", nullable = false, length = 128) + private String contentHash; + + @Enumerated(EnumType.STRING) + @Column(name = "kind", nullable = false, length = 8) + private ArtifactKind kind; + + public JobArtifactHashId(UUID jobId, String contentHash, ArtifactKind kind) { + this.jobId = jobId; + this.contentHash = contentHash; + this.kind = kind; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof JobArtifactHashId other)) return false; + return Objects.equals(jobId, other.jobId) + && Objects.equals(contentHash, other.contentHash) + && kind == other.kind; + } + + @Override + public int hashCode() { + return Objects.hash(jobId, contentHash, kind); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java new file mode 100644 index 0000000000..d80bad16d4 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java @@ -0,0 +1,99 @@ +package stirling.software.saas.payg.job; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.JobStatus; +import stirling.software.saas.payg.model.ProcessType; + +/** + * One process — a workflow that may comprise multiple lineage-linked tool calls but is billed once + * at process open. Closed by an explicit caller, by the frontend, or by the stale-close scheduler. + */ +@Entity +@Table(name = "processing_job") +@NoArgsConstructor +@Getter +@Setter +public class ProcessingJob implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "job_id") + private UUID id; + + @Column(name = "owner_user_id", nullable = false) + private Long ownerUserId; + + @Column(name = "owner_team_id") + private Long ownerTeamId; + + @Enumerated(EnumType.STRING) + @Column(name = "process_type", nullable = false, length = 32) + private ProcessType processType; + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 32) + private JobSource source; + + /** SHA-256 of the union of input file hashes; null if the input set is mixed or unknown. */ + @Column(name = "document_fingerprint", length = 64) + private String documentFingerprint; + + @Column(name = "doc_units", nullable = false) + private Integer docUnits = 0; + + @Column(name = "step_count", nullable = false) + private Integer stepCount = 0; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "last_step_at", nullable = false) + private LocalDateTime lastStepAt; + + @Column(name = "closed_at") + private LocalDateTime closedAt; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + /** Filled at close-time; absent while the job is still OPEN. */ + @Column(name = "charged_units") + private Integer chargedUnits; + + /** Cached money equivalent for receipts; not used by cap evaluation. */ + @Column(name = "charged_cents") + private Integer chargedCents; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private JobStatus status; + + /** Stable idempotency key for the open-process Stripe meter event. */ + @Column(name = "idempotency_key", unique = true, length = 128) + private String idempotencyKey; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata = new HashMap<>(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJobStep.java b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJobStep.java new file mode 100644 index 0000000000..5ae7c1ad31 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJobStep.java @@ -0,0 +1,62 @@ +package stirling.software.saas.payg.job; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.JobStepStatus; + +/** One tool invocation inside a {@link ProcessingJob}. Free after the first; carries audit data. */ +@Entity +@Table(name = "processing_job_step") +@NoArgsConstructor +@Getter +@Setter +public class ProcessingJobStep implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "step_id") + private Long id; + + @Column(name = "job_id", nullable = false) + private UUID jobId; + + /** Endpoint path, e.g. {@code /api/v1/general/split-pages}. */ + @Column(name = "tool_id", nullable = false, length = 128) + private String toolId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private JobStepStatus status; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "input_pages") + private Integer inputPages; + + @Column(name = "input_bytes") + private Long inputBytes; + + @Column(name = "error_code", length = 64) + private String errorCode; +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/ArtifactKind.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ArtifactKind.java new file mode 100644 index 0000000000..e7ba5d35a2 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ArtifactKind.java @@ -0,0 +1,7 @@ +package stirling.software.saas.payg.model; + +/** Whether a recorded content hash belongs to a job step's input or its output. */ +public enum ArtifactKind { + INPUT, + OUTPUT +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/AutoGroupStrategy.java b/app/saas/src/main/java/stirling/software/saas/payg/model/AutoGroupStrategy.java new file mode 100644 index 0000000000..4e5880ec32 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/AutoGroupStrategy.java @@ -0,0 +1,10 @@ +package stirling.software.saas.payg.model; + +/** + * Whether a team's tool calls auto-group into multi-step processes via content-hash lineage. {@code + * OFF} forces every call into its own single-step process. + */ +public enum AutoGroupStrategy { + AUTO, + OFF +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/CapPeriod.java b/app/saas/src/main/java/stirling/software/saas/payg/model/CapPeriod.java new file mode 100644 index 0000000000..90cd9d052e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/CapPeriod.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +public enum CapPeriod { + CALENDAR_MONTH, + CALENDAR_QUARTER, + CALENDAR_YEAR, + BILLING_CYCLE +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/EntitlementState.java b/app/saas/src/main/java/stirling/software/saas/payg/model/EntitlementState.java new file mode 100644 index 0000000000..0786a4e5ef --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/EntitlementState.java @@ -0,0 +1,7 @@ +package stirling.software.saas.payg.model; + +public enum EntitlementState { + FULL, + WARNED, + DEGRADED +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureGate.java b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureGate.java new file mode 100644 index 0000000000..edfecbcb59 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureGate.java @@ -0,0 +1,9 @@ +package stirling.software.saas.payg.model; + +/** Coarse capability flags evaluated by the entitlement guard before letting a request proceed. */ +public enum FeatureGate { + OFFSITE_PROCESSING, + AUTOMATION, + AI_SUPPORT, + CLIENT_SIDE +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureSet.java b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureSet.java new file mode 100644 index 0000000000..ad4ae7e709 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureSet.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +/** Bundles of {@link FeatureGate}s exposed at the team / member level. */ +public enum FeatureSet { + FULL, + MINIMAL, + CLIENT_ONLY +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java new file mode 100644 index 0000000000..8f7d2b3df7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java @@ -0,0 +1,19 @@ +package stirling.software.saas.payg.model; + +/** + * Where a tool invocation originated on the client side. Caller surface only — + * this enum does not encode whether the request was served by SaaS or by a self-hosted instance. + * That distinction lives at the team / policy level: self-hosted instances bind to their own team + * (via {@code license_keys.team_id}) which carries its own {@code pricing_policy_id}. + * + *

Used as the key for per-source step limits on {@code pricing_policy.step_limits}. + */ +public enum JobSource { + WEB, + API, + PIPELINE, + /** + * The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend. + */ + DESKTOP_APP +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobStatus.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStatus.java new file mode 100644 index 0000000000..a172bb8937 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStatus.java @@ -0,0 +1,9 @@ +package stirling.software.saas.payg.model; + +public enum JobStatus { + OPEN, + CLOSED, + REFUNDED, + PARTIAL_REFUND, + FAILED +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobStepStatus.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStepStatus.java new file mode 100644 index 0000000000..8c8eeb9105 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStepStatus.java @@ -0,0 +1,7 @@ +package stirling.software.saas.payg.model; + +public enum JobStepStatus { + OK, + FAILED, + SKIPPED +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerBucket.java b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerBucket.java new file mode 100644 index 0000000000..6aed3aa71e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerBucket.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +/** Which pool a ledger entry touches. Debits flow CYCLE → BOUGHT → OVERAGE in that order. */ +public enum LedgerBucket { + CYCLE, + BOUGHT, + OVERAGE +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerEntryType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerEntryType.java new file mode 100644 index 0000000000..4a7b3697ad --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerEntryType.java @@ -0,0 +1,11 @@ +package stirling.software.saas.payg.model; + +public enum LedgerEntryType { + CYCLE_GRANT, + DEBIT, + REFUND, + EXPIRE, + OVERAGE_REPORTED, + ADJUSTMENT, + LEGACY_BACKFILL +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/ProcessType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ProcessType.java new file mode 100644 index 0000000000..76fdd6d9ae --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ProcessType.java @@ -0,0 +1,11 @@ +package stirling.software.saas.payg.model; + +/** + * Shape of the workflow the job represents. Recorded for analytics; per-process step limits live on + * {@link JobSource} now. + */ +public enum ProcessType { + SINGLE_TOOL, + CHAIN, + AUTOMATION +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java new file mode 100644 index 0000000000..d966b3d22e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java @@ -0,0 +1,9 @@ +package stirling.software.saas.payg.model; + +/** What a {@code wallet_ledger.reference_id} points at. */ +public enum ReferenceType { + JOB, + INVOICE, + STRIPE_EVENT, + ADMIN +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/WalletEngine.java b/app/saas/src/main/java/stirling/software/saas/payg/model/WalletEngine.java new file mode 100644 index 0000000000..6414632ce6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/WalletEngine.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +/** Which charging engine a wallet is running. Flipped per-team during cutover. */ +public enum WalletEngine { + LEGACY, + PAYG_SHADOW, + PAYG +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java new file mode 100644 index 0000000000..2c0603fc80 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java @@ -0,0 +1,77 @@ +package stirling.software.saas.payg.policy; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.model.Team; + +/** + * Sidecar carrying PAYG-only team fields. 1:1 with {@link Team} via shared PK so OSS Hibernate + * (which only sees the proprietary {@link Team} entity) never tries to add PAYG columns to the + * shared {@code teams} table. Mirrors the existing {@code SaasTeamExtensions} pattern. + * + *

Created lazily on first PAYG access for a team. + */ +@Entity +@Table(name = "payg_team_extensions") +@NoArgsConstructor +@Getter +@Setter +public class PaygTeamExtensions implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "team_id") + private Long teamId; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "team_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private Team team; + + /** Per-team policy override; NULL means use the default row in {@code pricing_policy}. */ + @Column(name = "pricing_policy_id") + private Long pricingPolicyId; + + /** Stripe customer id for this team. Eager-created so every team has billing identity. */ + @Column(name = "stripe_customer_id", unique = true, length = 128) + private String stripeCustomerId; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Version + @Column(name = "version") + private Long version; + + public PaygTeamExtensions(Team team) { + this.team = team; + this.teamId = team.getId(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PolicyChangedEvent.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PolicyChangedEvent.java new file mode 100644 index 0000000000..0c9e4c58a7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PolicyChangedEvent.java @@ -0,0 +1,28 @@ +package stirling.software.saas.payg.policy; + +import org.springframework.context.ApplicationEvent; + +/** + * Fires after a successful admin write to a {@code pricing_policy*} or {@code + * payg_team_extensions.pricing_policy_id} row. {@link PricingPolicyService} listens and invalidates + * its in-process cache so the writer instance reflects the change immediately. Other instances pick + * up the change on the next 30-second TTL expiry. + * + *

{@code payload} is informational only ({@code "create:42"}, {@code "setDefault:7"}, etc.) — + * the invalidation strategy is "blow the whole cache" regardless of what changed. + */ +public class PolicyChangedEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + private final String payload; + + public PolicyChangedEvent(Object source, String payload) { + super(source); + this.payload = payload; + } + + public String getPayload() { + return payload; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java new file mode 100644 index 0000000000..f1443664cc --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java @@ -0,0 +1,147 @@ +package stirling.software.saas.payg.policy; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapKeyColumn; +import jakarta.persistence.MapKeyEnumerated; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.JobSource; + +/** + * Versioned pricing policy. Unit-calculation knobs, per-source step limits, and the per-currency + * Stripe price IDs that turn doc-units into invoice amounts. Money lives in Stripe; this row + * carries everything else. + */ +@Entity +@Table(name = "pricing_policy") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class PricingPolicy implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "policy_id") + private Long id; + + /** Human-readable version label, e.g. {@code v1-2026-06}. Unique across all policies. */ + @Column(name = "version", nullable = false, unique = true, length = 32) + private String version; + + @Column(name = "effective_from", nullable = false) + private LocalDateTime effectiveFrom; + + /** Null while the policy is the current one in its lineage. */ + @Column(name = "effective_to") + private LocalDateTime effectiveTo; + + @Column(name = "doc_pages_per_unit", nullable = false) + private Integer docPagesPerUnit; + + @Column(name = "doc_bytes_per_unit", nullable = false) + private Long docBytesPerUnit; + + @Column(name = "min_charge_units", nullable = false) + private Integer minChargeUnits = 1; + + @Column(name = "file_unit_cap", nullable = false) + private Integer fileUnitCap = 1000; + + /** + * Max tool steps allowed in one process before it splits, keyed by the caller's {@link + * JobSource}. Self-hosted teams typically get a higher limit via a per-team policy override. + * + *

Persisted as a normalized child table {@code pricing_policy_step_limit (policy_id, + * job_source, step_limit)} rather than JSONB — values are typed and queryable directly. + */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "pricing_policy_step_limit", + joinColumns = @JoinColumn(name = "policy_id")) + @MapKeyEnumerated(EnumType.STRING) + @MapKeyColumn(name = "job_source", length = 32) + @Column(name = "step_limit", nullable = false) + private Map stepLimits = new HashMap<>(); + + /** + * Stripe Price IDs this policy resolves to — one per currency we support. Currency is not + * stored here; it comes from {@code stripe.prices.currency} via Sync Engine when picking the + * right Price for a customer's subscription. All prices must share the same Billing Meter and + * the same free-tier upper bound in units (enforced by a deploy-time CI check). + * + *

Persisted as {@code pricing_policy_stripe_price (policy_id, stripe_price_id)}. + */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "pricing_policy_stripe_price", + joinColumns = @JoinColumn(name = "policy_id")) + @Column(name = "stripe_price_id", nullable = false, length = 128) + private Set stripePriceIds = new HashSet<>(); + + /** + * Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx. + */ + @Column(name = "is_default", nullable = false) + private Boolean isDefault = false; + + @Column(name = "notes", columnDefinition = "text") + private String notes; + + @Column(name = "created_by", length = 255) + private String createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * Convenience ctor for the unit-calc-only fields used by the document classifier and tests. + * Other fields are filled with sensible defaults; persistence callers should set the rest + * before saving. + */ + public PricingPolicy( + int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) { + if (docPagesPerUnit <= 0) { + throw new IllegalArgumentException("docPagesPerUnit must be > 0"); + } + if (docBytesPerUnit <= 0) { + throw new IllegalArgumentException("docBytesPerUnit must be > 0"); + } + if (minChargeUnits < 1) { + throw new IllegalArgumentException("minChargeUnits must be >= 1"); + } + if (fileUnitCap < 1) { + throw new IllegalArgumentException("fileUnitCap must be >= 1"); + } + this.docPagesPerUnit = docPagesPerUnit; + this.docBytesPerUnit = docBytesPerUnit; + this.minChargeUnits = minChargeUnits; + this.fileUnitCap = fileUnitCap; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicyService.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicyService.java new file mode 100644 index 0000000000..e273a391d9 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicyService.java @@ -0,0 +1,255 @@ +package stirling.software.saas.payg.policy; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.repository.PricingPolicyRepository; + +/** + * Read-side facade over {@link PricingPolicyRepository}. The hot-path question is "what pricing + * policy applies to this team right now?" — answered by either the team's per-team override (via + * {@link PaygTeamExtensions#getPricingPolicyId()}) or the row with {@code is_default = TRUE}. + * + *

Reads are cached per-{@code teamId} for {@value #CACHE_TTL_SECONDS} seconds. The TTL is the + * correctness floor: a policy change is visible on every instance within that window without any + * coordination. Admin writes additionally fire a {@link PolicyChangedEvent} after commit so the + * instance handling the write sees its own change immediately; other instances pick it up on the + * next TTL expiry. + * + *

Writes are transactional and publish a {@link PolicyChangedEvent} after commit. The + * after-commit timing matters: publishing inside the tx would clear caches on instances that + * haven't yet seen the row change, racing them into re-reading stale state. After-commit (via + * {@link TransactionSynchronizationManager}) guarantees the new state is visible before any + * listener fires. + * + *

Cache value is a JPA entity. Callers must not mutate the returned policy — treat as + * read-only. We accept this rather than wrapping in a DTO to keep the PR small; if mutation becomes + * a footgun, swap the cache value type for an immutable snapshot. + */ +@Service +@Slf4j +public class PricingPolicyService { + + static final int CACHE_TTL_SECONDS = 30; + private static final int CACHE_MAX_SIZE = 10_000; + + private final PricingPolicyRepository policyRepository; + private final PaygTeamExtensionsRepository teamExtensionsRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * Cache keyed by {@code teamId}. Null teamId not supported (caller's bug). Value is the + * effective policy — either the team's override or the default row. + */ + private final Cache byTeamCache; + + public PricingPolicyService( + PricingPolicyRepository policyRepository, + PaygTeamExtensionsRepository teamExtensionsRepository, + ApplicationEventPublisher eventPublisher) { + this.policyRepository = Objects.requireNonNull(policyRepository, "policyRepository"); + this.teamExtensionsRepository = + Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository"); + this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher"); + this.byTeamCache = + Caffeine.newBuilder() + .maximumSize(CACHE_MAX_SIZE) + .expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS)) + .recordStats() + .build(); + } + + /** + * Resolves the effective policy for {@code teamId}: per-team override if set, else the row with + * {@code is_default = TRUE}. Throws {@link IllegalStateException} if no default exists — the + * seed migration is expected to put one there. + * + *

{@link Transactional}({@code readOnly = true}) so the eager-loaded {@code stepLimits} and + * {@code stripePriceIds} collections initialize inside the same session. + */ + @Transactional(readOnly = true) + public PricingPolicy getEffectivePolicy(Long teamId) { + Objects.requireNonNull(teamId, "teamId"); + return byTeamCache.get(teamId, this::loadEffectivePolicy); + } + + /** Bypasses the cache. Useful for admin endpoints that want a fresh read after a mutation. */ + @Transactional(readOnly = true) + public PricingPolicy getEffectivePolicyUncached(Long teamId) { + Objects.requireNonNull(teamId, "teamId"); + return loadEffectivePolicy(teamId); + } + + /** Lists every policy (admin read). Not cached — admin pages should always see fresh state. */ + @Transactional(readOnly = true) + public List listAll() { + return policyRepository.findAll(); + } + + @Transactional(readOnly = true) + public Optional findByVersion(String version) { + return policyRepository.findByVersion(version); + } + + @Transactional(readOnly = true) + public Optional findById(Long policyId) { + return policyRepository.findById(policyId); + } + + /** Creates a new policy row. Publishes {@link PolicyChangedEvent} after commit. */ + @Transactional + public PricingPolicy create(PricingPolicy draft) { + Objects.requireNonNull(draft, "draft"); + if (draft.getId() != null) { + throw new IllegalArgumentException( + "Create draft must not carry a policy_id; use update() to modify an existing" + + " row."); + } + if (Boolean.TRUE.equals(draft.getIsDefault())) { + // Promotion to default must go through setDefault() so the existing default is + // atomically cleared first; otherwise the partial unique index rejects the insert. + throw new IllegalArgumentException( + "Create with is_default=true is not allowed; create the row then call" + + " setDefault(id)."); + } + PricingPolicy saved = policyRepository.save(draft); + publishOnCommit("create:" + saved.getId()); + return saved; + } + + /** + * Promotes {@code newDefaultId} to be the default policy, atomically clearing the existing + * default first. Idempotent — calling with a row already flagged default is a silent no-op (no + * event fired; no state actually changed). + */ + @Transactional + public PricingPolicy setDefault(Long newDefaultId) { + Objects.requireNonNull(newDefaultId, "newDefaultId"); + PricingPolicy target = + policyRepository + .findById(newDefaultId) + .orElseThrow( + () -> + new IllegalArgumentException( + "No pricing_policy with id " + newDefaultId)); + if (Boolean.TRUE.equals(target.getIsDefault())) { + return target; + } + policyRepository.clearDefaultFlag(); + target.setIsDefault(true); + PricingPolicy saved = policyRepository.save(target); + publishOnCommit("setDefault:" + saved.getId()); + return saved; + } + + /** + * Sets {@code teamId}'s per-team policy override. {@code policyId = null} clears the override + * (team falls back to default). Validates the policy exists. + */ + @Transactional + public void setTeamOverride(Long teamId, Long policyId) { + Objects.requireNonNull(teamId, "teamId"); + if (policyId != null && !policyRepository.existsById(policyId)) { + throw new IllegalArgumentException("No pricing_policy with id " + policyId); + } + PaygTeamExtensions extensions = + teamExtensionsRepository + .findById(teamId) + .orElseThrow( + () -> + new IllegalStateException( + "No payg_team_extensions row for team " + + teamId + + " — should have been created on first" + + " PAYG access.")); + extensions.setPricingPolicyId(policyId); + teamExtensionsRepository.save(extensions); + publishOnCommit("teamOverride:" + teamId); + } + + /** + * Invalidates the cache. Called on every {@link PolicyChangedEvent} regardless of which row + * changed — cache hit rate is already team-scoped so the cost of a clear is bounded by how many + * active teams there are. + */ + @EventListener + public void onPolicyChanged(PolicyChangedEvent event) { + long evicted = byTeamCache.estimatedSize(); + byTeamCache.invalidateAll(); + log.debug( + "PricingPolicyService cache invalidated (payload='{}', approx {} entries dropped)", + event.getPayload(), + evicted); + } + + /** Visible for tests. */ + long cacheSize() { + return byTeamCache.estimatedSize(); + } + + /** + * Schedules a {@link PolicyChangedEvent} to fire after the current transaction commits, or + * fires immediately if no transaction is active (e.g. test paths calling write methods without + * a tx). Inside-transaction firing would have listeners clearing caches before the row change + * is visible to other connections — racing them into re-reading stale state. + */ + private void publishOnCommit(String payload) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + eventPublisher.publishEvent( + new PolicyChangedEvent(PricingPolicyService.this, payload)); + } + }); + } else { + eventPublisher.publishEvent(new PolicyChangedEvent(this, payload)); + } + } + + private PricingPolicy loadEffectivePolicy(Long teamId) { + Optional overrideId = + teamExtensionsRepository + .findById(teamId) + .map(PaygTeamExtensions::getPricingPolicyId); + if (overrideId.isPresent()) { + Long id = overrideId.get(); + Optional override = policyRepository.findById(id); + if (override.isPresent()) { + return override.get(); + } + // Override points at a missing policy — log and fall through to default rather than + // failing hard. The admin path that sets the override should validate up front; this + // is a safety net for racing deletes. + log.warn( + "Team {} has pricing_policy_id={} set as override but that row is missing;" + + " falling back to default.", + teamId, + id); + } + return policyRepository + .findFirstByIsDefaultTrue() + .orElseThrow( + () -> + new IllegalStateException( + "No default pricing_policy row found — the V11 seed" + + " migration must run before" + + " PricingPolicyService is reachable.")); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PolicyDtos.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PolicyDtos.java new file mode 100644 index 0000000000..0069b84dc6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PolicyDtos.java @@ -0,0 +1,82 @@ +package stirling.software.saas.payg.policy.admin; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.policy.PricingPolicy; + +/** + * Request/response DTOs for the pricing-policy admin endpoints. Records rather than the JPA entity + * directly so the admin API surface is decoupled from internal columns (e.g. {@code @Version} + * optimistic-lock fields, audit timestamps). + */ +final class PolicyDtos { + + private PolicyDtos() {} + + /** Outbound representation of a {@link PricingPolicy}. */ + record PolicyResponse( + Long policyId, + String version, + LocalDateTime effectiveFrom, + LocalDateTime effectiveTo, + Integer docPagesPerUnit, + Long docBytesPerUnit, + Integer minChargeUnits, + Integer fileUnitCap, + Map stepLimits, + Set stripePriceIds, + Boolean isDefault, + String notes, + String createdBy, + LocalDateTime createdAt) { + + static PolicyResponse from(PricingPolicy p) { + return new PolicyResponse( + p.getId(), + p.getVersion(), + p.getEffectiveFrom(), + p.getEffectiveTo(), + p.getDocPagesPerUnit(), + p.getDocBytesPerUnit(), + p.getMinChargeUnits(), + p.getFileUnitCap(), + // Copy the outer collections so a caller's mutation can't leak back into the + // cached entity. Values (Integer, String) are immutable, so a shallow copy is + // sufficient here. + new HashMap<>(p.getStepLimits()), + new HashSet<>(p.getStripePriceIds()), + p.getIsDefault(), + p.getNotes(), + p.getCreatedBy(), + p.getCreatedAt()); + } + } + + /** + * Inbound payload for {@code POST /policies}. {@code stepLimits} and {@code stripePriceIds} + * default to empty collections if omitted. {@code effectiveFrom} defaults to {@code now()}. + */ + record CreatePolicyRequest( + String version, + LocalDateTime effectiveFrom, + LocalDateTime effectiveTo, + Integer docPagesPerUnit, + Long docBytesPerUnit, + Integer minChargeUnits, + Integer fileUnitCap, + Map stepLimits, + Set stripePriceIds, + String notes, + String createdBy) {} + + /** + * Inbound payload for {@code PUT /teams/{teamId}/policy-override}. {@code policyId = null} + * clears the override (team falls back to default). + */ + record TeamOverrideRequest(Long policyId) {} +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminController.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminController.java new file mode 100644 index 0000000000..0ed2bb417f --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminController.java @@ -0,0 +1,170 @@ +package stirling.software.saas.payg.policy.admin; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +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 lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; +import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest; +import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse; +import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest; + +/** + * Admin-only CRUD for {@link PricingPolicy} rows + per-team override + default-promotion. Every + * mutation routes through {@link PricingPolicyService} so the cache invalidation event is published + * exactly once per mutation, after commit. Reads return live data (no cache) so admins always see + * their own write. + * + *

Path namespace {@code /api/v1/admin/payg/...} matches the design's other admin endpoints + * (cap-setting, cohort migration). Every endpoint requires {@code ROLE_ADMIN}. + */ +@Hidden +@RestController +@RequestMapping("/api/v1/admin/payg") +@Profile("saas") +@Tag(name = "PAYG Admin — Pricing Policy", description = "Admin CRUD for pricing policies") +@RequiredArgsConstructor +@Slf4j +public class PricingPolicyAdminController { + + private final PricingPolicyService policyService; + + @GetMapping("/policies") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "List all pricing policies (admin)") + public ResponseEntity> listPolicies() { + return ResponseEntity.ok( + policyService.listAll().stream().map(PolicyResponse::from).toList()); + } + + @GetMapping("/policies/{policyId}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get a single pricing policy by id (admin)") + public ResponseEntity getPolicy(@PathVariable Long policyId) { + return policyService + .findById(policyId) + .map(p -> ResponseEntity.ok(PolicyResponse.from(p))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/policies") + @PreAuthorize("hasRole('ADMIN')") + @Operation( + summary = "Create a new pricing policy (admin)", + description = + "Creates a non-default policy. To promote to default, call set-default after" + + " creation.") + public ResponseEntity createPolicy(@RequestBody CreatePolicyRequest req) { + try { + PricingPolicy draft = mapCreateRequest(req); + PricingPolicy saved = policyService.create(draft); + return ResponseEntity.status(HttpStatus.CREATED).body(PolicyResponse.from(saved)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(error(e.getMessage())); + } + } + + @PostMapping("/policies/{policyId}/set-default") + @PreAuthorize("hasRole('ADMIN')") + @Operation( + summary = "Promote a policy to default (admin)", + description = + "Atomically clears the existing default flag and sets this row's flag." + + " Teams without an override use the default.") + public ResponseEntity setDefault(@PathVariable Long policyId) { + try { + PricingPolicy promoted = policyService.setDefault(policyId); + return ResponseEntity.ok(PolicyResponse.from(promoted)); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error(e.getMessage())); + } + } + + @PutMapping("/teams/{teamId}/policy-override") + @PreAuthorize("hasRole('ADMIN')") + @Operation( + summary = "Set or clear a team's per-team pricing-policy override (admin)", + description = + "Payload {policyId: } sets the override; {policyId: null} clears it" + + " (team falls back to default).") + public ResponseEntity setTeamOverride( + @PathVariable Long teamId, @RequestBody TeamOverrideRequest req) { + try { + policyService.setTeamOverride(teamId, req == null ? null : req.policyId()); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException | IllegalStateException e) { + HttpStatus status = + e instanceof IllegalStateException + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status).body(error(e.getMessage())); + } + } + + @GetMapping("/teams/{teamId}/effective-policy") + @PreAuthorize("hasRole('ADMIN')") + @Operation( + summary = "Read the effective policy for a team (admin)", + description = + "Returns the override if set, else the default. Bypasses the read cache so" + + " admins always see the latest state.") + public ResponseEntity getEffectivePolicy(@PathVariable Long teamId) { + return ResponseEntity.ok( + PolicyResponse.from(policyService.getEffectivePolicyUncached(teamId))); + } + + private static PricingPolicy mapCreateRequest(CreatePolicyRequest req) { + if (req == null) { + throw new IllegalArgumentException("Request body required."); + } + if (req.version() == null || req.version().isBlank()) { + throw new IllegalArgumentException("version is required."); + } + if (req.docPagesPerUnit() == null || req.docBytesPerUnit() == null) { + throw new IllegalArgumentException("docPagesPerUnit and docBytesPerUnit are required."); + } + PricingPolicy p = new PricingPolicy(); + p.setVersion(req.version()); + p.setEffectiveFrom(req.effectiveFrom() != null ? req.effectiveFrom() : LocalDateTime.now()); + p.setEffectiveTo(req.effectiveTo()); + p.setDocPagesPerUnit(req.docPagesPerUnit()); + p.setDocBytesPerUnit(req.docBytesPerUnit()); + p.setMinChargeUnits(req.minChargeUnits() != null ? req.minChargeUnits() : 1); + p.setFileUnitCap(req.fileUnitCap() != null ? req.fileUnitCap() : 1000); + p.setStepLimits( + req.stepLimits() != null ? new HashMap<>(req.stepLimits()) : new HashMap<>()); + p.setStripePriceIds( + req.stripePriceIds() != null + ? new HashSet<>(req.stripePriceIds()) + : new HashSet<>()); + p.setIsDefault(false); + p.setNotes(req.notes()); + p.setCreatedBy(req.createdBy()); + return p; + } + + private static java.util.Map error(String message) { + return java.util.Map.of("error", message == null ? "unknown" : message); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java new file mode 100644 index 0000000000..c9cb2af9cc --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java @@ -0,0 +1,41 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.job.JobArtifactHash; +import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId; +import stirling.software.saas.payg.model.JobStatus; + +@Repository +public interface JobArtifactHashRepository + extends JpaRepository { + + /** + * Lineage lookup: find the open job (if any) whose recorded input/output hashes include the + * supplied content hash, scoped to one user and the workflow window. + */ + @Query( + "SELECT j.ownerUserId, h.id.jobId FROM JobArtifactHash h" + + " JOIN ProcessingJob j ON j.id = h.id.jobId" + + " WHERE j.ownerUserId = :userId" + + " AND j.status = :openStatus" + + " AND j.lastStepAt > :since" + + " AND h.id.contentHash = :contentHash") + List findLineageMatches( + @Param("userId") Long userId, + @Param("openStatus") JobStatus openStatus, + @Param("since") LocalDateTime since, + @Param("contentHash") String contentHash); + + /** Prunes rows older than {@code cutoff}; run from a scheduled task. */ + @Modifying + @Query("DELETE FROM JobArtifactHash h WHERE h.createdAt < :cutoff") + int deleteOlderThan(@Param("cutoff") LocalDateTime cutoff); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java new file mode 100644 index 0000000000..ec1a51963a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java @@ -0,0 +1,22 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; + +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; + +import stirling.software.saas.payg.shadow.PaygShadowCharge; + +@Repository +public interface PaygShadowChargeRepository extends JpaRepository { + + @Query( + "SELECT s FROM PaygShadowCharge s" + + " WHERE s.occurredAt >= :from AND s.occurredAt < :to" + + " ORDER BY s.occurredAt DESC") + List findInWindow( + @Param("from") LocalDateTime from, @Param("to") LocalDateTime to); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java new file mode 100644 index 0000000000..3473eeeff7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java @@ -0,0 +1,14 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.policy.PaygTeamExtensions; + +@Repository +public interface PaygTeamExtensionsRepository extends JpaRepository { + + Optional findByStripeCustomerId(String stripeCustomerId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java new file mode 100644 index 0000000000..0a5b2075e0 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java @@ -0,0 +1,29 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.policy.PricingPolicy; + +@Repository +public interface PricingPolicyRepository extends JpaRepository { + + Optional findByVersion(String version); + + Optional findFirstByIsDefaultTrue(); + + /** + * Atomically clears the {@code is_default} flag on whichever row currently carries it. Used by + * {@code setDefault(newId)} to free the slot before flipping the new row's flag — the {@code + * uq_pricing_policy_default} partial unique index would otherwise reject the second row. + * + *

Returns the count of rows updated (0 if no default existed yet, 1 normally). + */ + @Modifying + @Query("UPDATE PricingPolicy p SET p.isDefault = false WHERE p.isDefault = true") + int clearDefaultFlag(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobRepository.java new file mode 100644 index 0000000000..a930a2bb36 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobRepository.java @@ -0,0 +1,26 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +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; + +import stirling.software.saas.payg.job.ProcessingJob; +import stirling.software.saas.payg.model.JobStatus; + +@Repository +public interface ProcessingJobRepository extends JpaRepository { + + List findByOwnerUserIdAndStatus(Long ownerUserId, JobStatus status); + + /** + * Jobs left {@code OPEN} past the workflow window; the stale-close scheduler picks these up. + */ + @Query("SELECT j FROM ProcessingJob j WHERE j.status = :status AND j.lastStepAt < :cutoff") + List findStale( + @Param("status") JobStatus status, @Param("cutoff") LocalDateTime cutoff); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobStepRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobStepRepository.java new file mode 100644 index 0000000000..3958c6a5cd --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobStepRepository.java @@ -0,0 +1,15 @@ +package stirling.software.saas.payg.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.job.ProcessingJobStep; + +@Repository +public interface ProcessingJobStepRepository extends JpaRepository { + + List findByJobIdOrderByStartedAtAsc(UUID jobId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletEntitlementSnapshotRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletEntitlementSnapshotRepository.java new file mode 100644 index 0000000000..e15222c21e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletEntitlementSnapshotRepository.java @@ -0,0 +1,26 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot; +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId; + +@Repository +public interface WalletEntitlementSnapshotRepository + extends JpaRepository { + + /** Team-wide snapshot lookup. */ + default Optional findTeamWide(Long teamId) { + return findById( + new WalletEntitlementSnapshotId( + teamId, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID)); + } + + /** Per-member snapshot lookup. */ + default Optional findForMember(Long teamId, Long userId) { + return findById(new WalletEntitlementSnapshotId(teamId, userId)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java new file mode 100644 index 0000000000..2a2a003429 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java @@ -0,0 +1,50 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; + +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; + +import stirling.software.saas.payg.model.LedgerEntryType; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; + +@Repository +public interface WalletLedgerRepository extends JpaRepository { + + List findByTeamIdOrderByOccurredAtDesc(Long teamId); + + /** Sum of signed amounts over a team's entries — the wallet's current balance in units. */ + @Query( + "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e WHERE e.teamId = :teamId") + long sumBalanceForTeam(@Param("teamId") Long teamId); + + /** Period-bounded spend for one team in units (debits only). */ + @Query( + "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e" + + " WHERE e.teamId = :teamId" + + " AND e.entryType = :entryType" + + " AND e.occurredAt >= :periodStart" + + " AND e.occurredAt < :periodEnd") + long sumPeriodAmount( + @Param("teamId") Long teamId, + @Param("entryType") LedgerEntryType entryType, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); + + /** Per-member period spend (only when the member has a sub-cap configured). */ + @Query( + "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e" + + " WHERE e.teamId = :teamId AND e.actorUserId = :actorUserId" + + " AND e.entryType = :entryType" + + " AND e.occurredAt >= :periodStart" + + " AND e.occurredAt < :periodEnd") + long sumPeriodAmountForMember( + @Param("teamId") Long teamId, + @Param("actorUserId") Long actorUserId, + @Param("entryType") LedgerEntryType entryType, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletPolicyRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletPolicyRepository.java new file mode 100644 index 0000000000..f8c3076e53 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletPolicyRepository.java @@ -0,0 +1,14 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.wallet.WalletPolicy; + +@Repository +public interface WalletPolicyRepository extends JpaRepository { + + Optional findByTeamId(Long teamId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java new file mode 100644 index 0000000000..495930c08c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java @@ -0,0 +1,61 @@ +package stirling.software.saas.payg.shadow; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Per-job comparison row written while a team is in {@code PAYG_SHADOW} mode: what the legacy + * engine actually charged vs. what the PAYG engine would have charged. Aggregated daily by the + * shadow-reconciliation report; deletable after promotion. + */ +@Entity +@Table(name = "payg_shadow_charge") +@NoArgsConstructor +@Getter +@Setter +public class PaygShadowCharge implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "shadow_id") + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + @Column(name = "job_id", nullable = false) + private UUID jobId; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "payg_units", nullable = false) + private Integer paygUnits; + + @Column(name = "legacy_credits_charged", nullable = false) + private Integer legacyCreditsCharged; + + /** Signed percent difference: {@code 100 * (payg - legacy) / max(1, legacy)}. */ + @Column(name = "diff_pct", nullable = false) + private Integer diffPct; + + @CreationTimestamp + @Column(name = "occurred_at", nullable = false, updatable = false) + private LocalDateTime occurredAt; +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java new file mode 100644 index 0000000000..90aed63cd3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -0,0 +1,86 @@ +package stirling.software.saas.payg.wallet; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.LedgerBucket; +import stirling.software.saas.payg.model.LedgerEntryType; +import stirling.software.saas.payg.model.ReferenceType; + +/** + * Append-only ledger keyed on {@code team_id}. {@code amount_units} is signed (positive = credit, + * negative = debit). Two unique indexes (reference triple, stripe event id) prevent double-posting. + */ +@Entity +@Table(name = "wallet_ledger") +@NoArgsConstructor +@Getter +@Setter +public class WalletLedgerEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "entry_id") + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** Which team member triggered this entry; null for system grants. */ + @Column(name = "actor_user_id") + private Long actorUserId; + + @Enumerated(EnumType.STRING) + @Column(name = "entry_type", nullable = false, length = 32) + private LedgerEntryType entryType; + + @Enumerated(EnumType.STRING) + @Column(name = "bucket", nullable = false, length = 16) + private LedgerBucket bucket; + + /** Signed: positive = credit, negative = debit. The only quantity the app tracks. */ + @Column(name = "amount_units", nullable = false) + private Integer amountUnits; + + @Enumerated(EnumType.STRING) + @Column(name = "reference_type", nullable = false, length = 32) + private ReferenceType referenceType; + + @Column(name = "reference_id", nullable = false, length = 128) + private String referenceId; + + @Column(name = "policy_id") + private Long policyId; + + @Column(name = "stripe_event_id", length = 128) + private String stripeEventId; + + @CreationTimestamp + @Column(name = "occurred_at", nullable = false, updatable = false) + private LocalDateTime occurredAt; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata = new HashMap<>(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletPolicy.java new file mode 100644 index 0000000000..91cc78ad07 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletPolicy.java @@ -0,0 +1,94 @@ +package stirling.software.saas.payg.wallet; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.AutoGroupStrategy; +import stirling.software.saas.payg.model.CapPeriod; +import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.model.WalletEngine; + +/** + * Per-team wallet configuration: charging engine, period spend cap, warn/degrade thresholds, the + * degraded feature set, and the lineage-detection strategy. + * + *

No {@code @Version} — admin-only writes, no concurrent writers on a single row. + */ +@Entity +@Table(name = "wallet_policy") +@NoArgsConstructor +@Getter +@Setter +public class WalletPolicy implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "policy_id") + private Long id; + + @Column(name = "team_id", nullable = false, unique = true) + private Long teamId; + + @Enumerated(EnumType.STRING) + @Column(name = "engine", nullable = false, length = 16) + private WalletEngine engine = WalletEngine.LEGACY; + + @Enumerated(EnumType.STRING) + @Column(name = "cap_period", nullable = false, length = 16) + private CapPeriod capPeriod = CapPeriod.CALENDAR_MONTH; + + /** Null = unlimited. Doc-units per period. */ + @Column(name = "cap_units") + private Long capUnits; + + /** + * Original money cap input ("$50/month") in smallest currency unit; null if set as units. The + * currency comes from {@code stripe.customers.currency} at recompute time — we don't duplicate + * it here. + */ + @Column(name = "cap_source_money") + private Long capSourceMoney; + + @Column(name = "warn_at_pct", nullable = false) + private Integer warnAtPct = 80; + + @Column(name = "degrade_at_pct", nullable = false) + private Integer degradeAtPct = 100; + + @Enumerated(EnumType.STRING) + @Column(name = "degraded_feature_set", nullable = false, length = 32) + private FeatureSet degradedFeatureSet = FeatureSet.MINIMAL; + + @Enumerated(EnumType.STRING) + @Column(name = "auto_group_strategy", nullable = false, length = 16) + private AutoGroupStrategy autoGroupStrategy = AutoGroupStrategy.AUTO; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "notification_emails", columnDefinition = "jsonb", nullable = false) + private List notificationEmails = new ArrayList<>(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/app/saas/src/main/java/stirling/software/saas/service/CreditService.java b/app/saas/src/main/java/stirling/software/saas/service/CreditService.java index 426c70ae7f..775c97d8d4 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/CreditService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/CreditService.java @@ -15,6 +15,8 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; @@ -55,6 +57,7 @@ public class CreditService { private final Counter creditsConsumedCounter; private final Counter creditConsumptionFailuresCounter; private final Counter cycleResetCounter; + private final Counter stripeReportFailuresCounter; public CreditService( UserCreditRepository userCreditRepository, @@ -90,6 +93,10 @@ public class CreditService { Counter.builder("credits.cycle_reset") .description("Number of credit cycle resets performed") .register(meterRegistry); + this.stripeReportFailuresCounter = + Counter.builder("credits.stripe_report.failures") + .description("Stripe meter post failed after the DB debit committed") + .register(meterRegistry); // Active gauges for current credit levels Gauge.builder("credits.total_available", this, CreditService::getTotalAvailableCredits) @@ -296,7 +303,8 @@ public class CreditService { return true; } } else { - // Partial or full overage: consume free credits and report overage to Stripe + // Partial or full overage: consume free credits in this tx, report the overage + // to Stripe after commit (see scheduleStripeReportAfterCommit). int freeCreditsUsed = userCredits.getCycleCreditsRemaining() != null ? userCredits.getCycleCreditsRemaining() @@ -328,55 +336,27 @@ public class CreditService { } } - // Stable idempotency key per (user, amount, operation) so retries dedupe. String operationId = MDC.get("requestId"); - if (operationId == null || operationId.isBlank()) { - operationId = UUID.randomUUID().toString(); - } String idempotencyKey = stripeUsageReportingService.generateIdempotencyKey( supabaseId, overageCredits, operationId); - log.info( - "[CREDIT-CONSUME] Calling Stripe reporting service - User: {}, Overage credits: {}, Idempotency key: {}", + scheduleStripeReportAfterCommit( supabaseId, overageCredits, - idempotencyKey); - - boolean reported = - stripeUsageReportingService.reportUsageToStripe( - supabaseId, overageCredits, idempotencyKey); - - log.info( - "[CREDIT-CONSUME] Stripe reporting result: {} for user: {}", - reported ? "SUCCESS" : "FAILED", - supabaseId); - - if (reported) { - creditsConsumedCounter.increment(creditAmount); - log.info( - "[USAGE-BASED] User {} consumed {} free + {} overage credits (total: {})", - supabaseId, - freeCreditsUsed, - overageCredits, - creditAmount); - return true; - } else { - log.error( - "[USAGE-BASED] Failed to report {} overage credits to Stripe for user: {}", - overageCredits, - supabaseId); - log.error( - "[USAGE-BASED] Throwing exception to fail the operation; metering must succeed"); - creditConsumptionFailuresCounter.increment(); - throw new RuntimeException( - "Unable to report usage to Stripe. Operation cannot proceed without metering. Please try again or contact support if the issue persists."); - } + idempotencyKey, + creditAmount, + freeCreditsUsed); + return true; } - // Free credits were sufficient; already consumed and returned above - // If we reach here, there's a logic error - log.error("[USAGE-BASED] Unexpected code path reached for user: {}", supabaseId); + // Lost a concurrent-debit race: the in-memory balance check passed but the atomic + // UPDATE found insufficient credits. Surface the failure so the caller can retry. + log.warn( + "[USAGE-BASED] Concurrent-debit race lost the free-tier consumption for" + + " user {}; caller should retry.", + supabaseId); + creditConsumptionFailuresCounter.increment(); return false; } @@ -411,17 +391,6 @@ public class CreditService { creditConsumptionFailuresCounter.increment(); return false; } catch (RuntimeException e) { - // Metering failures are critical and should fail the operation. - // This ensures users aren't charged for operations that weren't metered. - if (e.getMessage() != null - && e.getMessage().contains("Unable to report usage to Stripe")) { - log.error( - "[CREDIT-CONSUME] Metering failure; rethrowing exception to fail operation"); - throw e; - } - - // Other runtime exceptions are logged but don't fail the operation. - // This prevents transient errors from blocking user operations. log.error( "[CREDIT-CONSUME] Unexpected runtime error consuming credits for user: {} - {}", supabaseId, @@ -451,6 +420,87 @@ public class CreditService { return saasUserExtensionService.isMeteredBillingEnabled(user); } + /** + * Posts the Stripe meter event for an overage debit in a {@code TransactionSynchronization} + * afterCommit hook, so the DB row lock is released before the HTTP call to Stripe. + * + *

If no transaction is active (e.g. a test calling consume directly) the report runs + * synchronously instead, so the meter event still fires. + */ + private void scheduleStripeReportAfterCommit( + String supabaseId, + int overageCredits, + String idempotencyKey, + int creditAmount, + int freeCreditsUsed) { + + Runnable reportToStripe = + () -> { + log.info( + "[CREDIT-CONSUME] Posting Stripe meter event - User: {}, Overage: {}," + + " Idempotency: {}", + supabaseId, + overageCredits, + idempotencyKey); + + boolean reported; + try { + reported = + stripeUsageReportingService.reportUsageToStripe( + supabaseId, overageCredits, idempotencyKey); + } catch (RuntimeException e) { + // Don't let a Stripe exception unwind the afterCommit chain — the DB + // debit has already committed. + log.error( + "[CREDIT-CONSUME] Stripe meter post threw for user {} (overage {});" + + " usage owed-but-unbilled until a retry succeeds", + supabaseId, + overageCredits, + e); + stripeReportFailuresCounter.increment(); + return; + } + + if (reported) { + creditsConsumedCounter.increment(creditAmount); + log.info( + "[USAGE-BASED] User {} consumed {} free + {} overage credits" + + " (total: {}); Stripe meter posted.", + supabaseId, + freeCreditsUsed, + overageCredits, + creditAmount); + } else { + // DB has the debit, Stripe doesn't. The idempotency key is stable, so a + // replay with the same key recovers the meter event without + // double-charging. + stripeReportFailuresCounter.increment(); + log.error( + "[USAGE-BASED] Failed to post Stripe meter event for user {}" + + " (overage {}); usage owed-but-unbilled. Idempotency key" + + " is stable: replay with key '{}' to recover.", + supabaseId, + overageCredits, + idempotencyKey); + } + }; + + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + reportToStripe.run(); + } + }); + } else { + log.warn( + "[CREDIT-CONSUME] No active transaction; reporting Stripe usage synchronously." + + " Expected only in tests."); + reportToStripe.run(); + } + } + /** Check if a user has credits available by Supabase ID (unified approach). */ public boolean hasCreditsAvailableBySupabaseId(String supabaseId) { Optional credits = getUserCreditsBySupabaseId(supabaseId); @@ -1054,48 +1104,23 @@ public class CreditService { // STEP 4: Try metered billing (check flag, not role) if (saasUserExtensionService.isMeteredBillingEnabled(user)) { log.info( - "[WATERFALL] User {} has metered billing enabled; reporting {} credits to Stripe", + "[WATERFALL] User {} has metered billing enabled; scheduling {} credits for" + + " Stripe report (after commit)", user.getUsername(), creditAmount); - try { - String operationId = MDC.get("requestId"); - if (operationId == null || operationId.isBlank()) { - operationId = UUID.randomUUID().toString(); - } - String idempotencyKey = - stripeUsageReportingService.generateIdempotencyKey( - supabaseId.toString(), creditAmount, operationId); + String operationId = MDC.get("requestId"); + String idempotencyKey = + stripeUsageReportingService.generateIdempotencyKey( + supabaseId.toString(), creditAmount, operationId); - boolean reported = - stripeUsageReportingService.reportUsageToStripe( - supabaseId.toString(), creditAmount, idempotencyKey); - - if (reported) { - creditsConsumedCounter.increment(creditAmount); - - log.info( - "[WATERFALL] Reported {} overage credits to Stripe for user: {}", - creditAmount, - user.getUsername()); - return CreditConsumptionResult.success("METERED_SUBSCRIPTION"); - } else { - log.error( - "[WATERFALL] Failed to report usage to Stripe for user: {}", - user.getUsername()); - creditConsumptionFailuresCounter.increment(); - return CreditConsumptionResult.failure("Failed to report usage to Stripe"); - } - } catch (Exception e) { - log.error( - "[WATERFALL] Exception while reporting to Stripe for user {}: {}", - user.getUsername(), - e.getMessage(), - e); - creditConsumptionFailuresCounter.increment(); - return CreditConsumptionResult.failure( - "Error reporting usage to Stripe: " + e.getMessage()); - } + scheduleStripeReportAfterCommit( + supabaseId.toString(), + creditAmount, + idempotencyKey, + creditAmount, + /* freeCreditsUsed= */ 0); + return CreditConsumptionResult.success("METERED_SUBSCRIPTION"); } else if (user.getRolesAsString().contains("ROLE_PRO_USER")) { // Pro user without metered billing enabled; reject with helpful message log.warn( diff --git a/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql b/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql new file mode 100644 index 0000000000..b7450984b2 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql @@ -0,0 +1,226 @@ +-- PAYG data model: pricing policy, processing jobs + lineage, wallet ledger, wallet policy, +-- entitlement snapshots, shadow-mode comparison rows, plus a payg_team_extensions sidecar table +-- carrying team-level PAYG fields, and a cap_units column on team_memberships. +-- +-- Sidecar pattern (mirrors saas_team_extensions): PAYG-only team fields don't sit directly on +-- `teams`, so OSS deployments running Hibernate ddl-auto=update against the proprietary Team +-- entity never see PAYG columns they don't have entities for. +-- +-- Everything is purely additive. No existing rows are modified, no columns are dropped. + +-- --------------------------------------------------------------------------------------------- +-- 1. pricing_policy — versioned economic config (units, lifecycle metadata). +-- step_limits and stripe_price_ids live on normalised child tables below — typed columns, no +-- JSON parsing, queryable directly. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS pricing_policy ( + policy_id BIGSERIAL PRIMARY KEY, + version VARCHAR(32) NOT NULL UNIQUE, + effective_from TIMESTAMP NOT NULL, + effective_to TIMESTAMP, + doc_pages_per_unit INTEGER NOT NULL, + doc_bytes_per_unit BIGINT NOT NULL, + min_charge_units INTEGER NOT NULL DEFAULT 1, + file_unit_cap INTEGER NOT NULL DEFAULT 1000, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + notes TEXT, + created_by VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_pricing_policy_default + ON pricing_policy (is_default) WHERE is_default = TRUE; + +-- Max steps allowed per process for each caller surface (JobSource). +CREATE TABLE IF NOT EXISTS pricing_policy_step_limit ( + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE, + job_source VARCHAR(32) NOT NULL, + step_limit INTEGER NOT NULL, + PRIMARY KEY (policy_id, job_source) +); + +-- Stripe Price IDs this policy resolves to, one per supported currency. Currency itself isn't +-- stored here — it lives on stripe.prices.currency and is looked up via Sync Engine when picking +-- the right Price for a customer's subscription. All prices in one policy must share the same +-- Billing Meter and the same first-tier upper bound in units (deploy-time CI check). +CREATE TABLE IF NOT EXISTS pricing_policy_stripe_price ( + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE, + stripe_price_id VARCHAR(128) NOT NULL, + PRIMARY KEY (policy_id, stripe_price_id) +); + +-- --------------------------------------------------------------------------------------------- +-- 2. payg_team_extensions — sidecar carrying PAYG-only team fields. 1:1 with teams via shared PK. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS payg_team_extensions ( + team_id BIGINT PRIMARY KEY REFERENCES teams(team_id) ON DELETE CASCADE, + pricing_policy_id BIGINT REFERENCES pricing_policy(policy_id), + stripe_customer_id VARCHAR(128) UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +COMMENT ON COLUMN payg_team_extensions.pricing_policy_id IS + 'Override policy for this team. NULL means use the row in pricing_policy with is_default=TRUE.'; +COMMENT ON COLUMN payg_team_extensions.stripe_customer_id IS + 'Stripe customer id for this team. Eager-created so every team has billing identity on file.'; + +-- --------------------------------------------------------------------------------------------- +-- 3. team_memberships column addition: optional per-member sub-cap. Lives directly on the table +-- because team_memberships is already a SaaS-only table. +-- --------------------------------------------------------------------------------------------- +ALTER TABLE team_memberships + ADD COLUMN IF NOT EXISTS cap_units BIGINT; +COMMENT ON COLUMN team_memberships.cap_units IS + 'Per-period spend cap for this member inside their team wallet, in doc units. NULL = no member-level cap.'; + +-- --------------------------------------------------------------------------------------------- +-- 4. processing_job — one billable process; step_count and last_step_at track the workflow window. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS processing_job ( + job_id UUID PRIMARY KEY, + owner_user_id BIGINT NOT NULL, + owner_team_id BIGINT, + process_type VARCHAR(32) NOT NULL, + source VARCHAR(32) NOT NULL, + document_fingerprint VARCHAR(64), + doc_units INTEGER NOT NULL DEFAULT 0, + step_count INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_step_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP, + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id), + charged_units INTEGER, + charged_cents INTEGER, + status VARCHAR(32) NOT NULL, + idempotency_key VARCHAR(128) UNIQUE, + metadata JSONB +); + +CREATE INDEX IF NOT EXISTS idx_processing_job_owner_open + ON processing_job (owner_user_id, status) WHERE status = 'OPEN'; + +CREATE INDEX IF NOT EXISTS idx_processing_job_last_step + ON processing_job (status, last_step_at) WHERE status = 'OPEN'; + +-- --------------------------------------------------------------------------------------------- +-- 5. processing_job_step — per-tool-call audit within a job. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS processing_job_step ( + step_id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE, + tool_id VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + input_pages INTEGER, + input_bytes BIGINT, + error_code VARCHAR(64) +); + +CREATE INDEX IF NOT EXISTS idx_processing_job_step_job + ON processing_job_step (job_id); + +-- --------------------------------------------------------------------------------------------- +-- 6. job_artifact_hash — per-step input/output content hashes used by the lineage detector. +-- --------------------------------------------------------------------------------------------- +-- content_hash holds "type:value" signature keys; VARCHAR(128) fits SHA-256 and future schemes. +CREATE TABLE IF NOT EXISTS job_artifact_hash ( + job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE, + content_hash VARCHAR(128) NOT NULL, + kind VARCHAR(8) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (job_id, content_hash, kind) +); + +CREATE INDEX IF NOT EXISTS idx_artifact_hash_lookup + ON job_artifact_hash (content_hash, created_at); + +-- --------------------------------------------------------------------------------------------- +-- 7. wallet_ledger — append-only signed-amount ledger keyed on team_id. +-- amount_units is INTEGER (per-row delta, always small); cap and rollup columns are BIGINT +-- because they accumulate across a billing period. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wallet_ledger ( + entry_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, + actor_user_id BIGINT, + entry_type VARCHAR(32) NOT NULL, + bucket VARCHAR(16) NOT NULL, + amount_units INTEGER NOT NULL, + reference_type VARCHAR(32) NOT NULL, + reference_id VARCHAR(128) NOT NULL, + policy_id BIGINT, + stripe_event_id VARCHAR(128), + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata JSONB +); + +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team + ON wallet_ledger (team_id, occurred_at); + +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_actor + ON wallet_ledger (team_id, actor_user_id, occurred_at) WHERE actor_user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_ref + ON wallet_ledger (reference_type, reference_id, entry_type, bucket); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_stripe_event + ON wallet_ledger (stripe_event_id) WHERE stripe_event_id IS NOT NULL; + +-- --------------------------------------------------------------------------------------------- +-- 8. wallet_policy — per-team charging engine, cap, degradation rules, lineage strategy. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wallet_policy ( + policy_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL UNIQUE REFERENCES teams(team_id) ON DELETE CASCADE, + engine VARCHAR(16) NOT NULL DEFAULT 'LEGACY', + cap_period VARCHAR(16) NOT NULL DEFAULT 'CALENDAR_MONTH', + cap_units BIGINT, + -- Customer's money intent ("I want $50/month"); the currency comes from the team's Stripe + -- customer at recompute time, not stored separately here. + cap_source_money BIGINT, + warn_at_pct INTEGER NOT NULL DEFAULT 80, + degrade_at_pct INTEGER NOT NULL DEFAULT 100, + degraded_feature_set VARCHAR(32) NOT NULL DEFAULT 'MINIMAL', + auto_group_strategy VARCHAR(16) NOT NULL DEFAULT 'AUTO', + notification_emails JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- --------------------------------------------------------------------------------------------- +-- 9. wallet_entitlement_snapshot — hot-path state for the entitlement guard. +-- user_id = 0 is the team-wide sentinel (Postgres treats NULL as not-equal-to-NULL in unique +-- constraints, so 0 is the cleaner choice for a composite PK). +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wallet_entitlement_snapshot ( + team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, + user_id BIGINT NOT NULL DEFAULT 0, + period_start TIMESTAMP NOT NULL, + period_end TIMESTAMP NOT NULL, + period_spend_units BIGINT NOT NULL DEFAULT 0, + period_cap_units BIGINT, + state VARCHAR(16) NOT NULL DEFAULT 'FULL', + feature_set VARCHAR(32) NOT NULL DEFAULT 'FULL', + enabled_gates JSONB NOT NULL DEFAULT '[]'::jsonb, + computed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (team_id, user_id) +); + +-- --------------------------------------------------------------------------------------------- +-- 10. payg_shadow_charge — per-job legacy-vs-PAYG diff during PAYG_SHADOW engine mode. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS payg_shadow_charge ( + shadow_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, + job_id UUID NOT NULL, + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id), + payg_units INTEGER NOT NULL, + legacy_credits_charged INTEGER NOT NULL, + diff_pct INTEGER NOT NULL, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_payg_shadow_team_time + ON payg_shadow_charge (team_id, occurred_at); diff --git a/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql b/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql new file mode 100644 index 0000000000..d587a80b2e --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql @@ -0,0 +1,37 @@ +-- Seed the V1 default pricing policy. Idempotent — only inserts when no default row exists. +-- Units sized so a typical 25-page / 5 MiB document is 1 unit; tune via admin endpoints once +-- Stripe Prices are wired in production. +-- +-- This migration is separated from V11 because V11 has already shipped to main — adding rows to +-- it would change its Flyway checksum and break existing deployments. + +INSERT INTO pricing_policy ( + version, effective_from, doc_pages_per_unit, doc_bytes_per_unit, + min_charge_units, file_unit_cap, is_default, notes, created_by +) +SELECT + 'v1-initial', CURRENT_TIMESTAMP, 25, 5242880, + 1, 1000, TRUE, + 'V1 default seeded by V12 migration. Tune via admin once Stripe Prices are configured.', + 'system' +WHERE NOT EXISTS ( + SELECT 1 FROM pricing_policy WHERE is_default = TRUE +); + +-- Step limits for the default policy across every JobSource. References the row inserted above +-- via the partial unique index on is_default=TRUE. +INSERT INTO pricing_policy_step_limit (policy_id, job_source, step_limit) +SELECT p.policy_id, src.job_source, src.step_limit +FROM pricing_policy p +CROSS JOIN ( + VALUES + ('WEB', 10), + ('API', 10), + ('PIPELINE', 20), -- automations get a longer chain + ('DESKTOP_APP', 10) +) AS src(job_source, step_limit) +WHERE p.is_default = TRUE + AND NOT EXISTS ( + SELECT 1 FROM pricing_policy_step_limit s + WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source + ); diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java new file mode 100644 index 0000000000..faf7f561ec --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java @@ -0,0 +1,60 @@ +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 org.junit.jupiter.api.Test; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Guards {@link SaasJpaConfig}'s scan paths from drifting out of sync with the actual entity and + * repository packages — without this, a missing package goes undetected until a runtime "No + * qualifying bean of type" startup failure that Mockito-based tests can't catch. + * + *

Reflection-based rather than a real Spring boot because the production schema uses + * Postgres-specific features H2 doesn't fully support. + */ +class SaasJpaConfigScanTest { + + private static final List EXPECTED_REPO_PACKAGES = + List.of( + "stirling.software.saas.repository", + "stirling.software.saas.billing.repository", + "stirling.software.saas.ai.repository", + "stirling.software.saas.payg.repository"); + + private static final List EXPECTED_ENTITY_PACKAGES = + List.of( + "stirling.software.saas.model", + "stirling.software.saas.billing.model", + "stirling.software.saas.ai.model", + // Recursive — covers all payg.* sub-packages. + "stirling.software.saas.payg"); + + @Test + void enableJpaRepositoriesIncludesAllExpectedPackages() { + EnableJpaRepositories annotation = + SaasJpaConfig.class.getAnnotation(EnableJpaRepositories.class); + assertThat(annotation).as("SaasJpaConfig must carry @EnableJpaRepositories").isNotNull(); + + Set actual = Set.copyOf(Arrays.asList(annotation.basePackages())); + assertThat(actual) + .as("Every package holding @Repository interfaces must be listed") + .containsAll(EXPECTED_REPO_PACKAGES); + } + + @Test + void entityScanIncludesAllExpectedPackages() { + EntityScan annotation = SaasJpaConfig.class.getAnnotation(EntityScan.class); + assertThat(annotation).as("SaasJpaConfig must carry @EntityScan").isNotNull(); + + Set actual = Set.copyOf(Arrays.asList(annotation.value())); + assertThat(actual) + .as("Every package holding @Entity classes must be listed") + .containsAll(EXPECTED_ENTITY_PACKAGES); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierTest.java b/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierTest.java new file mode 100644 index 0000000000..4f8c610181 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierTest.java @@ -0,0 +1,231 @@ +package stirling.software.saas.payg.docs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.saas.payg.policy.PricingPolicy; + +class DefaultDocumentClassifierTest { + + /** Same shape as the V1 default we'd seed in pricing_policy. */ + private static final PricingPolicy DEFAULT_POLICY = + new PricingPolicy( + /* docPagesPerUnit= */ 25, + /* docBytesPerUnit= */ 10L * 1024 * 1024, + /* minChargeUnits= */ 1, + /* fileUnitCap= */ 1000); + + private final DefaultDocumentClassifier classifier = + new DefaultDocumentClassifier(buildTempFileManager()); + + @Test + void singlePagePdf_isOneUnit() throws Exception { + MultipartFile pdf = pdf("one.pdf", 1); + + DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(1); + assertThat(metrics.docUnits()).isEqualTo(1); + assertThat(metrics.contentType()).isEqualTo("application/pdf"); + } + + @Test + void multiPagePdf_chargesByPageAxisWhenBytesAreTiny() throws Exception { + // 100 pages, well under 10 MiB → page axis dominates. ceil(100 / 25) = 4 units. + MultipartFile pdf = pdf("hundred.pdf", 100); + + DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(100); + assertThat(metrics.docUnits()).isEqualTo(4); + } + + @Test + void bytesAxisDominatesWhenFileIsLargeButFewPages() { + // Use a KiB-scale unit so the test allocation stays small. + PricingPolicy bytesy = new PricingPolicy(25, 10L * 1024, 1, 1000); // 10 KiB per unit + // 30 KiB / 10 KiB = 3 units. + byte[] payload = new byte[30 * 1024]; + MultipartFile blob = new MockMultipartFile("file", "scan.tiff", "image/tiff", payload); + + DocumentMetrics metrics = classifier.classify(blob, bytesy); + + assertThat(metrics.pages()).isZero(); + assertThat(metrics.docUnits()).isEqualTo(3); + assertThat(metrics.contentType()).isEqualTo("image/tiff"); + } + + @Test + void singleFileFileUnitCap_clampsExtremelyLargeInputs() { + PricingPolicy tightCap = new PricingPolicy(25, 10L * 1024, 1, /* fileUnitCap= */ 10); + // 200 KiB → 20 raw units; per-file cap pins to 10. + byte[] payload = new byte[200 * 1024]; + MultipartFile blob = + new MockMultipartFile("file", "huge.bin", "application/octet-stream", payload); + + DocumentMetrics metrics = classifier.classify(blob, tightCap); + + assertThat(metrics.docUnits()).isEqualTo(10); + } + + @Test + void emptyFile_chargesTheOneUnitFloor() { + MultipartFile empty = + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]); + + DocumentMetrics metrics = classifier.classify(empty, DEFAULT_POLICY); + + assertThat(metrics.bytes()).isZero(); + assertThat(metrics.docUnits()).isEqualTo(1); + } + + @Test + void malformedPdf_fallsBackToBytesOnlyClassification() { + byte[] junk = "%PDF-not-really-a-pdf-but-claims-to-be".getBytes(); + MultipartFile bad = new MockMultipartFile("file", "broken.pdf", "application/pdf", junk); + + DocumentMetrics metrics = classifier.classify(bad, DEFAULT_POLICY); + + assertThat(metrics.pages()).isZero(); + assertThat(metrics.docUnits()).isEqualTo(1); + } + + @Test + void encryptedPdf_isStillClassifiable() throws Exception { + byte[] bytes = encryptedPdfBytes(5, "ownerpwd", "userpwd"); + MultipartFile encrypted = + new MockMultipartFile("file", "secret.pdf", "application/pdf", bytes); + + DocumentMetrics metrics = classifier.classify(encrypted, DEFAULT_POLICY); + + // Page count behaviour on encrypted PDFs varies by reader; the stable property is that + // the byte axis still produces a charge. + assertThat(metrics.docUnits()).isGreaterThanOrEqualTo(1); + assertThat(metrics.bytes()).isEqualTo(bytes.length); + } + + @Test + void nullContentType_defaultsToOctetStream() { + MultipartFile noType = + new MockMultipartFile( + "file", "unknown.dat", /* contentType= */ null, new byte[100]); + + DocumentMetrics metrics = classifier.classify(noType, DEFAULT_POLICY); + + assertThat(metrics.contentType()).isEqualTo("application/octet-stream"); + } + + @Test + void pdfDetectedByExtension_whenContentTypeIsGeneric() throws Exception { + byte[] pdfBytes = pdfBytes(50); + MultipartFile pdf = + new MockMultipartFile("file", "report.pdf", "application/octet-stream", pdfBytes); + + DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(50); + } + + @Test + void multiFile_aggregatesUnits() throws Exception { + // Two 50-page PDFs: each is ceil(50/25) = 2 raw units; total = 4. Group cap of 1000 × 2 + // doesn't bind. + DocumentMetrics metrics = + classifier.classify(List.of(pdf("a.pdf", 50), pdf("b.pdf", 50)), DEFAULT_POLICY); + + assertThat(metrics.docUnits()).isEqualTo(4); + assertThat(metrics.pages()).isEqualTo(100); + } + + @Test + void multiFile_groupCapBindsOnSumOfRawUnits() { + // Asymmetric file sizes are required to actually exercise the group cap: + // File A: 50 raw units (well over fileUnitCap) + // File B: 1 raw unit + // Raw sum: 51 + // Group cap = fileUnitCap (25) × file_count (2) = 50 + // + // With a buggy per-file clamp inside the loop: (25, 1) → sum 26. + // With the fixed group cap on the raw sum: min(50, 51) = 50. + PricingPolicy policy = + new PricingPolicy( + /* docPagesPerUnit= */ 25, + /* docBytesPerUnit= */ 1L * 1024, // 1 KiB per unit + /* minChargeUnits= */ 1, + /* fileUnitCap= */ 25); + + byte[] big = new byte[50 * 1024]; // 50 KiB → 50 raw units + byte[] small = new byte[1 * 1024]; // 1 KiB → 1 raw unit + MultipartFile a = new MockMultipartFile("file", "a.bin", "application/octet-stream", big); + MultipartFile b = new MockMultipartFile("file", "b.bin", "application/octet-stream", small); + + DocumentMetrics metrics = classifier.classify(List.of(a, b), policy); + + assertThat(metrics.docUnits()) + .as( + "Group cap should clamp the raw sum (51) to fileUnitCap × fileCount (50)." + + " A result of 26 here means per-file clamping has snuck back in" + + " and the group cap is dead.") + .isEqualTo(50); + } + + @Test + void multiFile_emptyListRejected() { + assertThatThrownBy(() -> classifier.classify(List.of(), DEFAULT_POLICY)) + .isInstanceOf(IllegalArgumentException.class); + } + + // --- Fixture helpers ------------------------------------------------------------------------ + + private static MultipartFile pdf(String name, int pages) throws IOException { + return new MockMultipartFile("file", name, "application/pdf", pdfBytes(pages)); + } + + private static byte[] pdfBytes(int pages) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage()); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static byte[] encryptedPdfBytes(int pages, String ownerPwd, String userPwd) + throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage()); + } + doc.protect(new StandardProtectionPolicy(ownerPwd, userPwd, new AccessPermission())); + doc.save(baos); + return baos.toByteArray(); + } + } + + /** + * Constructs a real {@link TempFileManager} backed by the OS temp dir. Cheaper and more + * faithful than mocking — the classifier exercises the actual write+read+delete path the way it + * would in production. + */ + private static TempFileManager buildTempFileManager() { + return new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java new file mode 100644 index 0000000000..bffc17bd93 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java @@ -0,0 +1,151 @@ +package stirling.software.saas.payg.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot; +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId; +import stirling.software.saas.payg.job.JobArtifactHash; +import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId; +import stirling.software.saas.payg.job.ProcessingJob; +import stirling.software.saas.payg.job.ProcessingJobStep; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.shadow.PaygShadowCharge; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; +import stirling.software.saas.payg.wallet.WalletPolicy; + +/** + * Boots each PAYG entity via the no-arg constructor that JPA requires, exercises a few getter / + * setter pairs, and confirms composite-key equality where applicable. Catches Lombok / annotation + * regressions without needing a database. + */ +class PaygEntitiesSmokeTest { + + @Test + void pricingPolicy_instantiatesAndRoundTripsFields() { + PricingPolicy p = new PricingPolicy(); + p.setVersion("v1-2026-06"); + p.setDocPagesPerUnit(25); + p.setDocBytesPerUnit(10L * 1024 * 1024); + p.setStepLimits(Map.of(JobSource.WEB, 10, JobSource.API, 20)); + p.setStripePriceIds(Set.of("price_abc", "price_def")); + + assertThat(p.getVersion()).isEqualTo("v1-2026-06"); + assertThat(p.getStepLimits()) + .containsEntry(JobSource.WEB, 10) + .containsEntry(JobSource.API, 20) + .hasSize(2); + assertThat(p.getStripePriceIds()).containsExactlyInAnyOrder("price_abc", "price_def"); + } + + @Test + void pricingPolicy_convenienceCtorValidates() { + // Existing classifier callsite uses this ctor — verify the validation it carries from the + // previous record stays in place. + PricingPolicy p = new PricingPolicy(25, 10L * 1024 * 1024, 1, 1000); + assertThat(p.getDocPagesPerUnit()).isEqualTo(25); + assertThat(p.getFileUnitCap()).isEqualTo(1000); + } + + @Test + void processingJob_acceptsAllStatuses() { + ProcessingJob job = new ProcessingJob(); + job.setId(UUID.randomUUID()); + job.setOwnerUserId(42L); + job.setProcessType(ProcessType.CHAIN); + job.setSource(JobSource.WEB); + job.setStatus(JobStatus.OPEN); + job.setStartedAt(LocalDateTime.now()); + job.setLastStepAt(LocalDateTime.now()); + + assertThat(job.getProcessType()).isEqualTo(ProcessType.CHAIN); + assertThat(job.getStatus()).isEqualTo(JobStatus.OPEN); + } + + @Test + void processingJobStep_isInstantiable() { + ProcessingJobStep step = new ProcessingJobStep(); + step.setJobId(UUID.randomUUID()); + step.setToolId("/api/v1/general/compress"); + step.setStatus(JobStepStatus.OK); + + assertThat(step.getStatus()).isEqualTo(JobStepStatus.OK); + } + + @Test + void jobArtifactHash_compositeIdEqualityHolds() { + UUID jobId = UUID.randomUUID(); + JobArtifactHashId a = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT); + JobArtifactHashId b = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT); + JobArtifactHashId different = new JobArtifactHashId(jobId, "abc123", ArtifactKind.OUTPUT); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(different); + + JobArtifactHash row = new JobArtifactHash(); + row.setId(a); + assertThat(row.getId().getKind()).isEqualTo(ArtifactKind.INPUT); + } + + @Test + void walletLedgerEntry_signedAmountAllowed() { + WalletLedgerEntry entry = new WalletLedgerEntry(); + entry.setTeamId(7L); + entry.setEntryType(LedgerEntryType.DEBIT); + entry.setBucket(LedgerBucket.CYCLE); + entry.setAmountUnits(-4); + entry.setReferenceType(ReferenceType.JOB); + entry.setReferenceId("job:abc"); + + assertThat(entry.getAmountUnits()).isEqualTo(-4); + } + + @Test + void walletPolicy_carriesSensibleDefaults() { + WalletPolicy policy = new WalletPolicy(); + + assertThat(policy.getEngine()).isEqualTo(WalletEngine.LEGACY); + assertThat(policy.getCapPeriod()).isEqualTo(CapPeriod.CALENDAR_MONTH); + assertThat(policy.getWarnAtPct()).isEqualTo(80); + assertThat(policy.getDegradeAtPct()).isEqualTo(100); + assertThat(policy.getDegradedFeatureSet()).isEqualTo(FeatureSet.MINIMAL); + assertThat(policy.getAutoGroupStrategy()).isEqualTo(AutoGroupStrategy.AUTO); + } + + @Test + void walletEntitlementSnapshot_compositeIdHandlesTeamWideSentinel() { + WalletEntitlementSnapshotId teamWide = + new WalletEntitlementSnapshotId(7L, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID); + WalletEntitlementSnapshotId memberA = new WalletEntitlementSnapshotId(7L, 42L); + + assertThat(teamWide).isNotEqualTo(memberA); + assertThat(teamWide.getUserId()).isZero(); + + WalletEntitlementSnapshot snap = new WalletEntitlementSnapshot(); + snap.setId(teamWide); + snap.setEnabledGates(List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.AUTOMATION)); + + assertThat(snap.getState()).isEqualTo(EntitlementState.FULL); + assertThat(snap.getEnabledGates()).hasSize(2); + } + + @Test + void paygShadowCharge_isInstantiable() { + PaygShadowCharge row = new PaygShadowCharge(); + row.setTeamId(7L); + row.setJobId(UUID.randomUUID()); + row.setPolicyId(1L); + row.setPaygUnits(4); + row.setLegacyCreditsCharged(20); + row.setDiffPct(-80); + + assertThat(row.getDiffPct()).isNegative(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/policy/PricingPolicyServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/policy/PricingPolicyServiceTest.java new file mode 100644 index 0000000000..dace97d367 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/policy/PricingPolicyServiceTest.java @@ -0,0 +1,268 @@ +package stirling.software.saas.payg.policy; + +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.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.context.ApplicationEventPublisher; + +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.repository.PricingPolicyRepository; + +/** + * Unit tests for {@link PricingPolicyService}: lookup precedence (override → default), cache + * hit/miss, invalidation on {@link PolicyChangedEvent}, mutation paths publishing the event. + */ +class PricingPolicyServiceTest { + + private PricingPolicyRepository policyRepo; + private PaygTeamExtensionsRepository extensionsRepo; + private ApplicationEventPublisher events; + private PricingPolicyService service; + + private PricingPolicy defaultPolicy; + private PricingPolicy overridePolicy; + + @BeforeEach + void setUp() { + policyRepo = Mockito.mock(PricingPolicyRepository.class); + extensionsRepo = Mockito.mock(PaygTeamExtensionsRepository.class); + events = Mockito.mock(ApplicationEventPublisher.class); + service = new PricingPolicyService(policyRepo, extensionsRepo, events); + + defaultPolicy = policy(1L, "v1-default", true); + overridePolicy = policy(2L, "v1-enterprise", false); + + when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.of(defaultPolicy)); + when(policyRepo.findById(1L)).thenReturn(Optional.of(defaultPolicy)); + when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy)); + when(policyRepo.existsById(2L)).thenReturn(true); + } + + @Test + void noOverride_returnsDefault() { + when(extensionsRepo.findById(42L)).thenReturn(Optional.empty()); + + PricingPolicy result = service.getEffectivePolicy(42L); + + assertThat(result).isEqualTo(defaultPolicy); + } + + @Test + void overrideSet_returnsOverride() { + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(42L); + ext.setPricingPolicyId(2L); + when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext)); + + PricingPolicy result = service.getEffectivePolicy(42L); + + assertThat(result).isEqualTo(overridePolicy); + } + + @Test + void overridePointsAtMissingPolicy_fallsBackToDefault() { + // Race condition: team's override row references a policy that has since been deleted. + // Service should log + fall back rather than throw, so the team still gets billed + // correctly under the default. + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(42L); + ext.setPricingPolicyId(999L); + when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext)); + when(policyRepo.findById(999L)).thenReturn(Optional.empty()); + + PricingPolicy result = service.getEffectivePolicy(42L); + + assertThat(result).isEqualTo(defaultPolicy); + } + + @Test + void noDefaultExists_throws() { + when(extensionsRepo.findById(42L)).thenReturn(Optional.empty()); + when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEffectivePolicy(42L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No default pricing_policy row"); + } + + @Test + void secondCallHitsCache_noRepoLookup() { + when(extensionsRepo.findById(42L)).thenReturn(Optional.empty()); + + service.getEffectivePolicy(42L); + service.getEffectivePolicy(42L); + service.getEffectivePolicy(42L); + + // Three calls, one DB lookup — the cache holds the result. + verify(policyRepo, times(1)).findFirstByIsDefaultTrue(); + verify(extensionsRepo, times(1)).findById(42L); + } + + @Test + void uncachedRead_alwaysHitsRepo() { + when(extensionsRepo.findById(42L)).thenReturn(Optional.empty()); + + service.getEffectivePolicyUncached(42L); + service.getEffectivePolicyUncached(42L); + + verify(policyRepo, times(2)).findFirstByIsDefaultTrue(); + } + + @Test + void policyChangedEvent_invalidatesCache() { + when(extensionsRepo.findById(42L)).thenReturn(Optional.empty()); + + service.getEffectivePolicy(42L); + assertThat(service.cacheSize()).isEqualTo(1); + + service.onPolicyChanged(new PolicyChangedEvent(this, "test")); + + assertThat(service.cacheSize()).isZero(); + // Next call repopulates from DB. + service.getEffectivePolicy(42L); + verify(policyRepo, times(2)).findFirstByIsDefaultTrue(); + } + + @Test + void create_rejectsDraftWithId() { + PricingPolicy draft = policy(99L, "v2", false); + assertThatThrownBy(() -> service.create(draft)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not carry a policy_id"); + verify(events, never()).publishEvent(any()); + } + + @Test + void create_rejectsDefaultFlagPreSet() { + PricingPolicy draft = policy(null, "v2", true); + assertThatThrownBy(() -> service.create(draft)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("setDefault"); + verify(events, never()).publishEvent(any()); + } + + @Test + void create_savesAndPublishesEvent() { + PricingPolicy draft = policy(null, "v2-fresh", false); + PricingPolicy saved = policy(3L, "v2-fresh", false); + when(policyRepo.save(draft)).thenReturn(saved); + + PricingPolicy result = service.create(draft); + + assertThat(result).isEqualTo(saved); + ArgumentCaptor evt = ArgumentCaptor.forClass(PolicyChangedEvent.class); + verify(events).publishEvent(evt.capture()); + assertThat(evt.getValue().getPayload()).contains("create:3"); + } + + @Test + void setDefault_promotesAndClearsExisting() { + // newDefaultId = 2, current default is 1 + PricingPolicy promoted = policy(2L, "v1-enterprise", true); + when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy)); + when(policyRepo.save(any(PricingPolicy.class))).thenReturn(promoted); + + PricingPolicy result = service.setDefault(2L); + + verify(policyRepo).clearDefaultFlag(); + assertThat(result.getIsDefault()).isTrue(); + verify(events, atLeastOnce()).publishEvent(any(PolicyChangedEvent.class)); + } + + @Test + void setDefault_alreadyDefault_isNoop() { + // Calling setDefault on the row that's already default → return it, don't re-flag, but + // still don't fire an event (no state change). Keeps callers idempotent without spamming + // listeners. + PricingPolicy result = service.setDefault(1L); + + assertThat(result).isEqualTo(defaultPolicy); + verify(policyRepo, never()).clearDefaultFlag(); + verify(policyRepo, never()).save(any(PricingPolicy.class)); + verify(events, never()).publishEvent(any()); + } + + @Test + void setDefault_unknownId_throws() { + when(policyRepo.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.setDefault(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No pricing_policy with id 999"); + verify(events, never()).publishEvent(any()); + } + + @Test + void setTeamOverride_setsAndPublishes() { + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(42L); + when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext)); + when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext); + + service.setTeamOverride(42L, 2L); + + assertThat(ext.getPricingPolicyId()).isEqualTo(2L); + verify(extensionsRepo).save(ext); + verify(events).publishEvent(any(PolicyChangedEvent.class)); + } + + @Test + void setTeamOverride_clearsWithNullPolicyId() { + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(42L); + ext.setPricingPolicyId(2L); + when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext)); + when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext); + + service.setTeamOverride(42L, null); + + assertThat(ext.getPricingPolicyId()).isNull(); + } + + @Test + void setTeamOverride_unknownPolicyId_throwsBeforeSave() { + when(policyRepo.existsById(999L)).thenReturn(false); + + assertThatThrownBy(() -> service.setTeamOverride(42L, 999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No pricing_policy with id 999"); + + verify(extensionsRepo, never()).save(any(PaygTeamExtensions.class)); + verify(events, never()).publishEvent(any()); + } + + @Test + void setTeamOverride_missingExtensionsRow_throws() { + when(extensionsRepo.findById(42L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.setTeamOverride(42L, 2L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("payg_team_extensions row"); + } + + private static PricingPolicy policy(Long id, String version, boolean isDefault) { + PricingPolicy p = new PricingPolicy(); + p.setId(id); + p.setVersion(version); + p.setEffectiveFrom(LocalDateTime.now()); + p.setDocPagesPerUnit(25); + p.setDocBytesPerUnit(5L * 1024 * 1024); + p.setMinChargeUnits(1); + p.setFileUnitCap(1000); + p.setIsDefault(isDefault); + return p; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java new file mode 100644 index 0000000000..595ce0ecd0 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java @@ -0,0 +1,230 @@ +package stirling.software.saas.payg.policy.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +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.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; +import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest; +import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse; +import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest; + +/** + * Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code + * CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's + * error mapping (4xx for validation, 404 for missing rows). + */ +@ExtendWith(MockitoExtension.class) +class PricingPolicyAdminControllerTest { + + @Mock private PricingPolicyService service; + + private PricingPolicyAdminController controller; + + @BeforeEach + void setUp() { + controller = new PricingPolicyAdminController(service); + } + + @Test + void listPolicies_returnsAll() { + when(service.listAll()) + .thenReturn(List.of(policy(1L, "v1", true), policy(2L, "v2", false))); + + ResponseEntity> resp = controller.listPolicies(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).hasSize(2); + assertThat(resp.getBody().get(0).version()).isEqualTo("v1"); + } + + @Test + void getPolicy_returnsOk() { + when(service.findById(1L)).thenReturn(Optional.of(policy(1L, "v1", true))); + + ResponseEntity resp = controller.getPolicy(1L); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody().policyId()).isEqualTo(1L); + } + + @Test + void getPolicy_missingReturns404() { + when(service.findById(999L)).thenReturn(Optional.empty()); + + ResponseEntity resp = controller.getPolicy(999L); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void createPolicy_happyPath() { + CreatePolicyRequest req = + new CreatePolicyRequest( + "v2", + LocalDateTime.now(), + null, + 25, + 5L * 1024 * 1024, + 1, + 1000, + null, + null, + "notes", + "admin@example.com"); + PricingPolicy saved = policy(99L, "v2", false); + ArgumentCaptor draft = ArgumentCaptor.forClass(PricingPolicy.class); + when(service.create(draft.capture())).thenReturn(saved); + + ResponseEntity resp = controller.createPolicy(req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(((PolicyResponse) resp.getBody()).policyId()).isEqualTo(99L); + assertThat(draft.getValue().getVersion()).isEqualTo("v2"); + // Controller must never let isDefault=true through to the service — setDefault is the + // only path for promotion. + assertThat(draft.getValue().getIsDefault()).isFalse(); + } + + @Test + void createPolicy_missingVersion_returns400() { + CreatePolicyRequest req = + new CreatePolicyRequest( + null, null, null, 25, 5L * 1024 * 1024, 1, 1000, null, null, null, null); + + ResponseEntity resp = controller.createPolicy(req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoInteractions(service); + } + + @Test + void createPolicy_missingDocFields_returns400() { + CreatePolicyRequest req = + new CreatePolicyRequest( + "v2", null, null, null, null, 1, 1000, null, null, null, null); + + ResponseEntity resp = controller.createPolicy(req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoInteractions(service); + } + + @Test + void setDefault_returnsOk() { + when(service.setDefault(2L)).thenReturn(policy(2L, "v2-promoted", true)); + + ResponseEntity resp = controller.setDefault(2L); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(((PolicyResponse) resp.getBody()).isDefault()).isTrue(); + } + + @Test + void setDefault_unknownId_returns404() { + when(service.setDefault(999L)) + .thenThrow(new IllegalArgumentException("No pricing_policy with id 999")); + + ResponseEntity resp = controller.setDefault(999L); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void setTeamOverride_noContent() { + TeamOverrideRequest req = new TeamOverrideRequest(2L); + + ResponseEntity resp = controller.setTeamOverride(42L, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(service).setTeamOverride(42L, 2L); + } + + @Test + void setTeamOverride_nullBody_clearsOverride() { + // Curl with no body, or {} → req == null is handled as "clear". + ResponseEntity resp = controller.setTeamOverride(42L, null); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(service).setTeamOverride(42L, null); + } + + @Test + void setTeamOverride_unknownPolicy_returns400() { + TeamOverrideRequest req = new TeamOverrideRequest(999L); + org.mockito.Mockito.doThrow(new IllegalArgumentException("No pricing_policy with id 999")) + .when(service) + .setTeamOverride(42L, 999L); + + ResponseEntity resp = controller.setTeamOverride(42L, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void setTeamOverride_missingTeamExtensions_returns404() { + TeamOverrideRequest req = new TeamOverrideRequest(2L); + org.mockito.Mockito.doThrow(new IllegalStateException("No payg_team_extensions row")) + .when(service) + .setTeamOverride(42L, 2L); + + ResponseEntity resp = controller.setTeamOverride(42L, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getEffectivePolicy_bypassesCache() { + when(service.getEffectivePolicyUncached(42L)).thenReturn(policy(1L, "v1", true)); + + ResponseEntity resp = controller.getEffectivePolicy(42L); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody().version()).isEqualTo("v1"); + verify(service).getEffectivePolicyUncached(42L); + } + + @Test + void policyResponse_collectionsAreDefensiveCopies() { + PricingPolicy p = policy(1L, "v1", true); + p.setStepLimits(new java.util.HashMap<>(Map.of())); + p.setStripePriceIds(new java.util.HashSet<>()); + + PolicyResponse resp = PolicyResponse.from(p); + + // Mutating the source after building the response should not affect the response. + p.getStepLimits().put(stirling.software.saas.payg.model.JobSource.WEB, 99); + p.getStripePriceIds().add("price_xyz"); + assertThat(resp.stepLimits()).isEmpty(); + assertThat(resp.stripePriceIds()).isEmpty(); + } + + private static PricingPolicy policy(Long id, String version, boolean isDefault) { + PricingPolicy p = new PricingPolicy(); + p.setId(id); + p.setVersion(version); + p.setEffectiveFrom(LocalDateTime.now()); + p.setDocPagesPerUnit(25); + p.setDocBytesPerUnit(5L * 1024 * 1024); + p.setMinChargeUnits(1); + p.setFileUnitCap(1000); + p.setIsDefault(isDefault); + return p; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/service/StripeAfterCommitOrderingTest.java b/app/saas/src/test/java/stirling/software/saas/service/StripeAfterCommitOrderingTest.java new file mode 100644 index 0000000000..6454dfe3b8 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/service/StripeAfterCommitOrderingTest.java @@ -0,0 +1,109 @@ +package stirling.software.saas.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link + * TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on + * rollback. + */ +class StripeAfterCommitOrderingTest { + + @AfterEach + void clearSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clear(); + } + } + + @Test + void afterCommitRunsAfterCommit_notDuringTransaction() { + List order = new ArrayList<>(); + + TransactionSynchronizationManager.initSynchronization(); + try { + order.add("inside-tx-before-register"); + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + order.add("after-commit-hook"); + } + }); + order.add("inside-tx-after-register"); + + // Simulate commit by firing afterCommit on every registered synchronization. + order.add("commit-triggered"); + for (TransactionSynchronization s : + TransactionSynchronizationManager.getSynchronizations()) { + s.afterCommit(); + } + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + assertThat(order) + .containsExactly( + "inside-tx-before-register", + "inside-tx-after-register", + "commit-triggered", + "after-commit-hook"); + } + + @Test + void afterCommitDoesNotRun_onRollback() { + List order = new ArrayList<>(); + + TransactionSynchronizationManager.initSynchronization(); + try { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + order.add("after-commit-hook-MUST-NOT-FIRE"); + } + + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + order.add("after-completion-rollback"); + } + } + }); + + // Simulate rollback: afterCompletion fires, afterCommit must not. + for (TransactionSynchronization s : + TransactionSynchronizationManager.getSynchronizations()) { + s.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); + } + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + assertThat(order) + .containsExactly("after-completion-rollback") + .doesNotContain("after-commit-hook-MUST-NOT-FIRE"); + } + + @Test + void isSynchronizationActive_reflectsSpringTransactionalContext() { + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + + TransactionSynchronizationManager.initSynchronization(); + try { + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/service/StripeRollbackOnFailureTest.java b/app/saas/src/test/java/stirling/software/saas/service/StripeRollbackOnFailureTest.java deleted file mode 100644 index 03bb8fa7d1..0000000000 --- a/app/saas/src/test/java/stirling/software/saas/service/StripeRollbackOnFailureTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package stirling.software.saas.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.concurrent.atomic.AtomicInteger; - -import org.junit.jupiter.api.Test; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.AbstractPlatformTransactionManager; -import org.springframework.transaction.support.DefaultTransactionDefinition; -import org.springframework.transaction.support.DefaultTransactionStatus; -import org.springframework.transaction.support.TransactionTemplate; - -/** - * Verifies finding #5 (CreditService Stripe ordering / DB divergence) end-to-end. - * - *

Connor's claim: free credits are deducted before the Stripe overage call; if Stripe fails the - * code throws but the deduction has already committed. Earlier analysis flagged this BOGUS because - * the class is {@code @Transactional} and Spring rolls back on uncaught RuntimeException — but the - * subtlety I missed last time (with {@code @PreAuthorize hasRole}) means I want a real test rather - * than another argument-from-docs. - * - *

This test reproduces the exact Spring transaction wiring: a method annotated as transactional - * does (1) an in-transaction "deduct credits" write, then (2) throws a RuntimeException. We assert - * the transaction manager observes the throw and triggers {@code rollback()}, not {@code commit()}. - */ -class StripeRollbackOnFailureTest { - - @Test - void runtimeExceptionTriggersRollback_notCommit() { - AtomicInteger commits = new AtomicInteger(); - AtomicInteger rollbacks = new AtomicInteger(); - - PlatformTransactionManager tm = - new AbstractPlatformTransactionManager() { - @Override - protected Object doGetTransaction() { - return new Object(); - } - - @Override - protected void doBegin( - Object transaction, - org.springframework.transaction.TransactionDefinition def) { - // no-op - } - - @Override - protected void doCommit(DefaultTransactionStatus status) { - commits.incrementAndGet(); - } - - @Override - protected void doRollback(DefaultTransactionStatus status) { - rollbacks.incrementAndGet(); - } - }; - - TransactionTemplate template = - new TransactionTemplate(tm, new DefaultTransactionDefinition()); - - // This is the exact shape of CreditService.consumeCreditBySupabaseId when Stripe fails: - // 1. deduct free credits (already happened, line 318-320 in production) - // 2. call Stripe → returns false (mocked) - // 3. throw new RuntimeException("Unable to report usage to Stripe...") - // The throw escapes through the catch at line 413-420 (which re-throws metering failures). - RuntimeException thrown = - assertThrows( - RuntimeException.class, - () -> - template.executeWithoutResult( - status -> { - // Step 1: imaginary credit deduction happens here. - // Step 2: Stripe returns false. - // Step 3: throw — same wording as production line 372. - throw new RuntimeException( - "Unable to report usage to Stripe. Operation cannot proceed without metering."); - })); - - assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe"); - assertThat(commits.get()) - .as("commit() must NOT be called when the method throws a RuntimeException") - .isZero(); - assertThat(rollbacks.get()) - .as("rollback() must be called when the method throws a RuntimeException") - .isEqualTo(1); - } - - @Test - void runtimeExceptionIsRethrown_notSwallowed_throughCatchBlock() { - // Sanity check that the actual catch logic at CreditService.java:413-420 re-throws the - // Stripe-failure RuntimeException rather than swallowing it. If it didn't re-throw, the - // transaction would commit. We rebuild the same try/catch shape here. - RuntimeException thrown = - assertThrows( - RuntimeException.class, - () -> consumeCreditMimicry(/* stripeReports= */ false)); - assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe"); - } - - @Test - void runtimeExceptionIsSwallowed_forNonMeteringErrors() { - // Unrelated runtime exceptions are caught at CreditService.java:425-431 and swallowed - // (return false). This is per the existing behaviour so we just lock it in. - Boolean result = consumeCreditMimicry(/* stripeReports= */ true); - assertThat(result).isTrue(); - } - - /** Tiny inline mock of the catch chain in CreditService.consumeCreditBySupabaseId. */ - private static Boolean consumeCreditMimicry(boolean stripeReports) { - try { - // Step 1: deduct free credits (would have been DB write). - // Step 2: Stripe call. - if (!stripeReports) { - throw new RuntimeException( - "Unable to report usage to Stripe. Operation cannot proceed without metering."); - } - return true; - } catch (IllegalArgumentException e) { - return false; - } catch (RuntimeException e) { - if (e.getMessage() != null - && e.getMessage().contains("Unable to report usage to Stripe")) { - throw e; // re-thrown so @Transactional rolls back - } - return false; - } catch (Exception e) { - return false; - } - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/service/StripeUsageIdempotencyKeyTest.java b/app/saas/src/test/java/stirling/software/saas/service/StripeUsageIdempotencyKeyTest.java new file mode 100644 index 0000000000..8c3414e942 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/service/StripeUsageIdempotencyKeyTest.java @@ -0,0 +1,62 @@ +package stirling.software.saas.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import stirling.software.saas.billing.service.StripeUsageReportingService; +import stirling.software.saas.config.SupabaseConfigurationProperties; + +/** + * Pins the Stripe meter-event idempotency key as a deterministic function of (Supabase user, + * overage amount, request id). Stripe collapses duplicates by this key, so a regression here means + * customers get double-billed on a retry. + */ +class StripeUsageIdempotencyKeyTest { + + private final StripeUsageReportingService service = + new StripeUsageReportingService(Mockito.mock(SupabaseConfigurationProperties.class)); + + @Test + void sameInputs_produceSameKey() { + String first = service.generateIdempotencyKey("user-123", 10, "req-abc"); + String second = service.generateIdempotencyKey("user-123", 10, "req-abc"); + + assertThat(first) + .as("Idempotency key must be stable across calls with identical inputs.") + .isEqualTo(second); + } + + @Test + void differentRequestIds_produceDifferentKeys() { + String reqA = service.generateIdempotencyKey("user-123", 10, "req-abc"); + String reqB = service.generateIdempotencyKey("user-123", 10, "req-xyz"); + + assertThat(reqA).isNotEqualTo(reqB); + } + + @Test + void differentOverageAmounts_produceDifferentKeys() { + String tenCredits = service.generateIdempotencyKey("user-123", 10, "req-abc"); + String elevenCredits = service.generateIdempotencyKey("user-123", 11, "req-abc"); + + assertThat(tenCredits).isNotEqualTo(elevenCredits); + } + + @Test + void differentUsers_produceDifferentKeys() { + String alice = service.generateIdempotencyKey("user-alice", 10, "req-abc"); + String bob = service.generateIdempotencyKey("user-bob", 10, "req-abc"); + + assertThat(alice).isNotEqualTo(bob); + } + + @Test + void keyShapeIncludesAllThreeDimensions() { + // Format: usage_{supabaseId}_{credits}_{operationId} + String key = service.generateIdempotencyKey("user-123", 42, "req-abc"); + + assertThat(key).contains("user-123").contains("42").contains("req-abc"); + } +} diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 6275bac775..c3a2c0d551 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -630,9 +630,10 @@ RUN set -eux; \ RUN set -eux; \ mkdir -p /configs /configs/cache /configs/heap_dumps /logs /customFiles \ /pipeline/watchedFolders /pipeline/finishedFolders \ + /storage \ /tmp/stirling-pdf/heap_dumps; \ chown -R stirlingpdfuser:stirlingpdfgroup \ - /home/stirlingpdfuser /configs /logs /customFiles /pipeline \ + /home/stirlingpdfuser /configs /logs /customFiles /pipeline /storage \ /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 1462bec52a..89913af09a 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -84,7 +84,8 @@ RUN set -eux; \ ln -s /configs /app/configs; \ ln -s /customFiles /app/customFiles; \ ln -s /pipeline /app/pipeline; \ - chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \ + ln -s /storage /app/storage; \ + chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ chown stirlingpdfuser:stirlingpdfgroup /app; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 81c10943f6..f9754b4721 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -80,7 +80,8 @@ RUN set -eux; \ ln -s /configs /app/configs; \ ln -s /customFiles /app/customFiles; \ ln -s /pipeline /app/pipeline; \ - chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \ + ln -s /storage /app/storage; \ + chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ chown stirlingpdfuser:stirlingpdfgroup /app; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index b748dbab82..d091d7b3fa 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -99,11 +99,11 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a curl \ shadow \ util-linux && \ - mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \ mkdir -p /usr/share/fonts/opentype/noto && \ # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /tmp/stirling-pdf + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /storage /tmp/stirling-pdf # Copy scripts and built artifacts after OS package layer to maximize cache reuse. COPY --chown=1000:1000 scripts/init-without-ocr.sh /scripts/init-without-ocr.sh diff --git a/docker/embedded/compose/docker-compose-latest-fat-security.yml b/docker/embedded/compose/docker-compose-latest-fat-security.yml index 7abd1acd6c..7efa65a718 100644 --- a/docker/embedded/compose/docker-compose-latest-fat-security.yml +++ b/docker/embedded/compose/docker-compose-latest-fat-security.yml @@ -20,6 +20,7 @@ services: - ../../../stirling/latest/data:/usr/share/tessdata:rw - ../../../stirling/latest/config:/configs:rw - ../../../stirling/latest/logs:/logs:rw + - ../../../stirling/latest/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "false" @@ -36,5 +37,4 @@ services: METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "true" SHOW_SURVEY: "true" - STORAGE_LOCAL_BASEPATH: /configs/storage restart: unless-stopped diff --git a/docker/embedded/compose/test_cicd.yml b/docker/embedded/compose/test_cicd.yml index fe165c8644..f42087291f 100644 --- a/docker/embedded/compose/test_cicd.yml +++ b/docker/embedded/compose/test_cicd.yml @@ -16,6 +16,7 @@ services: - ../../../stirling/latest/data:/usr/share/tessdata:rw - ../../../stirling/latest/config:/configs:rw - ../../../stirling/latest/logs:/logs:rw + - ../../../stirling/latest/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" @@ -31,4 +32,9 @@ services: SYSTEM_GOOGLEVISIBILITY: "true" SYSTEM_ENABLEMOBILESCANNER: "true" SECURITY_CUSTOMGLOBALAPIKEY: "123456789" + # Folder management + file-storage features the cucumber + # `folders_and_files.feature` suite needs to upload PDFs against. + # The folder endpoints and the storage upload endpoint short-circuit + # to 403 "Storage is disabled" when this is left at its default false. + STORAGE_ENABLED: "true" restart: on-failure:5 diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 742d1a7967..91ff27b5bb 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -1094,6 +1094,15 @@ class SanitizePdfParams(ApiModel): remove_xmp_metadata: bool = Field(False, description="Remove XMP metadata from the PDF") +class Orientation1(StrEnum): + """ + Orientation to apply to the target page size. Ignored when pageSize is KEEP. + """ + + portrait = "PORTRAIT" + landscape = "LANDSCAPE" + + class PageSize(StrEnum): """ The scale of pages in the output PDF. Acceptable values are A0-A6, LETTER, LEGAL, KEEP. @@ -1112,6 +1121,10 @@ class PageSize(StrEnum): class ScalePagesParams(ApiModel): + orientation: Orientation1 = Field( + Orientation1.portrait, + description="Orientation to apply to the target page size. Ignored when pageSize is KEEP.", + ) page_size: PageSize = Field( ..., description="The scale of pages in the output PDF. Acceptable values are A0-A6, LETTER, LEGAL, KEEP." ) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 6bada9283f..c8240371d1 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,8 +1,10 @@ dist/ dist-portal/ editor/dist/ -# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier) -editor/src-tauri/target/ +# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier). +# Match nested target/ dirs too — provisioner/ and thumbnail-handler/ each +# have their own Cargo workspace under src-tauri/. +editor/src-tauri/**/target/ editor/src-tauri/gen/ node_modules/ editor/public/vendor/ diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts index 1817a1361a..7a049e2632 100644 --- a/frontend/editor/playwright.config.ts +++ b/frontend/editor/playwright.config.ts @@ -4,10 +4,10 @@ import { defineConfig, devices } from "@playwright/test"; * Stirling-PDF E2E Test Configuration * * The suite is split into two projects: - * - `stubbed` — backend-free specs that mock `/api/v1/*` via `page.route()`. + * - `stubbed` - backend-free specs that mock `/api/v1/*` via `page.route()`. * Safe to run in CI without the Spring Boot server. Lives in * `src/core/tests/stubbed/**`. - * - `live` — specs that require a real backend on `localhost:8080` + * - `live` - specs that require a real backend on `localhost:8080` * (auth, admin mutation, real tool round-trips). Lives in * `src/core/tests/live/**`. * @@ -35,7 +35,7 @@ export default defineConfig({ expect: { timeout: 10_000 }, use: { - baseURL: "http://localhost:5173", + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173", trace: "on-first-retry", screenshot: "only-on-failure", video: "on-first-retry", @@ -44,14 +44,14 @@ export default defineConfig({ }, projects: [ - // Stubbed — no backend required, chromium-only for CI speed + // Stubbed - no backend required, chromium-only for CI speed { name: "stubbed", testDir: "./src/core/tests/stubbed", use: chromiumViewport, }, - // Live setup — runs once before the live suite to perform the real + // Live setup - runs once before the live suite to perform the real // forced-password-change first-login flow against a freshly-booted // backend. The live project depends on it. { @@ -61,7 +61,7 @@ export default defineConfig({ use: chromiumViewport, }, - // Live backend — auth + admin-mutation + real-tool smoke + // Live backend - auth + admin-mutation + real-tool smoke { name: "live", testDir: "./src/core/tests/live", @@ -69,7 +69,7 @@ export default defineConfig({ dependencies: ["live-setup"], }, - // Enterprise — license-gated SSO/SAML/audit/teams against keycloak compose + // Enterprise - license-gated SSO/SAML/audit/teams against keycloak compose // Uses port 8080 directly (the docker compose stack publishes the // backend's built-in frontend there); the Vite dev server is bypassed // because the OAuth/SAML callback URLs are registered against 8080. @@ -98,7 +98,7 @@ export default defineConfig({ webServer: { // In CI, serve a pre-built `dist/` via `vite preview` so the heavy tool // pages don't pay vite's on-demand transform cost on first hit (which - // blew the 30s navigationTimeout under --workers=3 — see + // blew the 30s navigationTimeout under --workers=3 - see // all-tool-pages-load.spec.ts). Locally, keep `vite` dev for HMR. command: process.env.CI ? "npx vite preview --port 5173 --strictPort" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 5ae0cfc435..9697479bf7 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -90,6 +90,7 @@ nothingToUndo = "Nothing to undo" noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" noValidFiles = "No valid files to process" oops = "Oops!" +openInNewWindow = "Open in new window" openInViewer = "Open in Viewer" operationCancelled = "Operation cancelled" page = "Page" @@ -494,6 +495,11 @@ title = "Adjust Page Scale" [adjustPageScale.error] failed = "An error occurred while adjusting the page scale." +[adjustPageScale.orientation] +label = "Page orientation" +landscape = "Landscape" +portrait = "Portrait" + [adjustPageScale.pageSize] keep = "Keep Original Size" label = "Target Page Size" @@ -1467,7 +1473,7 @@ settingsOverview = "This is the Settings Panel. Admin settings systemCustomization = "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users." teamsAndUsers = "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself." welcome = "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators." -wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help." +wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime - just open Settings and find it here in the Tours section under Help." [adminUserSettings] actions = "Actions" @@ -1500,6 +1506,32 @@ user = "User" usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address." webOnlyUser = "Web Only User" +[agents] +auto_redaction_description = "Redact PII automatically" +auto_redaction_name = "Auto Redaction" +back_to_tools = "Back to tools" +coming_soon = "Coming soon" +compliance_description = "Audit documents for compliance" +compliance_name = "Compliance Check" +data_extraction_description = "Extract tables & structured data" +data_extraction_name = "Data Extraction" +doc_summary_description = "Summarise long documents" +doc_summary_name = "Summariser" +form_filler_description = "Fill PDF forms intelligently" +form_filler_name = "Form Filler" +fullscreen_title = "Stirling Agents" +pdf_to_markdown_description = "Convert PDFs to clean Markdown" +pdf_to_markdown_name = "PDF to Markdown" +section_title = "Agents" +show_less = "Show less" +start_chat = "Start chatting" +stirling_description = "Your general-purpose PDF assistant" +stirling_full_name = "Stirling General Agent" +stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents." +stirling_name = "Stirling" +stirling_tooltip = "Stirling agent" +view_all = "View all agents" + [analytics] disable = "Disable analytics" enable = "Enable analytics" @@ -1525,7 +1557,7 @@ comment = "Comment" comments = "Comments" contents = "Text" delete = "Delete" -desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." +desc = "Use highlight, pen, text, and notes. Changes stay live-no flattening required." drawing = "Drawing" duplicate = "Duplicate" editCircle = "Edit Circle" @@ -1759,6 +1791,10 @@ insufficientPermissions = "You do not have permission to perform this action." pleaseLoginAgain = "Please login again." sessionExpired = "Session Expired" +[auth.displayName] +guest = "Guest" +user = "User" + [auto-rename] description = "Automatically finds the title from your PDF content and uses it as the filename." header = "Auto Rename PDF" @@ -1851,6 +1887,13 @@ label = "Open menu for {{title}}" [automate.files] placeholder = "Select files to process with this automation" +[automate.folderScanWarning] +advice = "You can still download the file (e.g. to inspect or hand-edit it), but the unsupported steps will need to be removed before the backend can run it." +cancel = "Cancel" +confirm = "Export anyway" +intro = "Folder scanning runs on the backend, so it can only execute tools that have a backend endpoint. The following step(s) in this automation do not, and will fail when the pipeline runs:" +title = "Some steps cannot run in folder scanning" + [automate.importModal] cancel = "Cancel" confirm = "Import" @@ -2082,9 +2125,9 @@ placeholder = "Number of pages" title = "Last N Pages" [bulkSelection.operators] -and = "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)" -comma = "Comma: , or | — combine selections (e.g., 1-10, 20)" -not = "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" +and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" +comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" +not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" text = "AND has higher precedence than comma. NOT applies within the document range." title = "Operators" @@ -2672,6 +2715,16 @@ title = "Change Permissions" [changePermissions.tooltip.warning] text = "To make these permissions unchangeable, use the Add Password tool to set an owner password." +[chat.header] +agentMenu = "Stirling agent options" +clearChat = "Clear chat" +settings = "Agent settings" + +[chat.input] +attach = "Attach files" +placeholder = "What do you want to do?" +send = "Send message" + [chat.progress] analyzing = "Analysing your request..." calling_engine = "AI is thinking..." @@ -2687,6 +2740,32 @@ whole_doc_read_done = "Finished reading the document..." whole_doc_read_started = "Reading the document..." whole_doc_slice_done = "Reading the document... ({{percent}}% complete)" +[chat.quickActions] +browseYourFiles = "Browse your files" +compressMany = "Compress these documents" +compressOne = "Compress this document" +convertMany = "Convert these documents to PDF" +convertOne = "Convert this document to PDF" +fileSummary_one = "1 file in workbench ({{types}})" +fileSummary_other = "{{count}} files in workbench ({{types}})" +heading = "Get started" +mergeMany = "Merge these {{count}} documents into 1" +moreFiles = "+{{count}} more" +openFromComputer = "Open from computer" +removeFile = "Remove {{name}}" +rotateMany = "Rotate these documents" +rotateOne = "Rotate this document" +splitOne = "Split this document" + +[chat.responses] +cannot_continue = "Something went wrong and I can't continue." +cannot_do = "I'm unable to do that." +done = "Done." +need_clarification = "Could you clarify your request?" +not_found = "I couldn't find the requested information." +processing = "Processing ({{outcome}})..." +unsupported_capability = "Unsupported capability: {{capability}}" + [chat.toolsUsed] summary = "Ran {{count}} tools" summary_one = "Ran 1 tool" @@ -2780,13 +2859,14 @@ title = "These PDFs look highly different" [compare.edited] label = "Edited PDF" -selectBaseFirst = "Select original PDF first" placeholder = "Select the edited PDF" +selectBaseFirst = "Select original PDF first" [compare.error] filesMissing = "Unable to locate the selected files. Please re-select them." generic = "Unable to compare these files." selectRequired = "Select a original and edited document." +title = "Comparison failed" [compare.large.file] message = "One or Both of the provided documents are too large to process" @@ -2852,6 +2932,7 @@ title = "Still working…" [compare.status] complete = "Comparison ready" +error = "Comparison failed" extracting = "Extracting text..." processing = "Analysing differences..." @@ -2862,9 +2943,9 @@ pageLabel = "Page" [compare.swap] confirm = "Swap and Re-run" -label = "Swap" confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?" confirmTitle = "Re-run comparison?" +label = "Swap" [compare.toasts] unlinkedBody = "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." @@ -3184,7 +3265,7 @@ showPreferencesBtn = "Manage preferences" title = "How we use Cookies" [cookieBanner.popUp.description] -1 = "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love." +1 = "We use cookies and other technologies to make Stirling PDF work better for you-helping us improve our tools and keep building features you'll love." 2 = "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." [cookieBanner.preferencesModal] @@ -3197,16 +3278,16 @@ subtitle = "Cookie Usage" title = "Consent Preferences Center" [cookieBanner.preferencesModal.analytics] -description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with." +description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured-Stirling PDF cannot and will never track the content of the documents you work with." title = "Analytics" [cookieBanner.preferencesModal.description] 1 = "Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users." -2 = "Stirling PDF cannot—and will never—track or access the content of the documents you use." +2 = "Stirling PDF cannot-and will never-track or access the content of the documents you use." 3 = "Your privacy and trust are at the core of what we do." [cookieBanner.preferencesModal.necessary] -description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off." +description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms-which is why they can't be turned off." [cookieBanner.preferencesModal.necessary.title] 1 = "Strictly Necessary Cookies" @@ -3759,13 +3840,207 @@ expand = "Expand sidebar" files = "Files" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" +leaveMyFiles = "Leave My Files" +myFiles = "My Files" noFiles = "No files yet" -openFileManager = "Open file manager" +openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" openSettings = "Open settings" search = "Search" searchPlaceholder = "Search files..." +[filesPage] +addToWorkspace = "Add to workspace" +addToWorkspaceCount = "Add {{count}} to workspace" +allFiles = "All files" +back = "Back" +backToFolder = "Back to {{folder}}" +backToMyFiles = "Back to My Files" +breadcrumbs = "Folder path" +cancel = "Cancel" +clearSearch = "Clear search" +clearSelection = "Clear selection" +closeDetails = "Close details" +create = "Create" +cycleBlocked = "Can't move a folder into one of its own subfolders." +delete = "Delete" +deleteFolder = "Delete folder" +deleteFolderBody = "Delete folder \"{{name}}\"?" +deleteFolderConfirm = "Delete folder \"{{name}}\"? Files inside will be moved to All files. {{count}} file(s) affected." +deleteFolderContents = "Also delete {{count}} file(s) inside the folder" +deleteFolderContentsWarning = "Files will be permanently removed and cannot be recovered." +deleteFolderError = "Could not delete the folder. Try again." +deleteFolderKeepHint = "Files inside will be moved to All files." +deleteFolderTitle = "Delete folder?" +deselectAll = "Clear selection" +details = "Details" +detailsCount = "{{count}} files selected" +dismissError = "Dismiss" +download = "Download" +downloadAll = "Download all" +downloadVersion = "Download this version" +dropOverlay = "Drop files to upload" +dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder." +file = "File" +fileMenu = "File actions" +folder = "Folder" +folderItems = "{{count}} items" +folderMenu = "Folder actions" +inPath = "in {{path}}" +inWorkspace = "Open" +inWorkspaceAria = "Already in workspace" +loading = "Loading…" +localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organise it." +moreActions = "More folder actions" +moveLocalToCloudBlocked = "Local-only files can't be moved into cloud folders. Save them to the cloud first." +moveSkippedRemote = "{{count}} file(s) couldn't be moved on the server (no permission or already deleted)." +moveTo = "Move to…" +myFiles = "My Files" +newFolder = "New folder" +newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." +newFolderTabUnavailable = "Switch to All or Cloud to create folders." +newRootFolder = "New folder at root" +offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration." +open = "Open" +openInWorkbench = "Open in workbench" +openVersionInWorkspace = "Open in workspace" +originFilter = "Filter by source" +quickView = "Quick view" +refresh = "Refresh from server" +remove = "Delete" +removeConfirm = "Delete {{count}} file(s)? This cannot be undone." +removeVersion = "Remove this version" +rename = "Rename" +renameFolder = "Rename folder" +resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)" +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" +selectFile = "Select file {{name}}" +shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it." +shareManage = "Manage sharing" +showDetails = "Show details" +summary = "{{count}} items" +syncFailed = "Folder sync failed: {{message}}" +syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be merged." +tree = "Folders" +upload = "Upload" +uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder." +uploadFromMobile = "Upload from Mobile" +versionActions = "Version actions" +versionCollapse = "Collapse middle versions" +versionOrigin = "Original upload" +versionsCount = "{{count}} versions" +versionShowHidden = "Show {{count}} earlier versions" +viewVersion = "View this version" +appearance.colour = "Colour" +appearance.icon = "Icon" +appearance.title = "Appearance" +appearance.useColour = "Use colour {{c}}" +column.modified = "Modified" +column.name = "Name" +column.size = "Size" +column.type = "Type" +empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files." +empty.newFolderCta = "Create folder" +empty.title = "This folder is empty" +empty.uploadCta = "Upload files" +empty.cloud.hint = "Upload a file to start, or create a folder to organise." +empty.cloud.offlineHint = "Reconnect to load your cloud library." +empty.cloud.offlineTitle = "No cached cloud files" +empty.cloud.title = "No cloud files yet" +empty.local.hint = "Files saved without uploading stay here. Drop a file to add one." +empty.local.title = "No local-only files" +empty.recent.hint = "Files you open or edit will appear here." +empty.recent.title = "Nothing modified yet" +empty.shared.hint = "When someone shares a file via link, it appears here." +empty.shared.title = "Nothing shared with you" +empty.sharedByMe.hint = "Create a share link or invite a teammate from any of your files to see it here." +empty.sharedByMe.title = "You haven't shared any files yet" +error.actionFailed = "Could not {{action}}." +error.actionFailedDetail = "Could not {{action}}: {{message}}" +error.deleteFolderFailed = "Could not delete folder." +error.deleteFolderFailedDetail = "Could not delete folder: {{message}}" +error.folderAppearanceFailed = "Could not update folder appearance." +error.folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}" +error.moveFilesFailed = "Could not move files." +error.moveFilesFailedDetail = "Could not move files: {{message}}" +error.moveFolderFailed = "Could not move folder." +error.moveFolderFailedDetail = "Could not move folder: {{message}}" +error.removeFilesFailed = "Could not remove files." +error.removeFilesFailedDetail = "Could not remove files: {{message}}" +error.uploadFilesFailed = "Could not upload files." +error.uploadFilesFailedDetail = "Could not upload files: {{message}}" +field.added = "Added" +field.count = "Files" +field.folder = "Folder" +field.modified = "Modified" +field.name = "Name" +field.size = "Size" +field.toolHistory = "Tool history" +field.toolHistoryAtVersion = "Cumulative tool chain" +field.totalSize = "Total size" +field.type = "Type" +field.versionHistory = "Version journey" +folderName.cancel = "Cancel" +folderName.error = "Could not save folder. Try again." +folderName.label = "Folder name" +folderName.placeholder = "Folder name" +moveDialog.cancel = "Cancel" +moveDialog.confirm = "Move here" +moveDialog.error = "Could not move. Try again." +moveDialog.hint = "Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left." +moveDialog.newFolderCancel = "Discard" +moveDialog.newFolderCreate = "Create" +moveDialog.newFolderError = "Could not create folder. Try again." +moveDialog.newFolderLabel = "New folder name" +moveDialog.newFolderPlaceholder = "Folder name" +moveDialog.newFolderToggle = "Create new folder…" +moveDialog.title = "Move to folder" +origin.all = "All sources" +origin.cloud = "Cloud" +origin.cloudHint = "Stored on the Stirling server" +origin.local = "Local" +origin.localHint = "Only stored in this browser" +origin.shared = "Shared" +origin.sharedHint = "Shared with you via link" +sort.modifiedAsc = "Oldest first" +sort.modifiedDesc = "Recent first" +sort.nameAsc = "Name A→Z" +sort.nameDesc = "Name Z→A" +sort.sizeAsc = "Smallest first" +sort.sizeDesc = "Largest first" +syncError.client = "Folder sync failed." +syncError.network = "Could not reach the server." +syncError.server = "Server error during folder sync." +tabName.local = "Local" +tabName.recent = "Recent" +tabName.shared = "Shared with me" +tabName.sharedByMe = "Shared by me" +tabs.all = "All" +tabs.ariaLabel = "File views" +tabs.cloud = "Cloud" +tabs.local = "Local" +tabs.recent = "Recent" +tabs.shared = "Shared with me" +tabs.sharedByMe = "Shared by me" +treeMenu.actions = "Folder actions for {{name}}" +treeMenu.collapse = "Collapse folder" +treeMenu.delete = "Delete folder" +treeMenu.expand = "Expand folder" +treeMenu.newSubfolder = "New subfolder" +treeMenu.rename = "Rename" +typeFilter.allTypes = "All types" +typeFilter.label = "Filter by type" +viewMode.grid = "Grid view" +viewMode.label = "View mode" +viewMode.list = "List view" + [fileToPDF] credit = "This service uses LibreOffice and Unoconv for file conversion." header = "Convert any file to PDF" @@ -4904,7 +5179,7 @@ toolInterface = "This is the Crop tool interface. As you can se viewer = "The Viewer lets you read and annotate your PDFs." viewSwitcher = "Use these controls to select how you want to view your PDFs." workbench = "This is the Workbench - the main area where you view and edit your PDFs." -wrapUp = "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help." +wrapUp = "You're all set! You can replay this tour anytime - just open Settings and find it here in the Tours section under Help." [onboarding.buttons] back = "Back" @@ -5244,9 +5519,9 @@ description = "Use n in formulas for patterns." title = "Mathematical Functions" [pageSelection.tooltip.operators] -and = "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)" -comma = "Comma: , or | — combine selections (e.g., 1-10, 20)" -not = "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" +and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" +comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" +not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" text = "AND has higher precedence than comma. NOT applies within the document range." title = "Operators" @@ -5434,7 +5709,7 @@ modified = "Edited" unsaved = "Edited" [pdfTextEditor.disclaimer] -alpha = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." +alpha = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." heading = "Preview Limitations" previewVariance = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible." textFocus = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here." @@ -5512,7 +5787,7 @@ paragraph = "Paragraph page" sparse = "Sparse text" [pdfTextEditor.tooltip.alpha] -text = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." +text = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." title = "Alpha Viewer" [pdfTextEditor.tooltip.header] @@ -6569,51 +6844,6 @@ title = "High Contrast" text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions." title = "Invert All Colours" -[workbenchBar] -annotations = "Annotations" -applyRedactionsFirst = "Apply redactions first" -closeAll = "Close All Files" -closePdf = "Close PDF" -closeSelected = "Close Selected Files" -deleteSelected = "Delete Selected Pages" -deselectAll = "Deselect All" -downloadAll = "Download All" -downloadSelected = "Download Selected Files" -draw = "Draw" -exitRedaction = "Exit Redaction Mode" -exportAll = "Export PDF" -exportSelected = "Export Selected Pages" -formFill = "Fill Form" -language = "Language" -panMode = "Pan Mode" -print = "Print PDF" -readAloud = "Read Aloud" -readAloudLanguage = "Language" -readAloudSpeed = "Speed" -redact = "Redact" -rotateLeft = "Rotate Left" -rotateRight = "Rotate Right" -ruler = "Ruler / Measure" -save = "Save" -saveAll = "Save All" -saveAs = "Save As" -saveChanges = "Save Changes" -search = "Search PDF" -selectAll = "Select All" -selectByNumber = "Select by Page Numbers" -selectLanguage = "Select language" -share = "Share" -toggleAnnotations = "Toggle Annotations Visibility" -toggleAttachments = "Toggle Attachments" -toggleBookmarks = "Toggle Bookmarks" -toggleComments = "Comments" -toggleLayers = "Toggle Layers" -toggleSidebar = "Toggle Sidebar" -toggleTheme = "Toggle Theme" -activeFiles = "Active Files" -multiTool = "Multi-Tool" -viewer = "Viewer" - [rotate] rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" @@ -6752,7 +6982,7 @@ useCase2 = "Split flatbed batches into separate files" useCase3 = "Break collages into individual photos" useCase4 = "Pull photos from documents" whatThisDoes = "What this does" -whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping." +whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image-no manual cropping." whenToUse = "When to use" [search] @@ -6907,6 +7137,20 @@ auto = "Auto" fitPage = "Fit page" fitWidth = "Fit width" +[settings.help] +label = "Tours" +title = "Help" + +[settings.help.adminTour] +description = "Explore team management, system settings, and enterprise features." +start = "Start" +title = "Admin Tour" + +[settings.help.toolsTour] +description = "Walk through uploading files, picking a tool, and reviewing results." +start = "Start" +title = "Tools Tour" + [settings.hotkeys] capturing = "Press keys… (Esc to cancel)" change = "Change shortcut" @@ -7038,20 +7282,6 @@ title = "Team" enableLoginFirst = "Enable login mode first" requiresEnterprise = "Requires Enterprise license" -[settings.help] -label = "Tours" -title = "Help" - -[settings.help.adminTour] -description = "Explore team management, system settings, and enterprise features." -start = "Start" -title = "Admin Tour" - -[settings.help.toolsTour] -description = "Walk through uploading files, picking a tool, and reviewing results." -start = "Start" -title = "Tools Tour" - [settings.workspace] people = "People" teams = "Teams" @@ -7730,6 +7960,7 @@ bulkTitle = "Upload checked files" description = "This uploads the current file to server storage for your own access." errorTitle = "Upload failed" failure = "Upload failed. Please check your login and storage settings." +featureDisabled = "Saving to the server isn't enabled on this server." fileCount = "{{count}} files" fileLabel = "File" hint = "Public links and access modes are controlled by your server settings." @@ -7903,13 +8134,18 @@ viewerMode = "Switch to the file editor to add multiple files." [toolPanel] allTools = "All tools" alpha = "Alpha" +backToAllTools = "Back to all tools" +backToDefault = "Back" backToTools = "Back to tools" collapse = "Collapse panel" comingSoon = "Coming soon:" expand = "Expand panel" +goBack = "Go back" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" search = "Search tools" +toolsHeader = "Tools" +viewAllTools = "View all tools" [toolPanel.fullscreen] comingSoon = "Coming soon:" @@ -8249,8 +8485,8 @@ csvStats = "{{rows}} rows · {{columns}} columns · {{size}}" emptyFile = "Empty file" fileTypeBadge = "{{type}} File" htmlPreview = "HTML preview" -htmlPreviewWarning = "HTML preview — external resources may not load · {{size}}" -invalidJson = "Invalid JSON — showing raw content" +htmlPreviewWarning = "HTML preview - external resources may not load · {{size}}" +invalidJson = "Invalid JSON - showing raw content" lineNumbers = "Line numbers" loading = "Loading..." renderMarkdown = "Render markdown" @@ -8460,6 +8696,50 @@ bullet3 = "Image will be resized to fit signature area" description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo." title = "Upload Signature Image" +[workbenchBar] +activeFiles = "Active Files" +annotations = "Annotations" +applyRedactionsFirst = "Apply redactions first" +closeAll = "Close All Files" +closePdf = "Close PDF" +closeSelected = "Close Selected Files" +deleteSelected = "Delete Selected Pages" +deselectAll = "Deselect All" +downloadAll = "Download All" +downloadSelected = "Download Selected Files" +draw = "Draw" +exitRedaction = "Exit Redaction Mode" +exportAll = "Export PDF" +exportSelected = "Export Selected Pages" +formFill = "Fill Form" +language = "Language" +multiTool = "Multi-Tool" +panMode = "Pan Mode" +print = "Print PDF" +readAloud = "Read Aloud" +readAloudLanguage = "Language" +readAloudSpeed = "Speed" +redact = "Redact" +rotateLeft = "Rotate Left" +rotateRight = "Rotate Right" +ruler = "Ruler / Measure" +save = "Save" +saveAll = "Save All" +saveAs = "Save As" +saveChanges = "Save Changes" +search = "Search PDF" +selectAll = "Select All" +selectByNumber = "Select by Page Numbers" +selectLanguage = "Select language" +share = "Share" +toggleAnnotations = "Toggle Annotations Visibility" +toggleAttachments = "Toggle Attachments" +toggleBookmarks = "Toggle Bookmarks" +toggleComments = "Comments" +toggleLayers = "Toggle Layers" +toggleSidebar = "Toggle Sidebar" +toggleTheme = "Toggle Theme" +viewer = "Viewer" [workspace] title = "Workspace" diff --git a/frontend/editor/scripts/build-universal-mac-jre.sh b/frontend/editor/scripts/build-universal-mac-jre.sh index a8fa6ce4d2..baf66016bb 100755 --- a/frontend/editor/scripts/build-universal-mac-jre.sh +++ b/frontend/editor/scripts/build-universal-mac-jre.sh @@ -11,7 +11,7 @@ # X64_JAVA_HOME path to an x86_64 JDK with jmods/ # JLINK_MODULES comma-separated module list (matches desktop.yml) # OUTPUT_DIR target directory (will be wiped); defaults to -# frontend/src-tauri/runtime/jre +# frontend/editor/src-tauri/runtime/jre set -euo pipefail @@ -19,7 +19,7 @@ set -euo pipefail : "${X64_JAVA_HOME:?X64_JAVA_HOME must be set}" : "${JLINK_MODULES:?JLINK_MODULES must be set}" -OUTPUT_DIR="${OUTPUT_DIR:-frontend/src-tauri/runtime/jre}" +OUTPUT_DIR="${OUTPUT_DIR:-frontend/editor/src-tauri/runtime/jre}" if [[ "$(uname -s)" != "Darwin" ]]; then echo "build-universal-mac-jre.sh only runs on macOS" >&2 diff --git a/frontend/editor/scripts/setup-env.mts b/frontend/editor/scripts/setup-env.mts index c955ba3aee..553ba181ed 100644 --- a/frontend/editor/scripts/setup-env.mts +++ b/frontend/editor/scripts/setup-env.mts @@ -6,9 +6,18 @@ * Vite automatically layers these `.local` files on top of the committed ones. * * Usage: - * tsx scripts/setup-env.ts # ensures .env.local - * tsx scripts/setup-env.ts --desktop # also ensures .env.desktop.local - * tsx scripts/setup-env.ts --saas # also ensures .env.saas.local + * tsx scripts/setup-env.mts # ensures .env.local + * tsx scripts/setup-env.mts --desktop # also ensures .env.desktop.local + * tsx scripts/setup-env.mts --saas # also ensures .env.saas.local + * + * Why .mts (and not .ts)? + * This script needs `import.meta.url` to resolve paths relative to itself, + * because Task invokes it from the workspace root (frontend/) but the .env + * files live one level deeper at frontend/editor/. `import.meta` is only + * valid in ESM output; `editor/scripts/tsconfig.json` extends the editor + * tsconfig which uses `module: node16`, treating plain .ts as CommonJS + * (TS1470 error on `import.meta`). The .mts extension explicitly marks + * the file as ESM, which tsx already runs at runtime anyway. */ import { existsSync, writeFileSync } from "fs"; @@ -17,8 +26,6 @@ import { fileURLToPath } from "url"; // .env files live next to the editor's vite.config.ts (frontend/editor/). // Resolve relative to this script regardless of where the build was invoked. -// `import.meta.dirname` would be tidier but isn't available under tsx's CJS -// transpilation today, so go via fileURLToPath for portability. const scriptDir = dirname(fileURLToPath(import.meta.url)); const root = resolve(scriptDir, ".."); const args = process.argv.slice(2); diff --git a/frontend/editor/src-tauri/capabilities/default.json b/frontend/editor/src-tauri/capabilities/default.json index 9259e4543f..98df1fef9b 100644 --- a/frontend/editor/src-tauri/capabilities/default.json +++ b/frontend/editor/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main"], + "windows": ["main", "main-*"], "permissions": [ "core:default", "core:window:allow-destroy", diff --git a/frontend/editor/src-tauri/src/commands/auth.rs b/frontend/editor/src-tauri/src/commands/auth.rs index 528f6d8de5..28d2ddd376 100644 --- a/frontend/editor/src-tauri/src/commands/auth.rs +++ b/frontend/editor/src-tauri/src/commands/auth.rs @@ -1,7 +1,7 @@ use keyring::{Entry}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; -use tauri::AppHandle; +use tauri::{AppHandle, Runtime}; use tauri_plugin_store::StoreExt; use tiny_http::{Response, Server}; use sha2::{Sha256, Digest}; @@ -18,6 +18,9 @@ const KEYRING_SERVICE: &str = "stirling-pdf"; const KEYRING_TOKEN_KEY: &str = "auth-token"; const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token"; +pub const TOKENS_STORE_FILE_FOR_TESTS: &str = TOKENS_STORE_FILE; +pub const REFRESH_TOKEN_STORE_KEY_FOR_TESTS: &str = REFRESH_TOKEN_STORE_KEY; + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserInfo { pub username: String, @@ -25,6 +28,9 @@ pub struct UserInfo { } fn get_keyring_entry() -> Result { + if std::env::var("STIRLING_PDF_TEST_FORCE_AUTH_KEYRING_FAIL").is_ok() { + return Err("Forced keyring failure for tests".to_string()); + } log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY); let entry = Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY) .map_err(|e| { @@ -35,11 +41,28 @@ fn get_keyring_entry() -> Result { Ok(entry) } -fn get_refresh_token_keyring_entry() -> Result { +pub fn get_refresh_token_keyring_entry() -> Result { + if std::env::var("STIRLING_PDF_TEST_FORCE_REFRESH_KEYRING_FAIL").is_ok() { + return Err("Forced keyring failure for tests".to_string()); + } Entry::new(KEYRING_SERVICE, KEYRING_REFRESH_TOKEN_KEY) .map_err(|e| format!("Failed to access keyring: {}", e)) } +/// Returns Ok(true) when keyring round-tripped the token; Ok(false) otherwise (caller must fall through to disk). +pub fn try_save_refresh_token_to_keyring(token: &str) -> Result { + match get_refresh_token_keyring_entry() { + Ok(entry) => match entry.set_password(token) { + Ok(_) => match entry.get_password() { + Ok(saved) if saved == token => Ok(true), + _ => Ok(false), + }, + Err(_) => Ok(false), + }, + Err(_) => Ok(false), + } +} + #[tauri::command] pub async fn save_auth_token(app_handle: AppHandle, token: String) -> Result<(), String> { let trimmed = token.trim(); @@ -172,31 +195,30 @@ pub async fn clear_auth_token(app_handle: AppHandle) -> Result<(), String> { } #[tauri::command] -pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> { +pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> { log::info!("Saving refresh token - trying keyring first"); - let entry = get_refresh_token_keyring_entry()?; - - // Try keyring (works in production with code signing) - match entry.set_password(&token) { - Ok(_) => { - // Verify it persists (fails in unsigned dev builds) - match entry.get_password() { - Ok(saved) if saved == token => { - log::info!("✅ Refresh token saved to keyring (production mode)"); - return Ok(()); - } - _ => { - log::info!("Keyring doesn't persist - using Tauri Store fallback (dev mode)"); + match try_save_refresh_token_to_keyring(&token) { + Ok(true) => { + // Clear any stale fallback copy so the keyring stays authoritative. + if let Ok(store) = app_handle.store(TOKENS_STORE_FILE) { + if store.get(REFRESH_TOKEN_STORE_KEY).is_some() { + store.delete(REFRESH_TOKEN_STORE_KEY); + let _ = store.save(); } } + log::info!("Refresh token saved to keyring"); + return Ok(()); + } + Ok(false) => { + log::info!("Keyring did not persist refresh token - using Tauri Store fallback"); } Err(e) => { - log::info!("Keyring failed: {} - using Tauri Store fallback", e); + log::info!("Keyring error for refresh token: {} - using Tauri Store fallback", e); } } - // Fallback to Tauri Store (dev mode without code signing) + // Fallback to Tauri Store (dev mode without code signing, or restricted environments) let store = app_handle .store(TOKENS_STORE_FILE) .map_err(|e| format!("Failed to access tokens store: {}", e))?; @@ -211,28 +233,33 @@ pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result< .save() .map_err(|e| format!("Failed to save tokens store: {}", e))?; - log::info!("✅ Refresh token saved to Tauri Store (fallback)"); + log::info!("Refresh token saved to Tauri Store (fallback)"); Ok(()) } #[tauri::command] -pub async fn get_refresh_token(app_handle: AppHandle) -> Result, String> { - // Try keyring first (production) - let entry = get_refresh_token_keyring_entry()?; - match entry.get_password() { - Ok(token) => { - log::info!("✅ Refresh token retrieved from keyring"); - return Ok(Some(token)); - } - Err(keyring::Error::NoEntry) => { - log::debug!("No token in keyring, trying Tauri Store"); - } +pub async fn get_refresh_token(app_handle: AppHandle) -> Result, String> { + // Try keyring first (production / unrestricted environments). Any failure - + // including entry creation - falls through to the Tauri Store fallback below. + match get_refresh_token_keyring_entry() { + Ok(entry) => match entry.get_password() { + Ok(token) => { + log::info!("Refresh token retrieved from keyring"); + return Ok(Some(token)); + } + Err(keyring::Error::NoEntry) => { + log::debug!("No refresh token in keyring, trying Tauri Store"); + } + Err(e) => { + log::warn!("Keyring error reading refresh token: {} - trying Tauri Store", e); + } + }, Err(e) => { - log::warn!("Keyring error: {} - trying Tauri Store", e); + log::warn!("Keyring entry unavailable for refresh token: {} - trying Tauri Store", e); } } - // Fallback to Tauri Store (dev) + // Fallback to Tauri Store (dev or restricted environments) let store = app_handle .store(TOKENS_STORE_FILE) .map_err(|e| format!("Failed to access tokens store: {}", e))?; @@ -242,7 +269,7 @@ pub async fn get_refresh_token(app_handle: AppHandle) -> Result, .and_then(|v| serde_json::from_value(v.clone()).ok()); if token.is_some() { - log::info!("✅ Refresh token retrieved from Tauri Store"); + log::info!("Refresh token retrieved from Tauri Store"); } else { log::info!("No refresh token found"); } @@ -251,18 +278,26 @@ pub async fn get_refresh_token(app_handle: AppHandle) -> Result, } #[tauri::command] -pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> { +pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> { log::info!("Clearing refresh token from all storage"); - // Clear from keyring - let entry = get_refresh_token_keyring_entry()?; - match entry.delete_credential() { - Ok(_) => log::info!("Cleared from keyring"), - Err(keyring::Error::NoEntry) => log::debug!("Not in keyring"), - Err(e) => log::warn!("Keyring clear error: {}", e), + // Best-effort keyring clear; never blocks disk clear. + match get_refresh_token_keyring_entry() { + Ok(entry) => match entry.delete_credential() { + Ok(_) | Err(keyring::Error::NoEntry) => {} + Err(e) => { + log::warn!("Failed to delete keyring refresh token: {}. Attempting overwrite with empty token.", e); + if let Err(e2) = entry.set_password("") { + log::warn!("Failed to overwrite keyring refresh token: {}", e2); + } + } + }, + Err(e) => { + log::warn!("Keyring entry unavailable while clearing refresh token: {} - clearing Tauri Store fallback only", e); + } } - // Clear from Tauri Store + // Clear from Tauri Store fallback let store = app_handle .store(TOKENS_STORE_FILE) .map_err(|e| format!("Failed to access tokens store: {}", e))?; @@ -273,7 +308,7 @@ pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> { .save() .map_err(|e| format!("Failed to save tokens store: {}", e))?; - log::info!("✅ Refresh token cleared"); + log::info!("Refresh token cleared"); Ok(()) } diff --git a/frontend/editor/src-tauri/src/commands/mod.rs b/frontend/editor/src-tauri/src/commands/mod.rs index 27720de256..495b38aafb 100644 --- a/frontend/editor/src-tauri/src/commands/mod.rs +++ b/frontend/editor/src-tauri/src/commands/mod.rs @@ -5,9 +5,18 @@ pub mod auth; pub mod default_app; pub mod platform; pub mod print; +pub mod window; pub use backend::{cleanup_backend, get_backend_port, start_backend}; pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files}; +pub use window::{ + forward_files_to_window, + open_files_in_new_window, + open_in_new_window, + pop_window_file_ids, + target_window_label, + MAIN_WINDOW_LABEL, +}; pub use connection::{ get_connection_config, is_first_launch, diff --git a/frontend/editor/src-tauri/src/commands/window.rs b/frontend/editor/src-tauri/src/commands/window.rs new file mode 100644 index 0000000000..761d47aeea --- /dev/null +++ b/frontend/editor/src-tauri/src/commands/window.rs @@ -0,0 +1,216 @@ +use crate::commands::files::add_opened_file; +use crate::utils::add_log; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +// The primary window created from tauri.conf.json. +pub const MAIN_WINDOW_LABEL: &str = "main"; + +static NEXT_WINDOW_ID: AtomicU32 = AtomicU32::new(2); + +// Per-window queues of stored-file IDs waiting to be opened. Unlike disk paths +// (which use the global OPENED_FILES queue), these reference files already in +// the shared IndexedDB store, so a "new window" opened from the My Files page +// loads the same file by reference. Keyed by the new window's label. +static PENDING_FILE_IDS: Mutex>>> = Mutex::new(None); + +fn next_window_label() -> String { + let id = NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst); + format!("main-{}", id) +} + +fn queue_file_ids(label: &str, ids: Vec) { + let mut guard = PENDING_FILE_IDS.lock().unwrap(); + let map = guard.get_or_insert_with(HashMap::new); + map.entry(label.to_string()).or_default().extend(ids); +} + +// Shared window builder: every Stirling window must use identical WebView2 +// browser args so they can share one user-data folder (see the note below), +// so all spawn paths funnel through here. +fn build_window(app: &AppHandle, label: &str, url: &str) -> Result { + let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into())) + .title("Stirling-PDF") + .inner_size(1280.0, 800.0) + // Below this width the file manager collapses to its mobile layout, + // so keep new windows above the breakpoint. + .min_inner_size(1030.0, 600.0) + .resizable(true); + + // WebView2 (Windows only) requires every webview sharing a user-data folder + // to use identical additional_browser_args. wry's behaviour + // (webview2/mod.rs:294): when the user provides args it uses them as-is and + // does NOT prepend its own default `--disable-features=msWebOOUI,...`. So the + // main window's actual args are EXACTLY what tauri.conf.json declares - + // nothing more. We mirror that string byte-for-byte so windows share one data + // dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and + // Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only. + #[cfg(target_os = "windows")] + let builder = + builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature"); + + builder.build().map_err(|e| e.to_string()) +} + +// Run `work` on the main thread and await its result. WebView2 on Windows +// refuses to create a webview off the main thread (HRESULT 0x8007139F), but +// Tauri command handlers run on a worker thread - so any window creation has to +// hop over first. Centralised here so every command does it the same way. +async fn run_on_main_thread_result(app: &AppHandle, work: F) -> Result +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let _ = tx.send(work()); + }) + .map_err(|e| e.to_string())?; + rx.await.map_err(|e| e.to_string()) +} + +// Spawn a new webview window in the same Tauri process. +// The backend stays single; only the frontend is duplicated. +// If `paths` is non-empty, they're enqueued under the new window's label, +// so the React app pops them on mount just like a fresh launch with a file. +fn spawn_new_window(app: &AppHandle, paths: Vec) -> Result { + let label = next_window_label(); + + for path in &paths { + add_opened_file(path.clone()); + } + + match build_window(app, &label, "/") { + Ok(window) => { + add_log(format!( + "🪟 Spawned new window '{}' with {} initial file(s)", + label, + paths.len() + )); + // The new window pops the shared queue on mount, so the files are + // already waiting for it. We target the emit at this window only + // (not a broadcast) so already-open windows don't race to pop them. + if !paths.is_empty() { + let _ = window.emit_to(label.as_str(), "files-changed", ()); + } + Ok(label) + } + Err(err) => { + add_log(format!( + "❌ Failed to spawn new window '{}': {}", + label, err + )); + Err(err) + } + } +} + +#[tauri::command] +pub async fn open_in_new_window(app: AppHandle, paths: Vec) -> Result { + let valid_paths: Vec = paths + .into_iter() + .filter(|p| { + let exists = std::path::Path::new(p).exists(); + if !exists { + add_log(format!( + "⚠️ Ignoring non-existent path for new window: {}", + p + )); + } + exists + }) + .collect(); + + let app_clone = app.clone(); + run_on_main_thread_result(&app, move || spawn_new_window(&app_clone, valid_paths)).await? +} + +// Open already-stored files (by IndexedDB id) in a fresh window. Used by the +// "Open in new window" action on the My Files page. The ids are queued under +// the new window's label; the new window pops them on mount and loads them from +// the shared store into its workspace. +#[tauri::command] +pub async fn open_files_in_new_window( + app: AppHandle, + file_ids: Vec, +) -> Result { + let label = next_window_label(); + let app_clone = app.clone(); + run_on_main_thread_result(&app, move || { + build_window(&app_clone, &label, "/").map(|window| { + let count = file_ids.len(); + // Queue the ids only after the window is created, so a failed build + // doesn't leave orphaned ids under a label no window will consume. + queue_file_ids(&label, file_ids); + add_log(format!( + "🪟 Spawned new window '{}' for {} stored file(s)", + label, count + )); + // The new window also pops on mount; this emit is a nudge in case it + // mounted before the ids were queued. + let _ = window.emit_to(label.as_str(), "window-files-ready", ()); + label.clone() + }) + }) + .await? +} + +// Pop (return and clear) the stored-file ids queued for the calling window. +#[tauri::command] +pub async fn pop_window_file_ids(window: WebviewWindow) -> Result, String> { + let label = window.label().to_string(); + let ids = { + let mut guard = PENDING_FILE_IDS.lock().unwrap(); + guard + .as_mut() + .and_then(|map| map.remove(&label)) + .unwrap_or_default() + }; + if !ids.is_empty() { + add_log(format!( + "📂 Returning {} stored file id(s) for window '{}'", + ids.len(), + label + )); + } + Ok(ids) +} + +// Pick the best existing window to receive an opened file: the focused one, +// else the main window, else any open window. Returns None only if there are +// no windows at all. Used so file-opens (file association, "open with") land in +// the window the user is actually looking at, and still work if the original +// "main" window has been closed. +pub fn target_window_label(app: &AppHandle) -> Option { + let windows = app.webview_windows(); + if let Some((label, _)) = windows + .iter() + .find(|(_, w)| w.is_focused().unwrap_or(false)) + { + return Some(label.clone()); + } + if windows.contains_key(MAIN_WINDOW_LABEL) { + return Some(MAIN_WINDOW_LABEL.to_string()); + } + windows.keys().next().cloned() +} + +// Add files to the shared queue and notify a specific window to consume them. +// Used by drag-drop, the macOS open event, and the second-instance callback +// (when --new-window is NOT set). The emit is targeted at `label` so only that +// window pops the queue - other windows ignore it and keep their own files. +pub fn forward_files_to_window(app: &AppHandle, label: &str, paths: Vec) { + for path in &paths { + add_opened_file(path.clone()); + } + if let Some(window) = app.get_webview_window(label) { + let _ = app.emit_to(label, "files-changed", ()); + let _ = window.set_focus(); + let _ = window.unminimize(); + } else { + // Target window is gone; let any window pick the files up. + let _ = app.emit("files-changed", ()); + } +} diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index 08986b2d37..aa73dc8a81 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; mod utils; -mod commands; +pub mod commands; mod state; use commands::{ @@ -11,12 +11,16 @@ use commands::{ clear_opened_files, clear_refresh_token, clear_user_info, + forward_files_to_window, is_default_pdf_handler, get_auth_token, get_backend_port, get_connection_config, get_opened_files, + open_files_in_new_window, + open_in_new_window, pop_opened_files, + pop_window_file_ids, get_refresh_token, get_user_info, is_first_launch, @@ -31,6 +35,8 @@ use commands::{ print_pdf_file_native, start_backend, start_oauth_login, + target_window_label, + MAIN_WINDOW_LABEL, }; use commands::connection::apply_provisioning_if_present; use state::connection_state::AppConnectionState; @@ -47,6 +53,16 @@ fn dispatch_deep_link(app: &AppHandle, url: &str) { } } +// Extract existing file paths from CLI args (skips the executable name). +fn parse_launch_files(args: &[String]) -> Vec { + args + .iter() + .skip(1) + .filter(|arg| std::path::Path::new(arg).exists()) + .cloned() + .collect() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -66,38 +82,33 @@ pub fn run() { .plugin(tauri_plugin_window_state::Builder::default().build()) .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - // This callback runs when a second instance tries to start + // Runs in the existing instance when a second launch is attempted + // (e.g. "open with" / double-click while the app is running). add_log(format!("📂 Second instance detected with args: {:?}", args)); - // Scan args for PDF files (skip first arg which is the executable) - for arg in args.iter().skip(1) { - if std::path::Path::new(arg).exists() { - add_log(format!("📂 Forwarding file to existing instance: {}", arg)); + let files = parse_launch_files(&args); + // Route to the window the user is in (focused -> main -> any) so opens + // consolidate into one window instead of spawning a new one. + let label = target_window_label(app).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string()); - // Store file for later retrieval (in case frontend isn't ready yet) - add_opened_file(arg.clone()); - - // Bring the existing window to front - if let Some(window) = app.get_webview_window("main") { - let _ = window.set_focus(); - let _ = window.unminimize(); - } - } + if !files.is_empty() { + add_log(format!("📂 Forwarding {} file(s) to existing window '{}'", files.len(), label)); + forward_files_to_window(app, &label, files); + } else if let Some(window) = app.get_webview_window(&label) { + // No files: just bring the app to the front. + let _ = window.set_focus(); + let _ = window.unminimize(); } - - // Emit a generic notification that files were added (frontend will re-read storage) - let _ = app.emit("files-changed", ()); })) .setup(|app| { add_log("🚀 Tauri app setup started".to_string()); - // Process command line arguments on first launch + // Files passed on the command line at first launch load into the main + // window once the frontend mounts. let args: Vec = std::env::args().collect(); - for arg in args.iter().skip(1) { - if std::path::Path::new(arg).exists() { - add_log(format!("📂 Initial file from command line: {}", arg)); - add_opened_file(arg.clone()); - } + for path in parse_launch_files(&args) { + add_log(format!("📂 Initial file from command line: {}", path)); + add_opened_file(path); } { @@ -147,6 +158,9 @@ pub fn run() { get_opened_files, pop_opened_files, clear_opened_files, + open_in_new_window, + open_files_in_new_window, + pop_window_file_ids, get_tauri_logs, get_connection_config, set_connection_mode, @@ -183,26 +197,19 @@ pub fn run() { // Don't cleanup here - let JavaScript handler prevent close if needed // Backend cleanup happens in ExitRequested when window actually closes } - RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => { + RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), label, .. } => { use tauri::DragDropEvent; - match drag_drop_event { - DragDropEvent::Drop { paths, .. } => { - add_log(format!("📂 Files dropped: {:?}", paths)); - let mut added_files = false; + if let DragDropEvent::Drop { paths, .. } = drag_drop_event { + add_log(format!("📂 Files dropped on window '{}': {:?}", label, paths)); + let file_paths: Vec = paths + .iter() + .filter_map(|p| p.to_str().map(|s| s.to_string())) + .collect(); - for path in paths { - if let Some(path_str) = path.to_str() { - add_log(format!("📂 Processing dropped file: {}", path_str)); - add_opened_file(path_str.to_string()); - added_files = true; - } - } - - if added_files { - let _ = app_handle.emit("files-changed", ()); - } + // Route to the window the file was actually dropped on. + if !file_paths.is_empty() { + forward_files_to_window(app_handle, &label, file_paths); } - _ => {} } } #[cfg(target_os = "macos")] @@ -210,30 +217,29 @@ pub fn run() { use urlencoding::decode; add_log(format!("📂 Tauri file opened event: {:?}", urls)); - let mut added_files = false; - - for url in urls { - let url_str = url.as_str(); - if url_str.starts_with("file://") { - let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str); - + let file_paths: Vec = urls + .iter() + .filter_map(|url| { + let url_str = url.as_str(); + if !url_str.starts_with("file://") { + return None; + } + let encoded = url_str.strip_prefix("file://").unwrap_or(url_str); // Decode URL-encoded characters (%20 -> space, etc.) - let file_path = match decode(encoded_path) { - Ok(decoded) => decoded.into_owned(), + match decode(encoded) { + Ok(decoded) => Some(decoded.into_owned()), Err(e) => { - add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e)); - encoded_path.to_string() // Fallback to encoded path + add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded, e)); + Some(encoded.to_string()) } - }; + } + }) + .collect(); - add_log(format!("📂 Processing opened file: {}", file_path)); - add_opened_file(file_path); - added_files = true; - } - } - // Emit a generic notification that files were added (frontend will re-read storage) - if added_files { - let _ = app_handle.emit("files-changed", ()); + if !file_paths.is_empty() { + // Route to the window the user is in (focused -> main -> any). + let label = target_window_label(app_handle).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string()); + forward_files_to_window(app_handle, &label, file_paths); } } _ => { diff --git a/frontend/editor/src-tauri/tests/refresh_token_fallback.rs b/frontend/editor/src-tauri/tests/refresh_token_fallback.rs new file mode 100644 index 0000000000..8172e792d9 --- /dev/null +++ b/frontend/editor/src-tauri/tests/refresh_token_fallback.rs @@ -0,0 +1,94 @@ +//! Asserts the refresh-token keyring helper falls through (Ok(false)) +//! instead of bailing (Err) when keyring access fails. This was the +//! original login-loop bug. + +use app_lib::commands::auth::{ + get_refresh_token_keyring_entry, try_save_refresh_token_to_keyring, + REFRESH_TOKEN_STORE_KEY_FOR_TESTS, TOKENS_STORE_FILE_FOR_TESTS, +}; +use serde_json::json; + +const ENV_FORCE_FAIL: &str = "STIRLING_PDF_TEST_FORCE_REFRESH_KEYRING_FAIL"; + +// Single test fn — env var is process-global; parallel tests would race. +#[test] +fn refresh_token_fallback_proof() { + // ===== Step 1: env-var injection forces keyring entry-creation to Err ===== + std::env::set_var(ENV_FORCE_FAIL, "1"); + + let entry_result = get_refresh_token_keyring_entry(); + assert!( + entry_result.is_err(), + "with {} set, get_refresh_token_keyring_entry must return Err", + ENV_FORCE_FAIL + ); + let err_msg = entry_result.err().unwrap(); + assert!( + err_msg.contains("Forced keyring failure for tests"), + "expected forced-failure marker; got: {}", + err_msg + ); + + // ===== Step 2: the bug-fix proof ===== + // Original bug: `let entry = get_refresh_token_keyring_entry()?;` would + // propagate Err and short-circuit save_refresh_token, never reaching the + // disk-store fallback. Fixed behaviour: the same keyring failure must be + // converted into a "fall through" signal (Ok(false)), NOT propagated as Err. + let outcome = try_save_refresh_token_to_keyring("test-token-xyz-456"); + assert!( + matches!(outcome, Ok(false)), + "REGRESSION: keyring entry-creation failure must convert to Ok(false) \ + so save_refresh_token falls through to the disk store. Got {:?}. \ + This means the function would short-circuit and the user's refresh \ + token would never be persisted - exactly the original login-loop bug.", + outcome + ); + + std::env::remove_var(ENV_FORCE_FAIL); + + // ===== Step 3: env-var unset does NOT carry the forced-failure marker ===== + let result_unset = get_refresh_token_keyring_entry(); + if let Err(e) = &result_unset { + assert!( + !e.contains("Forced keyring failure for tests"), + "with {} unset, error must NOT contain forced-failure marker; got: {}", + ENV_FORCE_FAIL, + e + ); + } + + // ===== Step 4: disk-fallback JSON round-trips ===== + // tauri_plugin_store writes via serde_json::to_vec_pretty(&HashMap). Reproduce that format and verify it round-trips, proving + // the file save_refresh_token's disk fallback writes is readable by + // get_refresh_token's disk fallback. + let tmp = std::env::temp_dir().join(format!( + "stirling_pdf_refresh_token_test_{}.json", + std::process::id() + )); + let token = "test-refresh-token-disk-roundtrip-abc-123"; + let mut map = std::collections::HashMap::::new(); + map.insert(REFRESH_TOKEN_STORE_KEY_FOR_TESTS.to_string(), json!(token)); + let bytes = serde_json::to_vec_pretty(&map).expect("serialize tokens map"); + std::fs::write(&tmp, &bytes).expect("write tokens file"); + + let read_bytes = std::fs::read(&tmp).expect("read tokens file"); + let read_map: std::collections::HashMap = + serde_json::from_slice(&read_bytes).expect("parse tokens json"); + let retrieved: Option = read_map + .get(REFRESH_TOKEN_STORE_KEY_FOR_TESTS) + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let _ = std::fs::remove_file(&tmp); + + assert_eq!( + retrieved, + Some(token.to_string()), + "disk-fallback JSON format must round-trip the refresh token (file: {})", + tmp.display() + ); + + // ===== Step 5: constants match production strings ===== + assert_eq!(TOKENS_STORE_FILE_FOR_TESTS, "tokens.json"); + assert_eq!(REFRESH_TOKEN_STORE_KEY_FOR_TESTS, "refresh_token"); +} diff --git a/frontend/editor/src-tauri/thumbnail-handler/README.md b/frontend/editor/src-tauri/thumbnail-handler/README.md index 3deffaab96..27c1ffdfc7 100644 --- a/frontend/editor/src-tauri/thumbnail-handler/README.md +++ b/frontend/editor/src-tauri/thumbnail-handler/README.md @@ -51,7 +51,7 @@ npm run tauri-build To build the DLL standalone: ```bash -cd frontend/src-tauri/thumbnail-handler +cd frontend/editor/src-tauri/thumbnail-handler cargo build --release # Output: target/release/stirling_thumbnail_handler.dll ``` diff --git a/frontend/editor/src/core/auth/UseSession.tsx b/frontend/editor/src/core/auth/UseSession.tsx index 01d9b94d11..8f9a44f52e 100644 --- a/frontend/editor/src/core/auth/UseSession.tsx +++ b/frontend/editor/src/core/auth/UseSession.tsx @@ -1,6 +1,19 @@ export interface AuthContextType { session: null; user: { id?: string; email?: string; [key: string]: unknown } | null; + /** + * Human-readable name to show in the UI for the current session. + * - A real identity (username/email/full_name) when the user is signed in. + * - A layer-specific placeholder (e.g. "Guest" in SaaS, "User" in + * proprietary) for anonymous sessions. + * - null only when there is no user object at all (signed-out, or core + * OSS with no auth context) - consumers can fall back to whatever + * makes sense in their build. + * + * Each layer derives this from its own native user shape - consumers + * should treat the resulting string as opaque display text. + */ + displayName: string | null; loading: boolean; error: Error | null; signOut: () => Promise; @@ -15,6 +28,7 @@ export function useAuth(): AuthContextType { return { session: null, user: null, + displayName: null, loading: false, error: null, signOut: async () => {}, diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 4f6f5566a5..3501658b98 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -32,6 +32,7 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets"; import AppConfigLoader from "@app/components/shared/AppConfigLoader"; import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; +import { FolderProvider } from "@app/contexts/FolderContext"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -125,39 +126,41 @@ export function AppProviders({ enableUrlSync={true} enablePersistence={true} > - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + diff --git a/frontend/editor/src/core/components/FileManager.tsx b/frontend/editor/src/core/components/FileManager.tsx index ea54093dcd..16cded856c 100644 --- a/frontend/editor/src/core/components/FileManager.tsx +++ b/frontend/editor/src/core/components/FileManager.tsx @@ -6,7 +6,6 @@ import type { FileId } from "@app/types/file"; import { useFileManager } from "@app/hooks/useFileManager"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; -import { Tool } from "@app/types/tool"; import MobileLayout from "@app/components/fileManager/MobileLayout"; import DesktopLayout from "@app/components/fileManager/DesktopLayout"; import DragOverlay from "@app/components/fileManager/DragOverlay"; @@ -20,8 +19,14 @@ import { loadScript } from "@app/utils/scriptLoader"; import { useAllFiles } from "@app/contexts/FileContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; +/** + * Structural prop: anything that exposes an optional `supportedFormats` + * string array. Both `Tool` (from `@app/types/tool`) and `ToolRegistryEntry` + * (from `@app/data/toolsTaxonomy`) satisfy this, so callers can pass either + * without an `as any` cast. + */ interface FileManagerProps { - selectedTool?: Tool | null; + selectedTool?: { supportedFormats?: string[] } | null; } const FileManager: React.FC = ({ selectedTool }) => { diff --git a/frontend/editor/src/core/components/agents/AgentsPanel.tsx b/frontend/editor/src/core/components/agents/AgentsPanel.tsx new file mode 100644 index 0000000000..282b4639b3 --- /dev/null +++ b/frontend/editor/src/core/components/agents/AgentsPanel.tsx @@ -0,0 +1,53 @@ +/** + * Core stubs for the right-rail Agents UI. + * + * The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx} + * and shadow these stubs via the {@code @app/*} alias cascade when the proprietary + * build is active. Core builds render nothing, so the right rail collapses to the + * tool list unchanged. + */ + +/** Whether the right rail should reserve space for agents UI. False in core. */ +export function useAgentsEnabled(): boolean { + return false; +} + +/** + * Whether the agent chat overlay is currently open. Core builds have no chat, + * so this always returns false. Proprietary builds bridge to the ChatContext. + * Used by {@code RightSidebar} so the fullscreen tool picker can yield to the + * chat overlay just like it yields to a selected tool. + */ +export function useAgentChatOpen(): boolean { + return false; +} + +/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */ +export function AgentsSection() { + return null; +} + +/** + * Icon-only agent button rendered in the collapsed (minimised) right rail. + * Returns null in core; proprietary renders the Stirling agent shortcut. + */ +export function AgentsCollapsedButton(_props: { onExpand: () => void }) { + return null; +} + +/** + * Full-rail chat overlay rendered inside {@code ToolPanel}. Covers the panel + * (including the search bar) when an agent conversation is active. + */ +export function AgentsChatOverlay() { + return null; +} + +/** + * Agents card rendered inside the fullscreen tool picker. Matches the visual + * language of the fullscreen category cards (gradient border, title, items). + * Returns null in core; proprietary renders the Stirling agent. + */ +export function AgentsFullscreenSection() { + return null; +} diff --git a/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx new file mode 100644 index 0000000000..e2d7edc438 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Button, + Checkbox, + Group, + Modal, + Stack, + Text, +} from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +import { FolderRecord } from "@app/types/folder"; + +interface DeleteFolderDialogProps { + opened: boolean; + folder: FolderRecord | null; + /** Number of files inside the folder (and subtree). */ + fileCount: number; + onClose: () => void; + /** Confirm; `deleteContents` is true when the user opted in to delete files. */ + onConfirm: (deleteContents: boolean) => void | Promise; +} + +export function DeleteFolderDialog({ + opened, + folder, + fileCount, + onClose, + onConfirm, +}: DeleteFolderDialogProps) { + const { t } = useTranslation(); + const [deleteContents, setDeleteContents] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setDeleteContents(false); + setSubmitting(false); + setError(null); + } + }, [opened]); + + if (!folder) return null; + + return ( + + + + {t("filesPage.deleteFolderBody", 'Delete folder "{{name}}"?', { + name: folder.name, + })} + + {fileCount > 0 && ( + setDeleteContents(e.currentTarget.checked)} + disabled={submitting} + label={t( + "filesPage.deleteFolderContents", + "Also delete {{count}} file(s) inside the folder", + { count: fileCount }, + )} + /> + )} + {fileCount > 0 && ( + + {deleteContents + ? t( + "filesPage.deleteFolderContentsWarning", + "Files will be permanently removed and cannot be recovered.", + ) + : t( + "filesPage.deleteFolderKeepHint", + "Files inside will be moved to All files.", + )} + + )} + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx new file mode 100644 index 0000000000..2608e4b30a --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -0,0 +1,680 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Badge, Button, Menu, Tooltip } from "@mantine/core"; +import CloseIcon from "@mui/icons-material/Close"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import HistoryIcon from "@mui/icons-material/History"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import LinkIcon from "@mui/icons-material/Link"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; + +import { FileId, ToolOperation } from "@app/types/file"; +import { ToolId } from "@app/types/toolId"; +import { FolderRecord } from "@app/types/folder"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; +import { + downloadFileFromStorage, + downloadMultipleFiles, +} from "@app/utils/downloadUtils"; +import ToolChain from "@app/components/shared/ToolChain"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; +import { fileStorage } from "@app/services/fileStorage"; + +interface FileDetailsPanelProps { + selectedFileIds: FileId[]; + fileMap: Map; + currentFolder: FolderRecord | null; + onClose: () => void; + onAddToWorkspace: (fileIds: FileId[]) => void; + onQuickView: (fileId: FileId) => void; + onMove: (fileIds: FileId[]) => void; + onRemove: (fileIds: FileId[]) => void; + /** Save to server; only shown when at least one selected file is local-only. */ + onSaveToServer?: (files: StirlingFileStub[]) => void; + /** When set, Save to server renders disabled with this tooltip (storage off). */ + saveToServerDisabledReason?: string | null; +} + +export function FileDetailsPanel({ + selectedFileIds, + fileMap, + currentFolder, + onClose, + onAddToWorkspace, + onQuickView, + onMove, + onRemove, + onSaveToServer, + saveToServerDisabledReason, +}: FileDetailsPanelProps) { + const { t } = useTranslation(); + const { sharingEnabled } = useSharingEnabled(); + const files = useMemo( + () => + selectedFileIds + .map((id) => fileMap.get(id)) + .filter((f): f is StirlingFileStub => Boolean(f)), + [selectedFileIds, fileMap], + ); + + // Hooks must run before any early return. + const [downloading, setDownloading] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); + // Version chain for the selected file; empty for v1 or multi-select. + const [versionChain, setVersionChain] = useState([]); + const singleFileForChain = files.length === 1 ? files[0] : null; + useEffect(() => { + if (!singleFileForChain) { + setVersionChain([]); + return; + } + let cancelled = false; + const rootId = (singleFileForChain.originalFileId ?? + singleFileForChain.id) as FileId; + fileStorage + .getHistoryChainStubs(rootId) + .then((chain) => { + if (!cancelled) setVersionChain(chain); + }) + .catch((err) => { + console.error("Failed to load version history", err); + if (!cancelled) setVersionChain([]); + }); + return () => { + cancelled = true; + }; + }, [singleFileForChain]); + + if (files.length === 0) { + return null; + } + + const single = files.length === 1 ? files[0]! : null; + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : ""; + // Files still needing a server upload; drives Save-to-server visibility. + const localOnlyFiles = files.filter((f) => f.remoteStorageId == null); + + const handleDownload = async () => { + setDownloading(true); + try { + if (single) { + await downloadFileFromStorage(single); + } else { + await downloadMultipleFiles(files); + } + } catch (err) { + console.error("Download failed", err); + } finally { + setDownloading(false); + } + }; + + return ( +

+ ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** Tool that produced `version` from `prior`; null for v1. */ +function deltaToolFor( + version: StirlingFileStub, + prior: StirlingFileStub | null, +): ToolOperation | null { + if (!prior) return null; + const priorLen = prior.toolHistory?.length ?? 0; + const curr = version.toolHistory ?? []; + return curr[priorLen] ?? null; +} + +interface VersionTimelineProps { + /** Chain sorted oldest-first. */ + chain: StirlingFileStub[]; + /** Currently selected version. */ + currentId: FileId; + onQuickView: (fileId: FileId) => void; + onAddToWorkspace: (fileIds: FileId[]) => void; + onRemove: (fileIds: FileId[]) => void; +} + +/** Version timeline with per-row tool deltas and collapse-when-long. */ +function VersionTimeline({ + chain, + currentId, + onQuickView, + onAddToWorkspace, + onRemove, +}: VersionTimelineProps) { + const { t } = useTranslation(); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [showAllCollapsed, setShowAllCollapsed] = useState(false); + + // Newest-first ordering. + const ordered = useMemo( + () => + [...chain].sort( + (a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1), + ), + [chain], + ); + + // Index by versionNumber for prior-version lookup. + const byVersionNumber = useMemo(() => { + const map = new Map(); + for (const v of chain) { + map.set(v.versionNumber ?? 1, v); + } + return map; + }, [chain]); + + // Collapse middle when long: 3 newest + ellipsis + 2 oldest. + const COLLAPSE_THRESHOLD = 6; + const collapsible = ordered.length > COLLAPSE_THRESHOLD; + type Row = + | { kind: "version"; version: StirlingFileStub } + | { + kind: "ellipsis"; + hidden: number; + }; + const rows: Row[] = useMemo(() => { + if (!collapsible || showAllCollapsed) { + return ordered.map((v) => ({ kind: "version", version: v }) as Row); + } + const head = ordered + .slice(0, 3) + .map((v) => ({ kind: "version", version: v }) as Row); + const tail = ordered + .slice(-2) + .map((v) => ({ kind: "version", version: v }) as Row); + const hidden = ordered.length - 5; + return [...head, { kind: "ellipsis", hidden }, ...tail]; + }, [collapsible, showAllCollapsed, ordered]); + + const toggleExpand = (id: FileId) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+ + {t("filesPage.field.versionHistory", "Version journey")} + + {t("filesPage.versionsCount", "{{count}} versions", { + count: ordered.length, + })} + +
+
    + {rows.map((row, idx) => { + const isLast = idx === rows.length - 1; + if (row.kind === "ellipsis") { + return ( +
  1. +
    + + {!isLast && ( + + )} +
    + +
  2. + ); + } + const v = row.version; + const isActive = v.id === currentId; + const isExpanded = expandedIds.has(v.id); + const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null; + const delta = deltaToolFor(v, prior); + return ( +
  3. +
    + + {!isLast && ( + + )} +
    +
    + +
    + {formatFileSize(v.size)} + {v.lastModified ? ( + <> + · + + {getFileDate({ lastModified: v.lastModified })} + + + ) : null} + {!isActive && ( + <> + + + + e.stopPropagation()} + > + + + + + } + onClick={() => onQuickView(v.id)} + > + {t("filesPage.viewVersion", "View this version")} + + } + onClick={() => onAddToWorkspace([v.id])} + > + {t( + "filesPage.openVersionInWorkspace", + "Open in workspace", + )} + + } + onClick={() => { + void downloadFileFromStorage(v); + }} + > + {t( + "filesPage.downloadVersion", + "Download this version", + )} + + + } + onClick={() => onRemove([v.id])} + > + {t( + "filesPage.removeVersion", + "Remove this version", + )} + + + + + )} +
    + {isExpanded && ( + // Filename + full cumulative tool chain. +
    + + {v.toolHistory && v.toolHistory.length > 0 && ( +
    + + {t( + "filesPage.field.toolHistoryAtVersion", + "Cumulative tool chain", + )} + + +
    + )} +
    + )} +
    +
  4. + ); + })} +
+ {collapsible && showAllCollapsed && ( + + )} +
+ ); +} + +/** Translated tool name via `home.{toolId}.title`. */ +function ToolLabel({ toolId }: { toolId: ToolId }) { + const { t } = useTranslation(); + return {t(`home.${toolId}.title`, toolId)}; +} diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx new file mode 100644 index 0000000000..eda15b7814 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -0,0 +1,1413 @@ +import React, { useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import FolderIcon from "@mui/icons-material/Folder"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import DeleteIcon from "@mui/icons-material/Delete"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; + +import { FileId } from "@app/types/file"; +import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; +import { useFolders } from "@app/contexts/FolderContext"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, + serialiseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { useDropTarget } from "@app/components/filesPage/useDropTarget"; +import { getFileOrigin } from "@app/components/filesPage/fileOrigin"; +import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge"; +import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; +import { findFolderIcon } from "@app/components/filesPage/folderIcons"; +import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; +import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; +import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; +import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; + +export type FilesPageViewMode = "grid" | "list"; + +export interface FilesPageEntry { + kind: "folder" | "file"; + folder?: FolderRecord; + /** Number of files inside this folder (folder entries only). */ + folderFileCount?: number; + file?: StirlingFileStub; + /** Parent breadcrumb path for search results outside the current folder. */ + parentPath?: string; +} + +interface FileGridProps { + entries: FilesPageEntry[]; + selectedFileIds: Set; + /** Ids of files loaded in the active workspace. */ + activeWorkspaceFileIds?: Set; + viewMode: FilesPageViewMode; + onSelectFile: (id: FileId, shiftKey: boolean, ctrlKey: boolean) => void; + /** Replace the entire selection set. */ + onSetSelection?: (ids: Set) => void; + onOpenFolder: (id: FolderId) => void; + /** "Add to workspace". */ + onOpenFile: (file: StirlingFileStub) => void; + /** "Quick view". */ + onQuickView: (file: StirlingFileStub) => void; + onMoveFiles: ( + fileIds: FileId[], + targetFolderId: FolderId | null, + ) => void | Promise; + onMoveFolder: ( + folderId: FolderId, + newParentId: FolderId | null, + ) => void | Promise; + onRenameFolder: (folder: FolderRecord) => void; + onDeleteFolder: (folder: FolderRecord) => void; + onChangeFolderAppearance: ( + folderId: FolderId, + appearance: { color?: string; icon?: string | null }, + ) => void; + onRemoveFiles: (fileIds: FileId[]) => void; + onPromptMoveFiles: (fileIds: FileId[]) => void; + /** Per-file Save to server; hidden when file already has remoteStorageId. */ + onSaveToServer?: (file: StirlingFileStub) => void; + /** When set, the Save to server item renders disabled with this tooltip. */ + saveToServerDisabledReason?: string | null; + /** When supplied the list-view column headers become sortable. */ + sortMode?: FilesPageSortMode; + onChangeSortMode?: (mode: FilesPageSortMode) => void; + /** Drives the empty-state copy. */ + currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe"; + /** Cloud reachability; switches the cloud empty-state copy. */ + serverReachable?: boolean; + /** Empty-state CTA handlers; if absent the matching button hides. */ + onEmptyUpload?: () => void; + onEmptyCreateFolder?: () => void; + /** Non-null disables the New folder CTA with this reason as tooltip. */ + newFolderDisabledReason?: string | null; +} + +export function FileGrid(props: FileGridProps & { loading?: boolean }) { + const { + viewMode, + entries, + loading, + currentTab, + serverReachable, + onEmptyUpload, + onEmptyCreateFolder, + newFolderDisabledReason, + } = props; + + if (loading && entries.length === 0) { + return ; + } + + if (entries.length === 0) { + return ( + + ); + } + + if (viewMode === "list") { + return ; + } + return ; +} + +function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) { + // Six placeholders mirroring the card layout while IDB resolves. + const placeholders = Array.from({ length: 6 }); + if (viewMode === "list") { + return ( +
+ {placeholders.map((_, i) => ( +
+ + + + + + +
+ ))} +
+ ); + } + return ( +
+ {placeholders.map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +interface EmptyStateProps { + /** Drives copy + iconography. */ + tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe"; + /** Switches the cloud empty-state copy. */ + serverReachable?: boolean; + /** CTA handlers; absent => button hidden. */ + onUpload?: () => void; + onCreateFolder?: () => void; + /** Non-null disables New folder CTA with this reason. */ + newFolderDisabledReason?: string | null; +} + +function EmptyState({ + tab = "all", + serverReachable = true, + onUpload, + onCreateFolder, + newFolderDisabledReason, +}: EmptyStateProps) { + const { t } = useTranslation(); + const { titleKey, titleFallback, hintKey, hintFallback } = (() => { + switch (tab) { + case "local": + return { + titleKey: "filesPage.empty.local.title", + titleFallback: "No local-only files", + hintKey: "filesPage.empty.local.hint", + hintFallback: + "Files saved without uploading stay here. Drop a file to add one.", + }; + case "cloud": + return serverReachable + ? { + titleKey: "filesPage.empty.cloud.title", + titleFallback: "No cloud files yet", + hintKey: "filesPage.empty.cloud.hint", + hintFallback: + "Upload a file to start, or create a folder to organise.", + } + : { + titleKey: "filesPage.empty.cloud.offlineTitle", + titleFallback: "No cached cloud files", + hintKey: "filesPage.empty.cloud.offlineHint", + hintFallback: "Reconnect to load your cloud library.", + }; + case "recent": + return { + titleKey: "filesPage.empty.recent.title", + titleFallback: "Nothing modified yet", + hintKey: "filesPage.empty.recent.hint", + hintFallback: "Files you open or edit will appear here.", + }; + case "shared": + return { + titleKey: "filesPage.empty.shared.title", + titleFallback: "Nothing shared with you", + hintKey: "filesPage.empty.shared.hint", + hintFallback: "When someone shares a file via link, it appears here.", + }; + case "sharedByMe": + return { + titleKey: "filesPage.empty.sharedByMe.title", + titleFallback: "You haven't shared any files yet", + hintKey: "filesPage.empty.sharedByMe.hint", + hintFallback: + "Create a share link or invite a teammate from any of your files to see it here.", + }; + case "all": + default: + return { + titleKey: "filesPage.empty.title", + titleFallback: "This folder is empty", + hintKey: "filesPage.empty.hint", + hintFallback: + "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files.", + }; + } + })(); + // Recent/Shared tabs are read-only filters; Local is cloud-only for folders. + const readOnlyTab = + tab === "recent" || tab === "shared" || tab === "sharedByMe"; + const showUpload = Boolean(onUpload) && !readOnlyTab; + const showCreateFolder = + Boolean(onCreateFolder) && !readOnlyTab && tab !== "local"; + const showCtas = showUpload || showCreateFolder; + return ( +
+ + + +
{t(titleKey, titleFallback)}
+
{t(hintKey, hintFallback)}
+ {showCtas && ( +
+ {showUpload && ( + + )} + {showCreateFolder && + (newFolderDisabledReason ? ( + + {/* Wrap so tooltip hovers while button is disabled. */} + + + + + ) : ( + + ))} +
+ )} +
+ ); +} + +function GridView({ + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onOpenFolder, + onOpenFile, + onQuickView, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + onRemoveFiles, + onPromptMoveFiles, + onSaveToServer, + saveToServerDisabledReason, +}: FileGridProps) { + return ( +
+ {entries.map((entry) => { + if (entry.kind === "folder" && entry.folder) { + return ( + onOpenFolder(entry.folder!.id)} + onRename={() => onRenameFolder(entry.folder!)} + onDelete={() => onDeleteFolder(entry.folder!)} + onChangeAppearance={(appearance) => + onChangeFolderAppearance(entry.folder!.id, appearance) + } + onMoveFiles={(fileIds) => onMoveFiles(fileIds, entry.folder!.id)} + onMoveFolder={(folderId) => + onMoveFolder(folderId, entry.folder!.id) + } + /> + ); + } + if (entry.kind === "file" && entry.file) { + return ( + = 2} + onClick={(e) => + onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) + } + onDoubleClick={() => onOpenFile(entry.file!)} + onQuickView={() => onQuickView(entry.file!)} + onRemove={() => onRemoveFiles([entry.file!.id])} + onMove={() => { + const target = selectedFileIds.has(entry.file!.id) + ? Array.from(selectedFileIds) + : [entry.file!.id]; + onPromptMoveFiles(target); + }} + onSaveToServer={ + onSaveToServer ? () => onSaveToServer(entry.file!) : undefined + } + saveToServerDisabledReason={saveToServerDisabledReason} + /> + ); + } + return null; + })} +
+ ); +} + +interface FolderCardProps { + folder: FolderRecord; + fileCount: number; + /** Subtitle for search results outside current folder. */ + parentPath?: string; + selectedFileIds: Set; + onOpen: () => void; + onRename: () => void; + onDelete: () => void; + onChangeAppearance: (appearance: { + color?: string; + icon?: string | null; + }) => void; + onMoveFiles: (fileIds: FileId[]) => void | Promise; + onMoveFolder: (folderId: FolderId) => void | Promise; +} + +function FolderCard({ + folder, + fileCount, + parentPath, + onOpen, + onRename, + onDelete, + onChangeAppearance, + onMoveFiles, + onMoveFolder, +}: FolderCardProps) { + const { t } = useTranslation(); + const { serverReachable, setError } = useFolders(); + const offlineHint = t( + "filesPage.offlineNoFolderEdits", + "Offline - folder changes are disabled.", + ); + const surfaceDrop = (err: unknown, label: string) => { + console.error(`[FolderCard] ${label}`, err); + setError( + err instanceof Error + ? `Could not ${label}: ${err.message}` + : `Could not ${label}.`, + ); + }; + const kebabRef = useRef(null); + const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({ + dragType: FILES_PAGE_DRAG_TYPE, + onDrop: (e) => { + const payload = parseFilesPageDragPayload(e.dataTransfer); + if (!payload) return; + // Surface rejections instead of silent no-op on IDB failures. + if (payload.kind === "files") { + Promise.resolve(onMoveFiles(payload.fileIds)).catch((err) => + surfaceDrop(err, "move files into folder"), + ); + } else if (payload.kind === "folder") { + Promise.resolve(onMoveFolder(payload.folderId)).catch((err) => + surfaceDrop(err, "move folder"), + ); + } + }, + }); + + return ( +
{ + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ + kind: "folder", + folderId: folder.id, + }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + {...dropHandlers} + className={`files-page-card is-folder${ + isDropTarget ? " is-drop-target" : "" + }`} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + > +
+ +
+
+
+ {folder.name} +
+ {parentPath && ( +
+ {t("filesPage.inPath", "in {{path}}", { path: parentPath })} +
+ )} +
+ {fileCount === 0 + ? t("filesPage.folder", "Folder") + : t("filesPage.folderItems", "{{count}} items", { + count: fileCount, + })} +
+
+
+ + + e.stopPropagation()} + aria-label={t("filesPage.folderMenu", "Folder actions")} + > + + + + + } + onClick={onOpen} + > + {t("filesPage.open", "Open")} + + } + onClick={onRename} + disabled={!serverReachable} + title={!serverReachable ? offlineHint : undefined} + > + {t("filesPage.rename", "Rename")} + + + + {t("filesPage.appearance.title", "Appearance")} + + + + } + onClick={onDelete} + disabled={!serverReachable} + title={!serverReachable ? offlineHint : undefined} + > + {t("filesPage.deleteFolder", "Delete folder")} + + + +
+
+ ); +} + +interface FileCardProps { + file: StirlingFileStub; + isSelected: boolean; + isInWorkspace: boolean; + /** Subtitle for search results outside current folder. */ + parentPath?: string; + selectedFileIds: Set; + /** Shows the checkbox once 2+ files are selected. */ + multiSelectActive: boolean; + onClick: (e: React.MouseEvent) => void; + onDoubleClick: () => void; + onQuickView: () => void; + onRemove: () => void; + onMove: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; + /** When set, the kebab Save to server is disabled with this tooltip. */ + saveToServerDisabledReason?: string | null; +} + +function FileCard({ + file, + parentPath, + isSelected, + isInWorkspace, + selectedFileIds, + multiSelectActive, + onClick, + onDoubleClick, + onQuickView, + onRemove, + onMove, + onSaveToServer, + saveToServerDisabledReason, +}: FileCardProps) { + const { t } = useTranslation(); + const cardRef = useRef(null); + const fileSize = useMemo(() => formatFileSize(file.size), [file.size]); + const fileDate = useMemo( + () => getFileDate({ lastModified: file.lastModified }), + [file.lastModified], + ); + + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const fileIds = isSelected ? Array.from(selectedFileIds) : [file.id]; + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ kind: "files", fileIds }), + ); + e.dataTransfer.effectAllowed = "move"; + }, + [file.id, isSelected, selectedFileIds], + ); + + const extension = file.name.split(".").pop()?.toUpperCase() ?? ""; + const isPdf = extension === "PDF"; + const resolvedThumbnail = useLazyThumbnail( + file.id, + file.size, + file.thumbnailUrl, + ); + + const kebabRef = useRef(null); + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + // Right-click on unselected card selects first, then opens menu. + if (!isSelected) onClick(e); + kebabRef.current?.click(); + }, + [isSelected, onClick], + ); + + return ( +
{ + if (e.key === "Enter") onDoubleClick(); + }} + className={`files-page-card${isSelected ? " is-selected" : ""}${ + isInWorkspace ? " is-in-workspace" : "" + }`} + > + {isInWorkspace && ( + + + {t("filesPage.inWorkspace", "Open")} + + )} + {/* Checkbox only renders once the user is explicitly in multi-select + mode (2+ files chosen via Ctrl/Shift-click, or one file then + another). For single-select the highlight border on the card is + the only state indicator - avoids the always-on-checkbox + visual noise and matches the file-explorer model. */} + {multiSelectActive && ( +
+ { + // Synthesise ctrl-click so parent takes the toggle branch. + 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, + })} + /> +
+ )} +
+ {resolvedThumbnail ? ( + // draggable={false} so card's onDragStart fires, not native image drag. + + ) : ( +
+ {isPdf ? ( + + ) : ( + + )} + {extension || "FILE"} +
+ )} +
+ +
+
+
+
+ {file.name} +
+ {parentPath && ( +
+ {t("filesPage.inPath", "in {{path}}", { path: parentPath })} +
+ )} +
+ {fileSize} + · + {fileDate} +
+
+
+ + + e.stopPropagation()} + aria-label={t("filesPage.fileMenu", "File actions")} + > + + + + + } + onClick={(e) => { + e.stopPropagation(); + onDoubleClick(); + }} + > + {t("filesPage.addToWorkspace", "Add to workspace")} + + } + onClick={(e) => { + e.stopPropagation(); + onQuickView(); + }} + > + {t("filesPage.quickView", "Quick view")} + + + } + onClick={(e) => { + e.stopPropagation(); + onMove(); + }} + > + {t("filesPage.moveTo", "Move to…")} + + {/* Per-file Save to server; shown for local-only files. When + storage is off it stays visible but disabled with a tooltip. */} + {onSaveToServer && file.remoteStorageId == null && ( + + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={(e) => { + e.stopPropagation(); + onSaveToServer(); + }} + style={ + saveToServerDisabledReason + ? { pointerEvents: "auto" } + : undefined + } + > + {t("filesPage.saveToServer", "Save to server")} + + + )} + + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t("filesPage.remove", "Delete")} + + + +
+
+ ); +} + +function ListView({ + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onSetSelection, + onOpenFolder, + onOpenFile, + onQuickView, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onSaveToServer, + saveToServerDisabledReason, + onChangeFolderAppearance, + onRemoveFiles, + onPromptMoveFiles, + sortMode, + onChangeSortMode, +}: FileGridProps & { + sortMode?: FilesPageSortMode; + onChangeSortMode?: (next: FilesPageSortMode) => void; +}) { + const { t } = useTranslation(); + + // Tri-state header checkbox state - computed from current entries. + const visibleFileIds = useMemo( + () => + entries + .filter( + (e): e is FilesPageEntry & { file: StirlingFileStub } => + e.kind === "file" && !!e.file, + ) + .map((e) => e.file.id), + [entries], + ); + const allSelected = + visibleFileIds.length > 0 && + visibleFileIds.every((id) => selectedFileIds.has(id)); + const someSelected = + !allSelected && visibleFileIds.some((id) => selectedFileIds.has(id)); + + const sortIndicator = (asc: FilesPageSortMode, desc: FilesPageSortMode) => { + if (sortMode === asc) return " ↑"; + if (sortMode === desc) return " ↓"; + return ""; + }; + + const headerProps = (asc: FilesPageSortMode, desc: FilesPageSortMode) => ({ + role: "button", + tabIndex: onChangeSortMode ? 0 : undefined, + "data-sortable": onChangeSortMode ? "true" : undefined, + onClick: () => { + if (!onChangeSortMode) return; + onChangeSortMode(sortMode === asc ? desc : asc); + }, + onKeyDown: (e: React.KeyboardEvent) => { + if (!onChangeSortMode) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onChangeSortMode(sortMode === asc ? desc : asc); + } + }, + }); + + return ( +
+
+ {onSetSelection && visibleFileIds.length > 0 ? ( + { + onSetSelection(allSelected ? new Set() : new Set(visibleFileIds)); + }} + aria-label={ + allSelected + ? t("filesPage.deselectAll", "Clear selection") + : t("filesPage.selectAll", "Select all") + } + /> + ) : ( +
+ {entries.map((entry) => { + if (entry.kind === "folder" && entry.folder) { + return ( + onOpenFolder(entry.folder!.id)} + onRename={() => onRenameFolder(entry.folder!)} + onDelete={() => onDeleteFolder(entry.folder!)} + onChangeAppearance={(appearance) => + onChangeFolderAppearance(entry.folder!.id, appearance) + } + onDropFiles={(fileIds) => onMoveFiles(fileIds, entry.folder!.id)} + onDropFolder={(folderId) => + onMoveFolder(folderId, entry.folder!.id) + } + /> + ); + } + if (entry.kind === "file" && entry.file) { + return ( + = 2} + onClick={(e) => + onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) + } + onOpen={() => onOpenFile(entry.file!)} + onQuickView={() => onQuickView(entry.file!)} + onRemove={() => onRemoveFiles([entry.file!.id])} + onMove={() => { + const target = selectedFileIds.has(entry.file!.id) + ? Array.from(selectedFileIds) + : [entry.file!.id]; + onPromptMoveFiles(target); + }} + onSaveToServer={ + onSaveToServer ? () => onSaveToServer(entry.file!) : undefined + } + saveToServerDisabledReason={saveToServerDisabledReason} + /> + ); + } + return null; + })} +
+ ); +} + +interface FolderRowProps { + folder: FolderRecord; + fileCount: number; + parentPath?: string; + onOpen: () => void; + onRename: () => void; + onDelete: () => void; + onChangeAppearance: (appearance: { + color?: string; + icon?: string | null; + }) => void; + onDropFiles: (fileIds: FileId[]) => void | Promise; + onDropFolder: (folderId: FolderId) => void | Promise; +} + +function FolderRow({ + folder, + fileCount, + parentPath, + onOpen, + onRename, + onDelete, + onChangeAppearance, + onDropFiles, + onDropFolder, +}: FolderRowProps) { + const { t } = useTranslation(); + const { serverReachable, setError } = useFolders(); + const offlineHint = t( + "filesPage.offlineNoFolderEdits", + "Offline - folder changes are disabled.", + ); + const surfaceDrop = (err: unknown, label: string) => { + console.error(`[FolderRow] ${label}`, err); + setError( + err instanceof Error + ? t("filesPage.error.actionFailedDetail", { + action: label, + message: err.message, + defaultValue: `Could not ${label}: ${err.message}`, + }) + : t("filesPage.error.actionFailed", { + action: label, + defaultValue: `Could not ${label}.`, + }), + ); + }; + const kebabRef = useRef(null); + const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({ + dragType: FILES_PAGE_DRAG_TYPE, + onDrop: (e) => { + const payload = parseFilesPageDragPayload(e.dataTransfer); + if (!payload) return; + if (payload.kind === "files") { + Promise.resolve(onDropFiles(payload.fileIds)).catch((err) => + surfaceDrop(err, "move files into folder"), + ); + } else if (payload.kind === "folder") { + Promise.resolve(onDropFolder(payload.folderId)).catch((err) => + surfaceDrop(err, "move folder"), + ); + } + }, + }); + return ( +
{ + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ + kind: "folder", + folderId: folder.id, + }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + {...dropHandlers} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`} + > +
+ ); +} + +interface FileRowProps { + file: StirlingFileStub; + isSelected: boolean; + isInWorkspace: boolean; + parentPath?: string; + selectedFileIds: Set; + /** Shows the checkbox once 2+ files are selected. */ + multiSelectActive: boolean; + onClick: (e: React.MouseEvent) => void; + onOpen: () => void; + onQuickView: () => void; + onRemove: () => void; + onMove: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; + /** When set, the kebab Save to server is disabled with this tooltip. */ + saveToServerDisabledReason?: string | null; +} + +function FileRow({ + file, + isSelected, + isInWorkspace, + parentPath, + selectedFileIds, + multiSelectActive, + onClick, + onOpen, + onQuickView, + onRemove, + onMove, + onSaveToServer, + saveToServerDisabledReason, +}: FileRowProps) { + const { t } = useTranslation(); + const kebabRef = useRef(null); + const fileSize = useMemo(() => formatFileSize(file.size), [file.size]); + const fileDate = useMemo( + () => getFileDate({ lastModified: file.lastModified }), + [file.lastModified], + ); + const ext = (file.name.split(".").pop() ?? "").toUpperCase(); + const resolvedThumbnail = useLazyThumbnail( + file.id, + file.size, + file.thumbnailUrl, + ); + return ( +
{ + const fileIds = isSelected ? Array.from(selectedFileIds) : [file.id]; + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ kind: "files", fileIds }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + onClick={onClick} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + if (!isSelected) onClick(e); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + className={`files-page-list-row${isSelected ? " is-selected" : ""}${ + 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. */} + {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, + })} + /> + ) : ( + // Empty cell preserves grid column alignment. +
+ ); +} + +// Re-export root constant for caller convenience +export { ROOT_FOLDER_ID }; diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx new file mode 100644 index 0000000000..8b54960e30 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -0,0 +1,1767 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + ActionIcon, + Button, + Drawer, + Group, + MultiSelect, + SegmentedControl, + Select, + Tooltip, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import SearchIcon from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import QrCode2Icon from "@mui/icons-material/QrCode2"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import GridViewIcon from "@mui/icons-material/GridView"; +import ViewListIcon from "@mui/icons-material/ViewList"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import RefreshIcon from "@mui/icons-material/Refresh"; + +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; +import { useFolders } from "@app/contexts/FolderContext"; +import { useFileActions } from "@app/contexts/file/fileHooks"; +import { useAllFiles } from "@app/contexts/FileContext"; +import { useFileHandler } from "@app/hooks/useFileHandler"; +import { + useNavigationActions, + useNavigationGuard, +} from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { + FILES_PAGE_VIEW_MODES, + FilesPageOriginFilter, + FilesPageSortMode, + useFilesPage, +} from "@app/contexts/FilesPageContext"; +import { getFileOrigin } from "@app/components/filesPage/fileOrigin"; + +import { FileId } from "@app/types/file"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder"; + +import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid"; +import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel"; +import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal"; +import MobileUploadModal from "@app/components/shared/MobileUploadModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; +import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog"; +import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; +import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; +import { materializeServerStubs } from "@app/services/fileSyncService"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { + clearFilesPageReturnRoute, + setFilesPageReturnRoute, +} from "@app/components/filesPage/filesPageReturnRoute"; +import "@app/components/filesPage/FilesPage.css"; + +export default function FileManagerView() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + + // Hide Shared tab when storageSharingEnabled is false. + const { sharingEnabled } = useSharingEnabled(); + + // ≤800px hosts the details panel in a button-triggered Drawer. + const isCompactDetailsViewport = useMediaQuery("(max-width: 800px)") ?? false; + // Phones get a full-screen drawer; tablets get a smaller one. + const useFullScreenDrawer = useMediaQuery("(max-width: 640px)") ?? false; + const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false); + // Save-to-server modal target. Bulk button uses local-only selection; + // per-file kebab uses [file]. Targets root; folder placement is via drop. + const [saveToServerTarget, setSaveToServerTarget] = useState< + StirlingFileStub[] | null + >(null); + const folders = useFolders(); + const { actions: fileActions } = useFileActions(); + const { fileIds: activeWorkspaceFileIds } = useAllFiles(); + const activeWorkspaceFileIdSet = useMemo( + () => new Set(activeWorkspaceFileIds.map((id) => id as string)), + [activeWorkspaceFileIds], + ); + const { addFiles } = useFileHandler(); + const { config: appConfig } = useAppConfig(); + const isMobile = useIsMobile(); + const isMobileUploadAvailable = + Boolean(appConfig?.enableMobileScanner) && !isMobile; + // Server storage gate; mirrors ConfigController's storageEnabled + // (enableLogin && storage.isEnabled). When off, Save-to-server stays + // visible but disabled with an explanatory tooltip (discoverability beats + // hiding - mirrors the New folder / Manage sharing gates in this view). + const uploadEnabled = appConfig?.storageEnabled === true; + const saveToServerDisabledReason: string | null = uploadEnabled + ? null + : t( + "filesPage.saveToServerDisabledHint", + "Saving to the server isn't enabled on this server. Ask your admin to enable it.", + ); + const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false); + const { actions: navActions } = useNavigationActions(); + const { requestNavigation } = useNavigationGuard(); + const { setActiveFileId } = useViewer(); + + const filesPage = useFilesPage(); + const { + allFiles, + fileMap, + loading, + refresh, + selectedFileIds, + setSelectedFileIds, + clearSelection, + viewMode, + setViewMode, + sortMode, + setSortMode, + search, + setSearch, + originFilter, + setOriginFilter, + typeFilter, + setTypeFilter, + currentTab, + setCurrentTab, + folderNameDialog, + openNewFolderDialog, + openRenameFolderDialog, + closeFolderNameDialog, + submitFolderName, + moveDialog, + promptMoveFiles, + closeMoveDialog, + moveFilesTo, + moveFolderTo, + removeFiles, + promptDeleteFolder, + deleteFolder, + deleteFolderDialog, + closeDeleteFolderDialog, + setFolderAppearance, + } = filesPage; + + const setCurrentFolderId = folders.setCurrentFolderId; + const foldersById = folders.foldersById; + const currentFolderId = folders.currentFolderId; + + // Sync the URL into FolderContext. + useEffect(() => { + const match = location.pathname.match(/^\/files\/([^/]+)/); + const param = match?.[1] ?? null; + if (param === null) { + setCurrentFolderId(ROOT_FOLDER_ID); + } else if (foldersById.has(param as FolderId)) { + setCurrentFolderId(param as FolderId); + } else { + setCurrentFolderId(ROOT_FOLDER_ID); + } + }, [location.pathname, foldersById, setCurrentFolderId]); + + // Bounce off any share-related tab when sharing isn't enabled. + useEffect(() => { + if ( + !sharingEnabled && + (currentTab === "shared" || currentTab === "sharedByMe") + ) { + setCurrentTab("all"); + } + }, [sharingEnabled, currentTab, setCurrentTab]); + + // Push folder selection into the URL while still on /files. + useEffect(() => { + if (!window.location.pathname.startsWith("/files")) return; + const target = + currentFolderId === null ? "/files" : `/files/${currentFolderId}`; + if (window.location.pathname !== target) { + navigate(target, { replace: true }); + } + }, [currentFolderId, navigate]); + + // ─── visible items (current folder + sort + search) ───────────────────── + + /** currentFolderId + all descendants. Includes `null` when at root. */ + const subtreeFolderIds = useMemo(() => { + const set = new Set(); + set.add(currentFolderId); + const childMap = new Map(); + for (const f of folders.folders) { + const list = childMap.get(f.parentFolderId) ?? []; + list.push(f.id); + childMap.set(f.parentFolderId, list); + } + // Iterative DFS to avoid stack overflow on deep chains. + const stack: (FolderId | null)[] = [currentFolderId]; + while (stack.length > 0) { + const cur = stack.pop()!; + for (const childId of childMap.get(cur) ?? []) { + if (set.has(childId)) continue; + set.add(childId); + stack.push(childId); + } + } + return set; + }, [folders.folders, currentFolderId]); + + const visibleFolders = useMemo(() => { + // Folders only appear in cloud-rooted tabs. + if ( + currentTab === "local" || + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" + ) { + return []; + } + const lc = search.toLowerCase(); + const matched = folders.folders.filter((f) => { + if (search) { + // Subtree-wide name match; exclude the current folder itself. + return ( + f.id !== currentFolderId && + subtreeFolderIds.has(f.parentFolderId) && + f.name.toLowerCase().includes(lc) + ); + } + // Direct children only. + return f.parentFolderId === currentFolderId; + }); + return matched.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + }, [folders.folders, currentFolderId, search, currentTab, subtreeFolderIds]); + + // Files in current folder, pre-filter. Drives the type-filter dropdown. + const filesInCurrentFolder = useMemo(() => { + // Tab overrides folder navigation for Local/Recent/Shared. + switch (currentTab) { + case "local": + // Local = files with no server copy. folderId is forced null on this + // path (cf. file.ts comment), but we check remoteStorageId too so + // stale local-folder rows from a pre-pivot DB don't slip through. + return allFiles.filter((f) => f.remoteStorageId == null); + case "cloud": + // Cloud bucket; search widens to subtree, else direct-folder match. + return allFiles.filter((f) => { + if (f.remoteStorageId == null) return false; + if (search) return subtreeFolderIds.has(f.folderId ?? null); + return (f.folderId ?? null) === (currentFolderId ?? null); + }); + case "recent": { + // Last 50 modified across local + cloud, folder context ignored. + const sorted = [...allFiles].sort( + (a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0), + ); + return sorted.slice(0, 50); + } + case "shared": + return allFiles.filter((f) => f.remoteOwnedByCurrentUser === false); + case "sharedByMe": + // Files I own that I've shared in any way - either with a public link + // or with a specific user. (Previously split across two visually + // identical tabs; merged here so the same idea lives in one place.) + return allFiles.filter( + (f) => + f.remoteOwnedByCurrentUser !== false && + (f.remoteHasShareLinks === true || f.remoteHasUserShares === true), + ); + case "all": + default: + // Search widens to the subtree. + // Files with a dangling folderId (folder deleted, or stale local IDB + // row) fall back to root so they aren't permanently invisible. + return allFiles.filter((f) => { + const rawFolder = f.folderId ?? null; + const effectiveFolder = + rawFolder !== null && !foldersById.has(rawFolder) + ? null + : rawFolder; + if (search) return subtreeFolderIds.has(effectiveFolder); + return effectiveFolder === (currentFolderId ?? null); + }); + } + }, [ + allFiles, + currentFolderId, + currentTab, + search, + subtreeFolderIds, + foldersById, + ]); + + const availableTypes = useMemo(() => { + const set = new Set(); + for (const f of filesInCurrentFolder) { + const ext = (f.name.split(".").pop() ?? "").toUpperCase(); + if (ext) set.add(ext); + } + return Array.from(set).sort(); + }, [filesInCurrentFolder]); + + // Drop any active type filters that no longer appear in this folder + // (e.g. when the user navigates between folders). + useEffect(() => { + if (typeFilter.length === 0) return; + const stillValid = typeFilter.filter((t) => availableTypes.includes(t)); + if (stillValid.length !== typeFilter.length) { + setTypeFilter(stillValid); + } + }, [availableTypes, typeFilter, setTypeFilter]); + + const visibleFiles = useMemo(() => { + const filtered = filesInCurrentFolder + .filter((f) => + search ? f.name.toLowerCase().includes(search.toLowerCase()) : true, + ) + .filter((f) => + originFilter === "all" ? true : getFileOrigin(f) === originFilter, + ) + .filter((f) => { + if (typeFilter.length === 0) return true; + const ext = (f.name.split(".").pop() ?? "").toUpperCase(); + return typeFilter.includes(ext); + }); + const sorted = [...filtered]; + sorted.sort((a, b) => { + switch (sortMode) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "modified-asc": + return (a.lastModified ?? 0) - (b.lastModified ?? 0); + case "size-desc": + return (b.size ?? 0) - (a.size ?? 0); + case "size-asc": + return (a.size ?? 0) - (b.size ?? 0); + case "modified-desc": + default: + return (b.lastModified ?? 0) - (a.lastModified ?? 0); + } + }); + return sorted; + }, [filesInCurrentFolder, search, sortMode, originFilter, typeFilter]); + + /** + * Resolve a folder id to its breadcrumb path (e.g. "Receipts / 2024 / Q1"). + * Returns empty string for root / unknown. Used for the search-result + * "where does this live?" subtitle. + */ + const pathForFolderId = useCallback( + (folderId: FolderId | null | undefined): string => { + if (folderId == null) return ""; + const parts: string[] = []; + let cursor: FolderId | null = folderId; + const seen = new Set(); + while (cursor !== null) { + if (seen.has(cursor)) break; + seen.add(cursor); + const f = foldersById.get(cursor); + if (!f) break; + parts.unshift(f.name); + cursor = f.parentFolderId; + } + return parts.join(" / "); + }, + [foldersById], + ); + + const entries = useMemo(() => { + // When searching, items may come from anywhere in the subtree, so we + // expose a "parentPath" subtitle whenever the item's parent differs from + // currentFolderId. When no search is active, every item is in the + // current folder by definition and the subtitle is suppressed. + const inSearch = search.length > 0; + return [ + ...visibleFolders.map((folder) => ({ + kind: "folder", + folder, + folderFileCount: filesPage.fileCountsByFolder.get(folder.id) ?? 0, + parentPath: + inSearch && folder.parentFolderId !== currentFolderId + ? pathForFolderId(folder.parentFolderId) || undefined + : undefined, + })), + ...visibleFiles.map((file) => ({ + kind: "file", + file, + parentPath: + inSearch && (file.folderId ?? null) !== (currentFolderId ?? null) + ? pathForFolderId(file.folderId ?? null) || undefined + : undefined, + })), + ]; + }, [ + visibleFolders, + visibleFiles, + filesPage.fileCountsByFolder, + search, + currentFolderId, + pathForFolderId, + ]); + + // ─── selection ────────────────────────────────────────────────────────── + const lastClickedFileRef = useRef(null); + const handleSelectFile = useCallback( + (fileId: FileId, shift: boolean, ctrl: boolean) => { + setSelectedFileIds((prev) => { + const next = new Set(prev); + if (shift && lastClickedFileRef.current) { + const idx = visibleFiles.findIndex((f) => f.id === fileId); + const lastIdx = visibleFiles.findIndex( + (f) => f.id === lastClickedFileRef.current, + ); + if (idx >= 0 && lastIdx >= 0) { + const [a, b] = idx < lastIdx ? [idx, lastIdx] : [lastIdx, idx]; + for (let i = a; i <= b; i += 1) { + next.add(visibleFiles[i]!.id); + } + return next; + } + } + // Once the user has 2+ files selected they're explicitly in + // multi-select mode (they checked a box, or shift-range'd, or + // ctrl-clicked) - in that mode plain clicks toggle add/remove + // instead of collapsing the whole selection back to one file. + // This is the Google Drive pattern: the "selection mode" sticks + // until the user explicitly exits via the X clear button or by + // clicking the empty background of the grid. + const inMultiSelectMode = prev.size >= 2; + if (ctrl || inMultiSelectMode) { + if (next.has(fileId)) next.delete(fileId); + else next.add(fileId); + } else { + // 0 or 1 selected: plain click replaces (Finder/Explorer + // behaviour). Clicking the already-only-selected file + // deselects it, so single-file selection still toggles. + const isSoleSelection = prev.size === 1 && prev.has(fileId); + next.clear(); + if (!isSoleSelection) next.add(fileId); + } + return next; + }); + lastClickedFileRef.current = fileId; + }, + [visibleFiles, setSelectedFileIds], + ); + + // Background click on the content area clears the selection. + const handleContentBackgroundClick = useCallback( + (e: React.MouseEvent) => { + // Only react when the click target is the scroll container itself + // (not a card, row, or drop overlay). + if (e.target === e.currentTarget) { + clearSelection(); + } + }, + [clearSelection], + ); + + // ─── upload (drag-from-desktop or button) ─────────────────────────────── + const fileInputRef = useRef(null); + const [isDraggingExternal, setIsDraggingExternal] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const handleNativeUpload = useCallback( + async (files: File[]) => { + if (files.length === 0) return; + // skipWorkspaceDispatch: the user is in the file manager, not opening + // files for work. Persist to IDB so the file appears in the grid (via + // FilesPageContext's independent IDB scan) but DON'T pollute workspace + // state - otherwise the file pops up the next time the user navigates + // to /viewer or /tools, which reads as "auto-opened" and surprised + // people every time. The grid will repaint via refresh() below. + const added = await addFiles(files, { + selectFiles: false, + skipWorkspaceDispatch: true, + }); + const fileIds = added.map((f) => f.fileId); + const target = currentFolderId; + // Uploaded files land in Local (folderId stays null). + if ( + target !== null && + fileIds.length > 0 && + (currentTab === "all" || currentTab === "cloud") + ) { + folders.setError( + t( + "filesPage.uploadedToLocal", + "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder.", + ), + ); + } + await refresh(); + }, + [addFiles, currentFolderId, currentTab, folders, refresh, t], + ); + + const onFileInputChange = useCallback( + async (e: React.ChangeEvent) => { + const list = Array.from(e.target.files ?? []); + e.target.value = ""; + if (list.length === 0) return; + await handleNativeUpload(list); + }, + [handleNativeUpload], + ); + + // ─── add to workspace vs quick view ───────────────────────────────────── + // addToWorkspace: commit; no back affordance. + // quickView: peek; "Back to My Files" pill in WorkbenchBar. + const openFilesInWorkbench = useCallback( + async (fileIds: FileId[], options: { trackReturn: boolean }) => { + const stubs = fileIds + .map((id) => fileMap.get(id)) + .filter((s): s is StirlingFileStub => Boolean(s)); + if (stubs.length === 0) return; + + const proceed = async () => { + if (options.trackReturn) { + const returnRoute = + currentFolderId === null ? "/files" : `/files/${currentFolderId}`; + const folderRecord = currentFolderId + ? (foldersById.get(currentFolderId) ?? null) + : null; + const returnLabel = folderRecord + ? folderRecord.name + : t("filesPage.myFiles", "My Files"); + setFilesPageReturnRoute(returnRoute, returnLabel); + } else { + clearFilesPageReturnRoute(); + } + + // Server-only stubs have no bytes in IDB; download + ingest first. + const materialized = await materializeServerStubs(stubs, { + addFiles: fileActions.addFilesWithOptions, + updateStub: fileActions.updateStirlingFileStub, + }); + if (materialized.length !== stubs.length) { + // At least one server download failed; refresh so the grid + // reflects any successful ingests and the user can retry. + await refresh(); + return; + } + + await fileActions.addStirlingFileStubs(materialized, { + selectFiles: false, + }); + // Branch on requested stubs so already-active files still activate. + if (materialized.length === 1) { + setActiveFileId(materialized[0]!.id); + navActions.setWorkbench("viewer"); + } else if (materialized.length > 1) { + navActions.setWorkbench("fileEditor"); + } + navigate("/"); + }; + + requestNavigation(() => { + void proceed(); + }); + }, + [ + fileMap, + fileActions, + setActiveFileId, + navActions, + navigate, + requestNavigation, + currentFolderId, + foldersById, + t, + ], + ); + + const handleAddToWorkspace = useCallback( + (fileIds: FileId[]) => + openFilesInWorkbench(fileIds, { trackReturn: false }), + [openFilesInWorkbench], + ); + + const handleQuickView = useCallback( + (fileId: FileId) => openFilesInWorkbench([fileId], { trackReturn: true }), + [openFilesInWorkbench], + ); + + const handleOpenFile = useCallback( + (file: StirlingFileStub) => { + // Double-click commits to workspace. + void handleAddToWorkspace([file.id]); + }, + [handleAddToWorkspace], + ); + + const handleOpenFolder = useCallback( + (id: FolderId) => { + folders.setCurrentFolderId(id); + clearSelection(); + }, + [folders, clearSelection], + ); + + // ─── full-page drag-and-drop for OS uploads ───────────────────────────── + const dropZoneRef = useRef(null); + useEffect(() => { + const node = dropZoneRef.current; + if (!node) return; + let counter = 0; + const isExternalFileDrag = (e: DragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + + const onEnter = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + counter += 1; + setIsDraggingExternal(true); + }; + const onOver = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + }; + const onLeave = () => { + counter -= 1; + if (counter <= 0) { + counter = 0; + setIsDraggingExternal(false); + } + }; + const onDrop = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + counter = 0; + setIsDraggingExternal(false); + const dropped = Array.from(e.dataTransfer?.files ?? []); + if (dropped.length > 0) { + handleNativeUpload(dropped).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.uploadFilesFailedDetail", { + message: err.message, + defaultValue: `Could not upload files: ${err.message}`, + }) + : t( + "filesPage.error.uploadFilesFailed", + "Could not upload files.", + ), + ), + ); + } + }; + node.addEventListener("dragenter", onEnter); + node.addEventListener("dragover", onOver); + node.addEventListener("dragleave", onLeave); + node.addEventListener("drop", onDrop); + return () => { + node.removeEventListener("dragenter", onEnter); + node.removeEventListener("dragover", onOver); + node.removeEventListener("dragleave", onLeave); + node.removeEventListener("drop", onDrop); + }; + }, [handleNativeUpload]); + + // ─── close / exit ─────────────────────────────────────────────────────── + const handleClose = useCallback(() => { + // Drop the return-route hint so the workbench doesn't show a stale back. + clearFilesPageReturnRoute(); + navigate("/"); + }, [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); + }, []); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const active = document.activeElement as HTMLElement | null; + const inInput = + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable); + + // Cmd/Ctrl + A - select every visible file in the current folder. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "a" && !inInput) { + e.preventDefault(); + setSelectedFileIds(new Set(visibleFiles.map((f) => f.id))); + return; + } + + // Delete / Backspace - remove selected files. + if ( + (e.key === "Delete" || e.key === "Backspace") && + !inInput && + selectedFileIds.size > 0 + ) { + e.preventDefault(); + removeFiles(Array.from(selectedFileIds)).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.removeFilesFailedDetail", { + message: err.message, + defaultValue: `Could not remove files: ${err.message}`, + }) + : t( + "filesPage.error.removeFilesFailed", + "Could not remove files.", + ), + ), + ); + return; + } + + // "/" focuses the search field. + if (e.key === "/" && !inInput) { + e.preventDefault(); + searchInputRef.current?.focus(); + return; + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + const active = document.activeElement as HTMLElement | null; + if ( + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable) + ) { + return; + } + const overlays = document.querySelectorAll(".mantine-Modal-overlay"); + for (const overlay of overlays) { + if ((overlay as HTMLElement).offsetWidth > 0) return; + } + // Esc-once cancels selection before closing the workbench. + if (selectedFileIds.size > 0) { + clearSelection(); + return; + } + handleClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [handleClose, selectedFileIds, clearSelection]); + + // ─── remove via context wrapper (clears selection state too) ──────────── + const handleRemoveFiles = useCallback( + async (fileIds: FileId[]) => { + await removeFiles(fileIds); + }, + [removeFiles], + ); + + // ─── derived UI bits ──────────────────────────────────────────────────── + const currentFolderRecord = currentFolderId + ? (foldersById.get(currentFolderId) ?? null) + : null; + const totalCount = entries.length; + const selectedFiles = useMemo( + () => Array.from(selectedFileIds), + [selectedFileIds], + ); + + // Local-only subset of selection; drives Save-to-server visibility. + const localOnlySelectedStubs = useMemo( + () => + selectedFiles + .map((id) => fileMap.get(id)) + .filter( + (s): s is StirlingFileStub => + Boolean(s) && s!.remoteStorageId == null, + ), + [selectedFiles, fileMap], + ); + + // null = New folder actionable; string = disabled tooltip reason. + const newFolderDisabledReason: string | null = useMemo(() => { + if (currentTab === "local") { + return t( + "filesPage.localFoldersUnavailable", + "Folders are cloud-only - save a file to the cloud to organise it.", + ); + } + if ( + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" + ) { + return t( + "filesPage.newFolderTabUnavailable", + "Switch to All or Cloud to create folders.", + ); + } + if (!folders.serverReachable) { + return t( + "filesPage.newFolderStorageDisabled", + "Server folder storage isn't enabled. Ask your admin to turn it on.", + ); + } + return null; + }, [currentTab, folders.serverReachable, t]); + + return ( +
+
+ {/* Breadcrumb only for folder-rooted tabs. */} + {(currentTab === "all" || currentTab === "cloud") && } + {(currentTab === "local" || + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe") && ( +
+ {currentTab === "local" + ? t("filesPage.tabName.local", "Local") + : currentTab === "recent" + ? t("filesPage.tabName.recent", "Recent") + : currentTab === "shared" + ? t("filesPage.tabName.shared", "Shared with me") + : t("filesPage.tabName.sharedByMe", "Shared by me")} +
+ )} + {(() => { + // Both the inline desktop buttons and the mobile kebab menu need + // these handlers - extract once so we don't drift two copies. + const handleRefresh = async () => { + setRefreshing(true); + try { + // pullFromServer bumps the folder revision, which the + // FolderProvider's effect reacts to by re-running refresh() - + // no need to await folders.refresh() manually. + const result = await folders.pullFromServer(); + if (!result.ok && result.reason !== "endpoint-missing") { + folders.setError( + result.reason === "network" + ? t( + "filesPage.syncError.network", + "Could not reach the server.", + ) + : result.reason === "server" + ? t( + "filesPage.syncError.server", + "Server error during folder sync.", + ) + : t("filesPage.syncError.client", "Folder sync failed."), + ); + } + await refresh(); + } finally { + setRefreshing(false); + } + }; + return ( + <> + +
+ + + + + + {newFolderDisabledReason ? ( + + + + + + ) : ( + + )} + + {isMobileUploadAvailable && ( + + setMobileUploadModalOpen(true)} + aria-label={t( + "filesPage.uploadFromMobile", + "Upload from Mobile", + )} + > + + + + )} + +
+ + ); + })()} +
+ + {folders.error && ( +
+ {folders.error} + folders.setError(null)} + > + + +
+ )} + + {/* No offline banner: when the folder API is unreachable the user + still sees their cached local files (the IDB read survives), and + folder-mutation controls are individually disabled with their own + tooltips. Banner removed per UX feedback. */} + +
+
+ {/* Tab strip filters the file list; ARIA Tabs keyboard model. */} + {(() => { + const TAB_DEFS = [ + { id: "all", label: t("filesPage.tabs.all", "All") }, + { id: "recent", label: t("filesPage.tabs.recent", "Recent") }, + // Sharing tabs only when sharingEnabled. + ...(sharingEnabled + ? [ + { + id: "shared" as const, + label: t("filesPage.tabs.shared", "Shared with me"), + }, + { + id: "sharedByMe" as const, + label: t("filesPage.tabs.sharedByMe", "Shared by me"), + }, + ] + : []), + ] as const; + const focusTab = (id: string) => { + const el = document.getElementById(`filesPage-tab-${id}`); + el?.focus(); + }; + return ( +
{ + const idx = TAB_DEFS.findIndex((t2) => t2.id === currentTab); + if (idx < 0) return; + let next: number; + if (e.key === "ArrowRight") + next = (idx + 1) % TAB_DEFS.length; + else if (e.key === "ArrowLeft") + next = (idx - 1 + TAB_DEFS.length) % TAB_DEFS.length; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = TAB_DEFS.length - 1; + else return; + e.preventDefault(); + const target = TAB_DEFS[next]!; + setCurrentTab(target.id); + focusTab(target.id); + }} + style={{ + display: "flex", + gap: "0.1rem", + padding: "0.2rem 1rem 0.2rem", + }} + > + {TAB_DEFS.map((tab) => ( + + ))} +
+ ); + })()} + +
+ + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { + count: totalCount, + })} + {selectedFiles.length > 0 && ( + + {" "} + ·{" "} + {t("filesPage.selectedCount", "{{count}} selected", { + count: selectedFiles.length, + })} + + )} + + {(() => { + // Select all / Clear toggle over visible files. + if (visibleFiles.length === 0) return null; + const allSelected = visibleFiles.every((f) => + selectedFileIds.has(f.id), + ); + const someSelected = !allSelected && selectedFiles.length > 0; + return ( + + + + ); + })()} +
+ {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + const quickViewLabel = t("filesPage.quickView", "Quick view"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {selectedFiles.length === 1 && ( + + + + )} + {/* Save to server; shown whenever local-only files are + selected. When storage is off it stays visible but + disabled, tooltip pointing at the admin. */} + {localOnlySelectedStubs.length > 0 && ( + + + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + + )} + + + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + + + + + ); + })()} + {selectedFiles.length > 0 && ( +
+
+ +
+ handleQuickView(file.id)} + onMoveFiles={moveFilesTo} + onMoveFolder={moveFolderTo} + onRenameFolder={openRenameFolderDialog} + onDeleteFolder={promptDeleteFolder} + onChangeFolderAppearance={(folderId, appearance) => { + setFolderAppearance(folderId, appearance).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.folderAppearanceFailedDetail", { + message: err.message, + defaultValue: `Could not update folder appearance: ${err.message}`, + }) + : t( + "filesPage.error.folderAppearanceFailed", + "Could not update folder appearance.", + ), + ), + ); + }} + onRemoveFiles={handleRemoveFiles} + onPromptMoveFiles={promptMoveFiles} + onSaveToServer={(file) => setSaveToServerTarget([file])} + saveToServerDisabledReason={saveToServerDisabledReason} + // Center-of-grid CTAs when the empty state shows - same + // handlers the corner header buttons use so behaviour + // (disabled tooltips, native file picker, dialog) is + // identical regardless of where the user clicks from. + onEmptyUpload={() => fileInputRef.current?.click()} + onEmptyCreateFolder={() => openNewFolderDialog()} + newFolderDisabledReason={newFolderDisabledReason} + /> + {isDraggingExternal && ( +
+ + + + + {t("filesPage.dropOverlay", "Drop files to upload")} + + + {/* Behavior contract: per handleNativeUpload above, all + newly-uploaded files start in Local (folderId stays + null) regardless of the current folder view. Saying + "will land in {folder}" was a lie; tell the truth + so the user reaches for Save-to-cloud / Move-to when + they actually want a folder placement. */} + {t( + "filesPage.dropOverlaySub", + "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder.", + )} + +
+ )} +
+
+ + {/* Inline aside on desktop. */} + {selectedFiles.length > 0 && !isCompactDetailsViewport && ( + clearSelection()} + onAddToWorkspace={handleAddToWorkspace} + onQuickView={handleQuickView} + onMove={promptMoveFiles} + onRemove={handleRemoveFiles} + onSaveToServer={(files) => setSaveToServerTarget(files)} + saveToServerDisabledReason={saveToServerDisabledReason} + /> + )} +
+ + {/* Drawer hosts the details panel on ≤800px viewports. */} + {isCompactDetailsViewport && ( + setMobileDetailsOpen(false)} + position="right" + size={useFullScreenDrawer ? "100%" : "sm"} + padding={0} + withCloseButton={false} + overlayProps={{ opacity: 0.45 }} + > + {mobileDetailsOpen && selectedFiles.length === 1 && ( + setMobileDetailsOpen(false)} + onAddToWorkspace={handleAddToWorkspace} + onQuickView={handleQuickView} + onMove={promptMoveFiles} + onRemove={handleRemoveFiles} + onSaveToServer={(files) => setSaveToServerTarget(files)} + saveToServerDisabledReason={saveToServerDisabledReason} + /> + )} + + )} + + { + if (moveDialog.fileIds && moveDialog.fileIds.length > 0) { + await moveFilesTo(moveDialog.fileIds, target); + } else if (moveDialog.folderId) { + await moveFolderTo(moveDialog.folderId, target); + } + }} + // Inline-create folder; gated on serverReachable. + onCreateFolder={ + folders.serverReachable + ? (name, parentFolderId) => + folders.createFolder(name, parentFolderId) + : undefined + } + /> + + + + { + const target = deleteFolderDialog.folder; + if (!target) return; + try { + await deleteFolder(target, deleteContents); + } catch (err) { + folders.setError( + err instanceof Error + ? t("filesPage.error.deleteFolderFailedDetail", { + message: err.message, + defaultValue: `Could not delete folder: ${err.message}`, + }) + : t( + "filesPage.error.deleteFolderFailed", + "Could not delete folder.", + ), + ); + throw err; + } + }} + /> + + {/* Save-to-server modal; keyed on target so updates don't retarget. */} + s.id).join(",")}`} + opened={Boolean(saveToServerTarget && saveToServerTarget.length > 0)} + onClose={() => setSaveToServerTarget(null)} + files={saveToServerTarget ?? []} + onUploaded={refresh} + /> + + setMobileUploadModalOpen(false)} + onFilesReceived={(files) => { + if (files.length > 0) { + void addFiles(files); + } + }} + /> +
+ ); +} + +const SearchField = React.forwardRef< + HTMLInputElement, + { value: string; onChange: (v: string) => void } +>(function SearchField({ value, onChange }, ref) { + const { t } = useTranslation(); + return ( +
+ + 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(); + const filesPage = useFilesPage(); + const trail = folders.breadcrumbs; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx new file mode 100644 index 0000000000..91a2bc0dba --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ComputerIcon from "@mui/icons-material/Computer"; +import CloudDoneIcon from "@mui/icons-material/CloudDone"; +import GroupIcon from "@mui/icons-material/Group"; + +import { FileOrigin } from "@app/components/filesPage/fileOrigin"; + +interface FileOriginBadgeProps { + origin: FileOrigin; + /** Compact (icon-only) vs full (icon + text). */ + compact?: boolean; +} + +const styles = { + base: { + display: "inline-flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.1rem 0.4rem", + borderRadius: "999px", + fontSize: "0.68rem", + fontWeight: 600, + textTransform: "uppercase" as const, + letterSpacing: "0.04em", + lineHeight: 1.2, + }, + local: { + background: + "color-mix(in srgb, var(--text-muted, #6b7280) 16%, transparent)", + color: "var(--text-secondary)", + }, + cloud: { + background: + "color-mix(in srgb, var(--accent-interactive, #6366f1) 16%, transparent)", + color: "var(--accent-interactive, #6366f1)", + }, + shared: { + background: + "color-mix(in srgb, var(--mantine-color-orange-6, #f97316) 16%, transparent)", + color: "var(--mantine-color-orange-6, #f97316)", + }, +}; + +export function FileOriginBadge({ + origin, + compact = false, +}: FileOriginBadgeProps) { + const { t } = useTranslation(); + + const config = (() => { + switch (origin) { + case "cloud": + return { + label: t("filesPage.origin.cloud", "Cloud"), + icon: , + style: styles.cloud, + tooltip: t( + "filesPage.origin.cloudHint", + "Stored on the Stirling server", + ), + }; + case "shared-with-me": + return { + label: t("filesPage.origin.shared", "Shared"), + icon: , + style: styles.shared, + tooltip: t("filesPage.origin.sharedHint", "Shared with you via link"), + }; + case "local": + default: + return { + label: t("filesPage.origin.local", "Local"), + icon: , + style: styles.local, + tooltip: t( + "filesPage.origin.localHint", + "Only stored in this browser", + ), + }; + } + })(); + + const badge = ( + + {config.icon} + {!compact && config.label} + + ); + + return ( + + {badge} + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css new file mode 100644 index 0000000000..9b3b7b206f --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -0,0 +1,1406 @@ +.files-page { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: var(--bg-background); + color: var(--text-primary); + overflow: hidden; +} + +.files-page-header { + /* 3-column grid keeps the search bar position-stable regardless of + * breadcrumb length. Left column flexes for the breadcrumb (truncating + * via overflow:hidden), middle column holds the search, right column + * holds the action buttons right-aligned. */ + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(min(14rem, 100%), 40rem) minmax( + 0, + 1fr + ); + align-items: center; + gap: 1rem; + min-height: 48px; + padding: 0 0.75rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + flex-shrink: 0; +} + +.files-page-header-actions { + justify-self: end; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.files-page-breadcrumbs { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.95rem; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} +.files-page-breadcrumbs::-webkit-scrollbar { + display: none; +} + +.files-page-breadcrumb { + background: none; + border: none; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + color: var(--text-secondary); + cursor: pointer; + font-size: inherit; + font-weight: 500; + transition: + background-color 0.12s ease, + color 0.12s ease; + white-space: nowrap; + max-width: 18rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.files-page-breadcrumb:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.files-page-breadcrumb.is-current { + color: var(--text-primary); + background: var(--hover-bg); +} + +.files-page-breadcrumb-sep { + color: var(--text-muted); + font-size: 1rem !important; + width: 1rem; + height: 1rem; + opacity: 0.55; + flex-shrink: 0; +} + +.files-page-search { + display: flex; + align-items: center; + gap: 0.35rem; + background: var(--bg-surface); + border: 1px solid var(--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(--text-primary); + font-size: 0.9rem; +} + +.files-page-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.files-page-tree { + width: 17rem; + flex-shrink: 0; + border-right: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.files-page-tree[data-collapsed="true"] { + width: 3.25rem; +} + +.files-page-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.75rem 0.5rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +/* Tree node styles moved to FolderTreePanel.css - that's where the + .files-page-tree-* elements actually render (the slide-out panel from + HomePage), so the styles need to load whether or not the standalone + /files route is mounted. */ + +.files-page-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + position: relative; +} + +.files-page-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1.25rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-background); + flex-shrink: 0; + flex-wrap: wrap; + min-height: 3rem; +} + +.files-page-toolbar-info { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; +} + +/* Upload + New folder at the start of the toolbar. */ +.files-page-toolbar-create { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + +.files-page-toolbar-info span { + color: var(--text-muted); +} + +.files-page-toolbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; + /* Allow this container to shrink below its intrinsic content width so + the bulk-action group + filter selects + view toggle can compete for + space without bursting out of the toolbar (which previously bled + under the right-hand details panel at wider viewports). */ + min-width: 0; +} + +/* Bulk-action buttons (Add to workspace, Quick view, Move to, Remove) + are ICON-ONLY at every viewport. Even at 1280px when both side panels + are visible, the main area is only ~616px wide - the labelled versions + of these 4 buttons plus the 2 filter selects + view toggle simply + don't fit. Each button has a Tooltip + aria-label, so the icon alone + is accessible. */ +.files-page-toolbar-actions .mantine-Button-root { + padding-left: 0.55rem; + padding-right: 0.55rem; + flex-shrink: 0; +} +.files-page-toolbar-actions .mantine-Button-section[data-position="left"] { + margin-right: 0; +} +.files-page-toolbar-actions .mantine-Button-label { + display: none; +} +/* Pin the view toggle: never let it clip off the right. flex-shrink:0 + keeps its width fixed; the rest of the row shrinks around it. */ +.files-page-toolbar-actions .mantine-SegmentedControl-root { + flex-shrink: 0; +} +/* Clip overflow on the toolbar itself as a safety net - the content + should always fit thanks to the rules above, but if a future addition + ever breaks that, this prevents the action group from bleeding into + sibling areas (notably the details panel on the right). */ +.files-page-toolbar { + overflow-x: hidden; +} + +.files-page-toolbar-divider { + width: 1px; + height: 1.4rem; + background: var(--border-subtle); + margin: 0 0.15rem; +} + +/* SegmentedControl in the toolbar uses icon-only options; this centers the + MUI icon inside Mantine's `.mantine-SegmentedControl-innerLabel` (which + defaults to `display: inline` and baseline-aligns its children, leaving + the icon visually pinned to the top of the label box). */ +.files-page-view-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + vertical-align: middle; +} + +/* Visually-hidden text that screen readers still announce. Lets us put + localised labels next to icon-only buttons (SegmentedControl options, + icon-only action rows) without growing the visible UI. Standard + "sr-only" / "visually-hidden" recipe. */ +.files-page-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.files-page-content { + flex: 1 1 auto; + overflow: auto; + padding: 1.25rem; + position: relative; + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.files-page-content::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; +} + +.files-page-content::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} + +.files-page-content::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + background-clip: content-box; +} + +.files-page-tree-list, +.folder-tree-panel-inner, +.files-page-details-body { + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.files-page-tree-list::-webkit-scrollbar, +.folder-tree-panel-inner::-webkit-scrollbar, +.files-page-details-body::-webkit-scrollbar { + width: 0.4rem; +} + +.files-page-tree-list::-webkit-scrollbar-thumb, +.folder-tree-panel-inner::-webkit-scrollbar-thumb, +.files-page-details-body::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 999px; +} + +.files-page-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 1rem; +} + +.files-page-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-subtle); + border-radius: 0.6rem; + overflow: hidden; + background: var(--bg-surface); +} + +.files-page-list-row { + display: grid; + grid-template-columns: 2.25rem minmax(0, 3fr) 1fr 1fr 1fr 2.5rem; + gap: 0.5rem; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.12s ease; +} + +.files-page-list-row:last-child { + border-bottom: none; +} + +.files-page-list-row.is-header { + background: var(--bg-toolbar); + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.05em; + cursor: default; + user-select: none; +} + +.files-page-list-row.is-header [data-sortable="true"] { + cursor: pointer; + padding: 0.2rem 0.4rem; + margin: -0.2rem -0.4rem; + border-radius: 0.3rem; + transition: + background-color 0.12s ease, + color 0.12s ease; +} + +.files-page-list-row.is-header [data-sortable="true"]:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.files-page-list-row:not(.is-header):hover { + background: var(--hover-bg); +} + +.files-page-list-row.is-selected { + background: var(--hover-bg); +} + +.files-page-list-row.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1); +} + +.files-page-card { + position: relative; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.85rem; + overflow: hidden; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: + transform 0.16s cubic-bezier(0.32, 0.72, 0.32, 1), + border-color 0.14s ease, + box-shadow 0.18s ease; +} + +.files-page-card:hover { + transform: translateY(-2px); + border-color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 35%, + var(--border-subtle) + ); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.08), + 0 8px 24px -8px rgba(0, 0, 0, 0.18); +} + +.files-page-card:focus-visible { + outline: 2px solid var(--accent-interactive, #6366f1); + outline-offset: 2px; +} + +.files-page-card.is-selected { + border-color: var(--accent-interactive, #6366f1); + box-shadow: + 0 0 0 2px var(--accent-interactive, #6366f1), + 0 6px 18px -6px + color-mix(in srgb, var(--accent-interactive, #6366f1) 35%, transparent); +} + +.files-page-card.is-folder { + cursor: pointer; +} + +.files-page-card-thumb { + aspect-ratio: 4 / 3; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--text-muted) 5%, var(--bg-surface)), + var(--bg-surface) + ); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +.files-page-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s cubic-bezier(0.32, 0.72, 0.32, 1); +} + +.files-page-card:hover .files-page-card-thumb img { + transform: scale(1.03); +} + +.files-page-card-thumb-fallback { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.files-page-card-body { + padding: 0.65rem 0.85rem 0.7rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.files-page-card-name { + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); + letter-spacing: -0.005em; +} + +.files-page-card-meta { + font-size: 0.76rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Parent-folder breadcrumb shown on cards/rows during recursive search so + the user can tell which folder each hit lives in without navigating. */ +.files-page-card-path { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.15rem; + font-style: italic; +} + +.files-page-card .files-page-card-selector { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 2; + opacity: 0; + pointer-events: none; +} + +/* Reveal on hover, on focus-within (keyboard navigation), and when the card is selected. + :focus-within is what makes the checkbox + kebab discoverable without a pointer device. */ +.files-page-card:hover .files-page-card-selector, +.files-page-card:focus-within .files-page-card-selector, +.files-page-card.is-selected .files-page-card-selector { + opacity: 1; + pointer-events: auto; +} + +/* Style the Mantine checkbox so it reads as a soft pill rather than a + bare form control. */ +.files-page-card-selector :where(.mantine-Checkbox-input) { + background: var(--bg-surface); + border-color: var(--border-subtle); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); +} + +.files-page-card-actions { + position: absolute; + top: 0.4rem; + right: 0.4rem; + opacity: 0; + transition: opacity 0.12s ease; + z-index: 4; +} + +/* Make the kebab a high-contrast pill that works on dark thumbnails, + white PDFs, and any background underneath. + Use stronger specificity + !important to win over Mantine's + variant=filled inline styles. */ +.files-page-card .files-page-card-actions button.mantine-ActionIcon-root { + background-color: var(--bg-raised, var(--bg-surface)) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-default, var(--border-subtle)) !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(6px); +} + +.files-page-card .files-page-card-actions button.mantine-ActionIcon-root:hover { + background-color: var(--hover-bg) !important; +} + +/* Row-view kebab: same high-contrast treatment so it's readable + against selected (tinted) and in-workspace rows. */ +.files-page-list-row .mantine-ActionIcon-root[aria-label$="actions"] { + color: var(--text-secondary); +} + +.files-page-list-row .mantine-ActionIcon-root[aria-label$="actions"]:hover { + background-color: var(--hover-bg) !important; + color: var(--text-primary); +} + +.files-page-card-origin { + position: absolute; + bottom: 0.4rem; + left: 0.4rem; + pointer-events: none; +} + +/* "Open" badge - file is currently loaded in the active workspace. + Solid pill with white text so it reads against any thumbnail + (white PDFs, dark covers, photos). */ +.files-page-card-open-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.22rem 0.55rem; + border-radius: 999px; + background: var(--mantine-color-teal-6, #10b981); + color: #ffffff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.85), + 0 2px 6px rgba(0, 0, 0, 0.18); + pointer-events: none; +} + +.files-page-card-open-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35); + animation: files-page-open-pulse 1.8s ease-in-out infinite; +} + +@keyframes files-page-open-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(0.85); + } +} + +.files-page-card.is-in-workspace { + border-color: color-mix( + in srgb, + var(--mantine-color-teal-6, #10b981) 35%, + var(--border-subtle) + ); +} + +.files-page-card.is-in-workspace::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--mantine-color-teal-6, #10b981) 6%, transparent) 0%, + transparent 40% + ); + z-index: 0; +} + +.files-page-row-open-pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.12rem 0.5rem; + border-radius: 999px; + background: var(--mantine-color-teal-6, #10b981); + color: #ffffff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + flex-shrink: 0; + margin-left: 0.25rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.files-page-list-row.is-in-workspace { + background: color-mix( + in srgb, + var(--mantine-color-teal-6, #10b981) 4%, + transparent + ); +} + +.files-page-card:hover .files-page-card-actions, +.files-page-card:focus-within .files-page-card-actions, +.files-page-card.is-selected .files-page-card-actions { + opacity: 1; +} + +.files-page-card.is-folder .files-page-card-thumb { + aspect-ratio: 1.6 / 1; + transition: + filter 0.16s ease, + transform 0.18s cubic-bezier(0.32, 0.72, 0.32, 1); +} + +.files-page-card.is-folder:hover .files-page-card-thumb { + filter: brightness(1.04); +} + +.files-page-card.is-folder.is-drop-target { + transform: translateY(-3px); +} + +.files-page-card.is-folder.is-drop-target .files-page-card-thumb { + filter: brightness(1.08); + transform: scale(1.03); +} + +.files-page-card.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 10%, + var(--bg-surface) + ); + box-shadow: 0 0 0 2px var(--accent-interactive, #6366f1); + border-color: var(--accent-interactive, #6366f1); +} + +@keyframes files-page-skeleton-pulse { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.files-page-skeleton-bar { + display: block; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--text-muted) 12%, transparent) 25%, + color-mix(in srgb, var(--text-muted) 22%, transparent) 50%, + color-mix(in srgb, var(--text-muted) 12%, transparent) 75% + ); + background-size: 200% 100%; + animation: files-page-skeleton-pulse 1.4s ease-in-out infinite; + border-radius: 0.4rem; +} + +.files-page-skeleton-card { + pointer-events: none; + border-color: var(--border-subtle); + box-shadow: none; +} + +.files-page-skeleton-card:hover { + transform: none; + box-shadow: none; + border-color: var(--border-subtle); +} + +.files-page-skeleton-row { + pointer-events: none; +} + +.files-page-skeleton-row .files-page-skeleton-bar { + height: 0.7rem; +} + +.files-page-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 0.75rem; + color: var(--text-muted); + padding: 4rem 1.5rem; + text-align: center; +} + +.files-page-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 5rem; + height: 5rem; + border-radius: 50%; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + color: var(--accent-interactive, #6366f1); + margin-bottom: 0.25rem; +} + +.files-page-empty-title { + font-size: 1.15rem; + color: var(--text-primary); + font-weight: 600; + letter-spacing: -0.01em; +} + +.files-page-empty-hint { + font-size: 0.9rem; + color: var(--text-muted); + max-width: 30rem; + line-height: 1.5; +} + +/* Empty-state CTA row; stacks on phones. */ +.files-page-empty-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 0.75rem; +} + +@media (max-width: 480px) { + .files-page-empty-actions { + flex-direction: column; + width: min(100%, 16rem); + } +} + +.files-page-details { + width: 22rem; + flex-shrink: 0; + border-left: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.files-page-details-header { + /* Tight vertical padding so the "DETAILS" label hugs the body content + below it - it's a quiet section header, not a banner, and the old + ~16px top + 18px body padding wasted nearly two character heights + of vertical space before the user saw the file thumbnail. */ + padding: 0.35rem 1.1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.files-page-details-header strong { + font-weight: 600; +} + +.files-page-details-body { + /* Smaller top padding to pair with the tightened header above. Side + and bottom padding stays at 1.1rem so the content still breathes + against the panel edges. */ + padding: 0.3rem 1.1rem 1.1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.files-page-details-thumb { + aspect-ratio: 4 / 3; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.6rem; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + /* Inside an overflow-y:auto flex column, `aspect-ratio` alone doesn't + guarantee intrinsic height - flex-shrink:1 (default) can squash the + thumb to a 2px sliver when the column gets cramped (notably after + tightening the body padding above). Pin shrink so the thumb keeps + its 4:3 box. */ + flex-shrink: 0; +} + +.files-page-details-thumb img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.files-page-details-fieldlist { + display: flex; + flex-direction: column; + gap: 0.55rem; + font-size: 0.85rem; + padding: 0.65rem 0.75rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} + +.files-page-details-field { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.files-page-details-field-label { + color: var(--text-muted); + font-size: 0.78rem; +} + +.files-page-details-field-value { + color: var(--text-primary); + text-align: right; + word-break: break-word; + font-weight: 500; +} + +.files-page-details-tool-history { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 0.5rem 0.6rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} + +.files-page-details-tool-history-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +/* Filename chip next to the title - "PDF", "DOCX", etc. + Mantine's rendered effectively invisible + in dark mode; project CSS vars track the active color scheme. */ +.files-page-details-ext-tag { + display: inline-flex; + align-items: center; + padding: 0.12rem 0.5rem; + border-radius: 0.4rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + color: var(--text-secondary, var(--text-primary)); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + line-height: 1.2; +} + +/* Vertical timeline of version history. + Each row has a left rail (dot + connector) and a body (summary + meta + + expandable detail). The rail visually threads versions together + like a commit graph; clicking a row's summary toggles its expanded + detail (cumulative tool chain, full meta). Long chains (> 6) collapse + the middle behind a "Show N earlier versions" button. */ +.files-page-details-version-timeline { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.55rem 0.7rem 0.7rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} +.files-page-details-version-timeline-label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} +.files-page-details-version-timeline-count { + margin-left: auto; + font-weight: 600; + color: var(--text-secondary, var(--text-muted)); + text-transform: none; + letter-spacing: 0; +} +.files-page-details-version-timeline-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.files-page-details-version-timeline-row, +.files-page-details-version-timeline-ellipsis { + display: flex; + gap: 0.6rem; + padding: 0.15rem 0; + position: relative; +} +.files-page-details-version-timeline-rail { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + width: 0.8rem; + padding-top: 0.45rem; +} +.files-page-details-version-timeline-rail-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--bg-toolbar); + border: 2px solid var(--border-strong, var(--border-subtle)); + z-index: 1; + flex-shrink: 0; +} +.files-page-details-version-timeline-rail-dot.is-active { + background: var(--accent-interactive, #6366f1); + border-color: var(--accent-interactive, #6366f1); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--accent-interactive, #6366f1) 25%, transparent); +} +.files-page-details-version-timeline-rail-dot.is-ellipsis { + width: 0.35rem; + height: 0.35rem; + background: var(--text-muted); + border-color: transparent; +} +.files-page-details-version-timeline-rail-line { + width: 2px; + flex: 1; + background: var(--border-subtle); + min-height: 0.6rem; + margin-top: 2px; +} +.files-page-details-version-timeline-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.25rem 0.3rem 0.4rem; + border-radius: 0.35rem; + transition: background-color 0.12s ease; +} +.files-page-details-version-timeline-row.is-active + .files-page-details-version-timeline-body { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 10%, + transparent + ); +} +.files-page-details-version-timeline-summary { + appearance: none; + background: none; + border: 0; + padding: 0; + margin: 0; + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; +} +.files-page-details-version-timeline-summary:hover + .files-page-details-version-timeline-chevron { + color: var(--text-primary); +} +.files-page-details-version-timeline-delta { + font-size: 0.82rem; + color: var(--text-primary); + 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(--text-muted); + font-style: italic; +} +.files-page-details-version-timeline-delta-plus { + color: var(--accent-interactive, #6366f1); + font-weight: 700; +} +.files-page-details-version-timeline-spacer { + flex: 1; +} +.files-page-details-version-timeline-chevron { + color: var(--text-muted); + transition: transform 0.15s ease; +} +.files-page-details-version-timeline-chevron.is-expanded { + transform: rotate(180deg); + color: var(--text-primary); +} +.files-page-details-version-timeline-meta-line { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.7rem; + color: var(--text-muted); +} +.files-page-details-version-timeline-expanded { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.45rem; + padding-top: 0.5rem; + border-top: 1px dashed var(--border-subtle); +} +.files-page-details-version-timeline-toolchain { + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.files-page-details-version-timeline-toolchain-label { + font-size: 0.65rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.files-page-details-version-timeline-ellipsis-btn, +.files-page-details-version-timeline-collapse-btn { + appearance: none; + background: none; + border: 1px dashed var(--border-subtle); + border-radius: 0.3rem; + padding: 0.25rem 0.5rem; + margin: 0.1rem 0; + font-size: 0.72rem; + color: var(--text-muted); + cursor: pointer; + text-align: left; + transition: + border-color 0.12s ease, + color 0.12s ease; +} +.files-page-details-version-timeline-ellipsis-btn:hover, +.files-page-details-version-timeline-collapse-btn:hover { + color: var(--text-primary); + border-color: var(--border-strong, var(--text-muted)); +} +.files-page-details-version-timeline-collapse-btn { + align-self: flex-start; + margin-top: 0.2rem; +} + +.files-page-drop-overlay { + position: absolute; + inset: 1rem; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 14%, + var(--bg-background) + ); + border: 2px dashed var(--accent-interactive, #6366f1); + border-radius: 1rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + pointer-events: none; + color: var(--accent-interactive, #6366f1); + font-weight: 600; + font-size: 1.1rem; + z-index: 10; + animation: files-page-drop-overlay-pulse 1.6s ease-in-out infinite; + backdrop-filter: blur(2px); +} + +.files-page-drop-overlay-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 4.5rem; + height: 4.5rem; + border-radius: 50%; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 30%, + transparent + ); +} + +.files-page-drop-overlay-icon svg { + font-size: 2.5rem !important; + color: var(--bg-background); +} + +.files-page-drop-overlay-sub { + font-size: 0.85rem; + font-weight: 500; + color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 80%, + var(--text-secondary) + ); +} + +@keyframes files-page-drop-overlay-pulse { + 0%, + 100% { + border-color: var(--accent-interactive, #6366f1); + transform: scale(1); + } + 50% { + border-color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 60%, + transparent + ); + transform: scale(1.005); + } +} + +@media (max-width: 1024px) { + .files-page-tree { + width: 14rem; + } + .files-page-details { + width: 18rem; + } +} + +@media (max-width: 800px) { + /* Tree slot inside the inline layout can't fit at this width - the + tree itself is still reachable via the FolderTreePanel slide-out. + Details panel visibility is controlled by JSX (conditional Drawer) + at this width, not CSS, so we don't blanket-hide .files-page-details + here - that would also hide it when it lives inside the Drawer's + portaled overlay tree. */ + .files-page-tree { + display: none; + } + .files-page-content { + padding: 0.75rem; + } +} + +/* ── Tablet / narrow desktop (≤900px) ──────────────────────────────── + As soon as the right-hand details panel disappears (at 800px) and + even slightly before, the toolbar gets crowded - the bulk-action + strip ("Add to workspace", "Quick view", "Move to…", "Remove") + + filter selects + grid/list toggle is too wide to fit. This block + collapses bulk-action button labels to icon-only, shrinks the + filter selects, and pins the view toggle so it never clips off the + right edge. The header stays in desktop layout here (it has fewer + items and the side panels disappearing actually GIVES it room). */ +.files-page-header [data-desktop-hide="true"] { + /* The mobile-only overflow menu trigger. Hidden by default; shown + inside the mobile breakpoint below. Live as inline-flex so it + sits next to the Upload button without breaking the action row. */ + display: none; +} +@media (max-width: 900px) { + .files-page-toolbar { + /* nowrap so "7 items" + "Select all" sit on the same row as the + filter dropdowns and view-toggle instead of stacking on three + separate lines. Per-child min-width:0 lets them shrink as needed. + Used to only kick in at ≤640px which left a broken zone where + both side panels were hidden but the toolbar was still wrapping + to multiple rows. */ + flex-wrap: nowrap; + gap: 0.35rem; + padding: 0.35rem 0.5rem; + min-height: auto; + overflow-x: hidden; + } + .files-page-toolbar-info { + /* Was `flex-basis: 100%` which forced a row break. Let it share + the row, shrink hard if needed, and ellipsize so the count line + collapses gracefully (was overlapping the bulk-action buttons + at ~400px because no truncation rule existed). */ + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .files-page-toolbar-actions { + flex-wrap: nowrap; + gap: 0.3rem; + } + .files-page-toolbar-actions .mantine-Select-root, + .files-page-toolbar-actions .mantine-Select-input { + /* Mantine Select defaults to ~12rem which is huge for narrow widths. + The inline `style={{ width: 140|160 }}` on the Selects gets + overridden here so they actually shrink. */ + min-width: 0; + max-width: 7.5rem; + width: auto !important; + } + /* The "Select all" / "Clear selection" toggle is a Mantine Button + directly inside .files-page-toolbar (NOT inside the actions group), + so the icon-only collapse from base styles doesn't apply to it. + Pin it to full text + nowrap so it never truncates to "Select al" + mid-word. */ + .files-page-toolbar > .mantine-Button-root { + flex-shrink: 0; + white-space: nowrap; + } +} + +/* ── Mobile (≤640px) ──────────────────────────────────────────────── + Tighten the chrome so the file manager fits inside the mobile + workspace slide. The slide-rail's bottom tab bar already provides + navigation, so the in-header Home/Apps/Close trio is duplicated + and the first to go. Same for "Upload" - the user can use the + centre drop overlay. */ +@media (max-width: 640px) { + /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto + * its own row when chrome is too cramped to share. */ + .files-page-header { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0 0.4rem; + overflow-x: hidden; + } + .files-page-header [data-mobile-hide="true"] { + display: none !important; + } + .files-page-header [data-desktop-hide="true"] { + display: inline-flex !important; + } + /* Mobile-hide for sub-toolbar create buttons. */ + .files-page-toolbar [data-mobile-hide="true"] { + display: none !important; + } + .files-page-header-actions { + margin-left: auto; + gap: 0.3rem; + flex-wrap: nowrap; + } + .files-page-search { + flex: 1 1 100%; + } + .files-page-breadcrumbs { + font-size: 0.85rem; + flex-wrap: nowrap; + 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" + label (Back has no other on-screen indicator that it's about leaving). */ + .files-page-header-actions .mantine-Button-root { + padding-left: 0.55rem; + padding-right: 0.55rem; + } + .files-page-header-actions .mantine-Button-section[data-position="left"] { + margin-right: 0; + } + .files-page-header-actions .mantine-Button-label { + display: none; + } + /* Grid: single column on very narrow phones; two columns from ~440px */ + .files-page-grid { + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0.75rem; + } + .files-page-content { + padding: 0.5rem; + } +} + +@media (max-width: 420px) { + /* Don't force 1fr - auto-fill with the 9rem floor already keeps two cards + per row on a 360px phone, which feels right for one-handed taps. The + real fix at this width is the list view, which had 6 columns that wouldn't + fit at all. */ + .files-page-grid { + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.5rem; + } + /* List view at tiny widths becomes cramped - drop the size + type + modified + columns and let name + actions take the room. */ + .files-page-list-row { + grid-template-columns: 1.75rem minmax(0, 1fr) 2rem; + } + .files-page-list-row > :nth-child(3), + .files-page-list-row > :nth-child(4), + .files-page-list-row > :nth-child(5) { + display: none; + } +} + +/* Honour user preference for reduced motion (WCAG 2.3.3). Disables the + open-badge pulse, drop-overlay pulse + scale, and the card hover lift - + keeps focus rings and selection borders so state is still visible. */ +@media (prefers-reduced-motion: reduce) { + .files-page-card-open-dot, + .files-page-drop-overlay { + animation: none; + } + .files-page-card, + .files-page-card-thumb img { + transition: none; + } + .files-page-card:hover { + transform: none; + } + .files-page-card.is-folder.is-drop-target { + transform: none; + } + .files-page-card.is-folder.is-drop-target .files-page-card-thumb { + transform: none; + } +} diff --git a/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx new file mode 100644 index 0000000000..c5f3479038 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx @@ -0,0 +1,178 @@ +/** + * Inline colour + icon picker for a folder. Rendered inside the folder + * kebab menu (Mantine Menu.Item with a custom body) so the menu can + * still own close-on-outside-click behaviour. + */ + +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; + +import { FolderRecord, FOLDER_COLOR_PALETTE } from "@app/types/folder"; +import { + FOLDER_ICONS, + FolderIconOption, +} from "@app/components/filesPage/folderIcons"; + +interface FolderAppearancePickerProps { + folder: FolderRecord; + onChange: (next: { color?: string; icon?: string | null }) => void; + /** When true, all colour + icon buttons are unresponsive (e.g. while offline). */ + disabled?: boolean; +} + +export function FolderAppearancePicker({ + folder, + onChange, + disabled = false, +}: FolderAppearancePickerProps) { + const { t } = useTranslation(); + + return ( +
+
+
+ {FOLDER_COLOR_PALETTE.map((c) => ( +
+
+ +
+
+ {FOLDER_ICONS.map((icon) => ( + + onChange({ icon: icon.id === "none" ? null : icon.id }) + } + /> + ))} +
+
+
+ ); +} + +function Section({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function IconButton({ + icon, + selected, + onClick, + disabled = false, +}: { + icon: FolderIconOption; + selected: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx new file mode 100644 index 0000000000..4197d652ed --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +interface FolderNameDialogProps { + opened: boolean; + title: string; + initialName?: string; + submitLabel: string; + onClose: () => void; + onSubmit: (name: string) => void | Promise; +} + +export function FolderNameDialog({ + opened, + title, + initialName = "", + submitLabel, + onClose, + onSubmit, +}: FolderNameDialogProps) { + const { t } = useTranslation(); + const [value, setValue] = useState(initialName); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setValue(initialName); + setSubmitting(false); + setError(null); + } + }, [opened, initialName]); + + const submit = async () => { + const name = value.trim(); + if (!name) return; + setSubmitting(true); + setError(null); + try { + await onSubmit(name); + onClose(); + } catch (err) { + // Keep dialog open so the user can retry. Closing on error was a + // silent failure (the dialog vanished, but the folder was never + // created - user thinks success, sees no folder). + setError( + err instanceof Error + ? err.message + : t( + "filesPage.folderName.error", + "Could not save folder. Try again.", + ), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + + setValue(e.currentTarget.value)} + placeholder={t("filesPage.folderName.placeholder", "Folder name")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + maxLength={120} + aria-label={t("filesPage.folderName.label", "Folder name")} + /> + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx new file mode 100644 index 0000000000..b8e68f9f7f --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx @@ -0,0 +1,170 @@ +/** + * Stylised folder thumbnail. + * + * A custom SVG folder shape that takes its accent colour from + * `FolderRecord.color` and shows the contained-file count as a small badge + * in the corner. Renders proportionally inside whatever container it's + * placed in (file card thumb, list-row icon). + */ + +import React, { useId } from "react"; + +interface FolderThumbnailProps { + color?: string; + fileCount?: number; + /** Visual scale - "thumb" for cards, "row" for list rows, "tree" for nav. */ + size?: "thumb" | "row" | "tree"; + /** Optional glyph (emoji) overlaid in the centre of the front pocket. */ + iconGlyph?: string; +} + +const SIZE_PX: Record, number> = { + thumb: 96, + row: 22, + tree: 18, +}; + +export function FolderThumbnail({ + color, + fileCount, + size = "thumb", + iconGlyph, +}: FolderThumbnailProps) { + const accent = color ?? "var(--accent-interactive, #6366f1)"; + const px = SIZE_PX[size]; + const showBadge = size === "thumb" && (fileCount ?? 0) > 0; + // Per-instance unique ids - `${color}` previously embedded `#` and CSS + // function syntax in the id, which broke `url(#...)` references (Safari + // would parse the inner `#` as a new fragment start and the lookup + // would miss entirely, leaving the folder shape unfilled). + const reactId = useId(); + const backId = `${reactId}-back`; + const frontId = `${reactId}-front`; + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.css b/frontend/editor/src/core/components/filesPage/FolderTreePanel.css new file mode 100644 index 0000000000..2b75efe685 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.css @@ -0,0 +1,198 @@ +/* Secondary navigator panel that slides out from the main FileSidebar + when the user enters the My Files workbench. + + Pattern: + - Outer panel is a flex item whose `width` transitions. The inner + content keeps a fixed natural width and the outer `overflow: hidden` + clips it during the slide so the user sees the panel grow from the + edge rather than the content shrinking. + - Inner content separately fades + nudges in for an attentive feel. + + Styled to mirror the main FileSidebar - same toolbar background, same + section-header treatment, same icon weight - so the two read as one + unified surface. */ + +.folder-tree-panel { + width: 0; + flex-shrink: 0; + background: var(--bg-toolbar); + border-right: 0 solid var(--border-subtle); + overflow: hidden; + height: 100%; + pointer-events: none; + position: relative; +} + +.folder-tree-panel[data-active="true"] { + width: var(--folder-tree-panel-width, 16rem); + border-right-width: 1px; + pointer-events: auto; +} + +.folder-tree-panel-inner { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-y: auto; + opacity: 0; + transform: translateX(-1rem); + /* Only fade/slide; width is driven by the inline custom property so + * dragging the resizer doesn't animate. */ + transition: + opacity 0.18s ease, + transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.folder-tree-panel[data-active="true"] .folder-tree-panel-inner { + opacity: 1; + transform: translateX(0); + transition-delay: 0.04s; +} + +/* Drag handle on the right edge. */ +.folder-tree-panel-resizer { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 2; + background: transparent; + transition: background-color 0.15s ease; +} + +.folder-tree-panel-resizer:hover, +.folder-tree-panel-resizer:focus-visible { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 35%, + transparent + ); + outline: none; +} + +/* Section header - matches .file-sidebar-section-header in FileSidebar.css */ +.folder-tree-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px 6px 18px; + margin-top: 4px; + flex-shrink: 0; +} + +.folder-tree-panel-title { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); + text-transform: uppercase; +} + +/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the + slide-out folder navigator reads as a continuation of the main sidebar's + design language. Same row height, padding, font weight, muted icon + treatment. The hover/active state uses the same --hover-bg pill that + the sidebar's other rows do, with no heavy accent bar. */ + +.files-page-tree-list { + display: flex; + flex-direction: column; + padding: 4px 0 12px; +} + +.files-page-tree-node { + display: flex; + align-items: center; + height: 32px; + padding: 0 14px; + border-radius: 4px; + margin: 0 8px; + cursor: pointer; + user-select: none; + color: var(--text-secondary); + font-size: 14px; + position: relative; + transition: background-color 0.15s ease; + flex-shrink: 0; +} + +.files-page-tree-node:hover { + background: var(--hover-bg); +} + +.files-page-tree-node.is-active { + background: var(--hover-bg); + color: var(--text-primary); + font-weight: 500; +} + +.files-page-tree-node.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1); + color: var(--text-primary); +} + +.files-page-tree-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--text-muted); + flex-shrink: 0; +} + +.files-page-tree-toggle svg { + font-size: 16px !important; +} + +.files-page-tree-spacer { + display: inline-block; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.files-page-tree-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-muted); + margin-left: 8px; + font-size: 18px; +} + +.files-page-tree-icon svg { + font-size: 18px !important; +} + +.files-page-tree-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 12px; +} + +.files-page-tree-count { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + margin-left: 8px; +} + +@media (max-width: 900px) { + /* Cap the user width on narrow viewports so the tree can't squeeze + * out the file grid. The custom property is still honoured but capped. */ + .folder-tree-panel[data-active="true"] { + width: min(var(--folder-tree-panel-width, 14rem), 14rem); + } +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx new file mode 100644 index 0000000000..eb7f19170c --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -0,0 +1,169 @@ +/** Folder tree navigator panel rendered next to FileSidebar on /files. */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar"; +import { useFilesPage } from "@app/contexts/FilesPageContext"; +import { useFolders } from "@app/contexts/FolderContext"; +import { FileId } from "@app/types/file"; +import { FolderId, FolderRecord } from "@app/types/folder"; +import { + MIN_WIDTH, + MAX_WIDTH, + clamp, + computeAutoFitWidth, + loadPersistedWidth, + savePersistedWidth, +} from "@app/components/filesPage/folderTreeWidth"; + +import "@app/components/filesPage/FolderTreePanel.css"; + +interface FolderTreePanelProps { + active: boolean; +} + +export function FolderTreePanel({ active }: FolderTreePanelProps) { + const { t } = useTranslation(); + const { + fileCountsByFolder, + openNewFolderDialog, + openRenameFolderDialog, + promptDeleteFolder, + moveFilesTo, + } = useFilesPage(); + const folders = useFolders(); + const rootLabel = t("filesPage.allFiles", "All files"); + + const [width, setWidth] = useState(() => { + const persisted = loadPersistedWidth(); + return persisted ?? 256; + }); + const userSetRef = useRef(loadPersistedWidth() !== null); + + // Auto-fit to the longest folder name on first render and whenever the + // folder list grows; skipped once the user manually resizes. + useEffect(() => { + if (userSetRef.current) return; + const auto = computeAutoFitWidth(folders.folders, rootLabel); + setWidth(auto); + }, [folders.folders, rootLabel]); + + const dragStateRef = useRef<{ + startX: number; + startWidth: number; + } | null>(null); + + const onMouseMove = useCallback((e: MouseEvent) => { + const state = dragStateRef.current; + if (!state) return; + const next = clamp(state.startWidth + (e.clientX - state.startX)); + setWidth(next); + }, []); + + const onMouseUp = useCallback(() => { + const state = dragStateRef.current; + if (!state) return; + dragStateRef.current = null; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + userSetRef.current = true; + setWidth((current) => { + savePersistedWidth(current); + return current; + }); + }, [onMouseMove]); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragStateRef.current = { startX: e.clientX, startWidth: width }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [onMouseMove, onMouseUp, width], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 32 : 8; + let next: number | null = null; + if (e.key === "ArrowLeft") next = clamp(width - step); + else if (e.key === "ArrowRight") next = clamp(width + step); + else if (e.key === "Home") next = MIN_WIDTH; + else if (e.key === "End") next = MAX_WIDTH; + if (next === null) return; + e.preventDefault(); + userSetRef.current = true; + setWidth(next); + savePersistedWidth(next); + }, + [width], + ); + + return ( +
+
+
+ + {t("filesPage.myFiles", "My Files")} + +
+ + + openRenameFolderDialog(folder) + } + onDeleteFolder={promptDeleteFolder} + onMoveFilesIntoFolder={async ( + targetId: FolderId | null, + fileIds: FileId[], + ) => { + if (fileIds.length === 0) return; + await moveFilesTo(fileIds, targetId); + }} + /> +
+ {active && ( +
{ + const auto = computeAutoFitWidth(folders.folders, rootLabel); + userSetRef.current = false; + setWidth(auto); + savePersistedWidth(auto); + }} + /> + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx new file mode 100644 index 0000000000..72bd4186ec --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx @@ -0,0 +1,490 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Menu } from "@mantine/core"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import HomeIcon from "@mui/icons-material/Home"; +import DevicesOtherIcon from "@mui/icons-material/DevicesOther"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; + +import { useFolders } from "@app/contexts/FolderContext"; +import { FileId } from "@app/types/file"; +import { + FolderId, + FolderRecord, + FolderTreeNode, + ROOT_FOLDER_ID, +} from "@app/types/folder"; +import { useFilesPage } from "@app/contexts/FilesPageContext"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, + serialiseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { useDropTarget } from "@app/components/filesPage/useDropTarget"; + +/** + * Hard cap on folder-tree render depth. The backend already enforces an + * application-level depth limit via cycle detection + folder-count cap, + * and React's render stack handles ~50 nested components comfortably, + * so this is purely defensive against a corrupted IDB cache producing + * a chain deeper than the server would allow. + */ +const MAX_TREE_DEPTH = 50; + +interface FolderTreeSidebarProps { + fileCounts: Map; + onRequestNewFolder: (parentId: FolderId | null) => void; + onRenameFolder: (folder: FolderRecord) => void; + onDeleteFolder: (folder: FolderRecord) => void; + /** + * Move the *dragged* files (from the drop payload) into the target folder. + * Earlier signature took only the folder id and the parent then used the + * current selection - which silently moved the wrong files whenever the + * user dragged something that wasn't in the selection. + */ + onMoveFilesIntoFolder: ( + folderId: FolderId | null, + fileIds: FileId[], + ) => Promise | void; +} + +// This component is always rendered inside FolderTreePanel, which supplies +// its own