Compare commits

...
Author SHA1 Message Date
Anthony Stirling bf77d67c00 Merge branch 'main' into desktopInstallButton 2026-05-28 15:57:43 +01:00
Anthony Stirling c80a5db5f5 folder and file fixes (#6461) 2026-05-28 15:57:35 +01:00
Anthony Stirling 4fa67afc3d Fix Tauri artifact copy path so installers upload (smoke + release) (#6466)
## Summary
Regression from #6404 (Restructure/frontend editor). Two CI workflows
copy the built installers to the wrong directory, so installer artifacts
(MSI / DMG / DEB / RPM / AppImage) silently vanish:

- **`tauri-build.yml`** (PR/desktop smoke builds) - uploads zero
installer artifacts.
- **`multiOSReleases.yml`** (production releases) - the empty artifacts
are downloaded by `create-release` and fed to `action-gh-release`, so a
release would publish **only the JARs, no desktop installers**.

## Root cause
#6404 moved the Tauri project from `frontend/` to `frontend/editor/` and
updated every **absolute** path (`projectPath`, `cd`, `Get-ChildItem`)
to add the `editor/` segment - but left the **relative** copy targets
`../../../dist`. Those resolve against the (now one level deeper)
working dir after `cd ./frontend/editor/src-tauri/target`:

| | resolves to |
|---|---|
| before #6404 (`frontend/src-tauri/target`) | repo-root `dist/`  |
| after #6404 (`frontend/editor/src-tauri/target`) | `frontend/dist/` 
(missing) |

The `cp` fails, repo-root `dist/` (from `mkdir -p ./dist`) stays empty,
and the upload finds nothing. `find -exec cp` failing is non-fatal, so
jobs still report success - that's why it went unnoticed. No release has
shipped broken yet: the last release (v2.11.0, 2026-05-19) predates
#6404 (2026-05-22).

## Fix
Copy to an absolute `$GITHUB_WORKSPACE/dist` in both workflows so the
`cd` can't drift the destination again. This matches where the upload /
signature-verify steps already read from.

## Evidence (run 26574078559, all 3 OS legs)
```
cp: cannot create regular file '../../../dist/Stirling-PDF-windows-x86_64.msi': No such file or directory
##[warning]No files were found with the provided path: ./dist/*. No artifacts will be uploaded.
```
The Tauri builds themselves succeeded - only the copy/upload was broken.

## Test plan
- [ ] `tauri-build` on this PR uploads non-empty `Stirling-PDF-<name>`
artifacts on Windows/macOS/Linux.
- [ ] Next release (or a `workflow_dispatch` of multiOSReleases)
attaches MSI/DMG/DEB/RPM/AppImage to the release.
2026-05-28 15:57:01 +01:00
Anthony StirlingandConnorYoh 8bd78d2624 Add landscape page size options (#6248)
# Description of Changes

Adds orientation (portrait/landscape) to the Adjust Page Scale tool.

- Orientation as a separate parameter (per review), sent through to the
backend
- ScalePagesController simplified; PDFWithPageSize gains the orientation
field
- Regenerated tool_models.py; frontend + backend tests added

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2026-05-28 14:16:15 +00:00
Anthony Stirling b3c4b8b463 Add S3 storage and cluster artifact backend (#6457) 2026-05-28 13:06:27 +01:00
James Brunton 57af5b9dc2 Fix Tauri testing (#6462)
# Description of Changes
#6402 introduced a Rust test `refresh_token_fallback.rs`, but it wasn't
moved properly after the restructure of the `frontend/` folder in #6404.
This PR moves the file to the right place, and also hooks up Task and CI
rules for `cargo test` since nothing was actually running the test in
the first place.
2026-05-28 11:05:56 +00:00
James Brunton 44fbf8c587 Various bug fixes found while testing SaaS build (#6459)
# Description of Changes
Various fixes and improvements I made while testing the SaaS code:
- Changes the new `.env.saas` file to live in `app/` and match the
semantics of the other `.env` files
- Adds top-level `task dev:saas` command to spawn SaaS frontend &
backend
- Deletes dead SaaS code and improves some overriding logic
- Fixes refreshing issue when coming back to the tab
- Fix the Compare tool's selection logic
- Make Compare handle error cases properly
- Fixes the location of the "Dismiss All Errors" button (was rendering
on top of the top-bar with a transparent background previously so it
looked rubbish)
- Fixes file selection in PDF Editor
2026-05-28 11:05:30 +00:00
Anthony Stirling 76840d8a57 Add CI DB migration smoke test against v2.0/v2.5/v2.10 updates (#6453) 2026-05-28 11:36:07 +01:00
James Brunton d459ded168 Add cancel button to kill long-running AI tasks (#6351)
# Description of Changes
Adds a cancel button to the AI chat to allow the user to abort
long-running AI tasks. Just disconnects the SSE stream (all the backend
code already interrupts when it notices the stream is dead).
2026-05-28 09:25:23 +00:00
ConnorYoh 43b67d213d feat(oauth2): opt-in claim-dump diagnostics for OIDC login failures (#6456)
# Description of Changes

## What & why

Customers using ADFS (or any generic OIDC provider that doesn't emit
`email`) hit `Attribute value for 'email' cannot be null` during OAuth2
login with no visibility into what claims the provider actually sent.
The only available remedy was guessing at
`security.oauth2.useAsUsername` until something worked.

This PR adds a new opt-in `security.oauth2.debugLogging` flag (default
`false`). When enabled, `CustomOAuth2UserService` logs:

- All ID token claims (sorted, with values)
- All UserInfo endpoint claims (if any)
- The merged attribute key set Spring exposes to `getAttribute()`
- The value the configured `useAsUsername` actually resolved to
- A **`Hint:`** line listing the claim keys present in the token that
map to a valid `UsernameAttribute` enum value — i.e. exactly what the
operator could put in `useAsUsername` to make login work

Logged at `INFO` on the success path and `ERROR` on failure (inside the
existing `catch (IllegalArgumentException)` block that throws
`OAuth2AuthenticationException`). The block is wrapped with a `[OAUTH2
DEBUG] ... [/OAUTH2 DEBUG]` banner and ends with a PII warning so
operators don't leave it on in production.

Default off → zero observable change for anyone not actively
troubleshooting.

## Files changed

| File | Why |
|---|---|
| `app/common/.../ApplicationProperties.java` | New `debugLogging` field
on the `OAUTH2` config class with javadoc warning about PII |
| `app/core/src/main/resources/settings.yml.template` | Documents
`oauth2.debugLogging` so it appears on next startup |
| `app/proprietary/.../security/service/CustomOAuth2UserService.java` |
Emits the claim dump + suggestion hint when the flag is on |
|
`app/proprietary/.../security/service/CustomOAuth2UserServiceDebugLoggingTest.java`
(new) | Unit test: mocks the OIDC delegate, asserts off-path is silent
and on-path emits the dump with the right Hint contents |

## End-to-end verification

Ran the bundled `testing/compose/docker-compose-keycloak-oauth.yml`
Keycloak realm, configured `security.oauth2.useAsUsername: mail`
(Keycloak emits `email`, not `mail`) and `provider: demarest` (matches
the original customer bug report). Triggered the OAuth flow at
`http://localhost:8080/oauth2/authorization/demarest` and confirmed:

- The ERROR-level dump fires with the full 19-claim ID token decoded
- `-- Value at 'mail' : <NULL — this is why login fails>` correctly
identifies the missing claim
- `-- Hint:` correctly suggests `[email, family_name, given_name,
preferred_username]` (the four keys present that map to valid
`UsernameAttribute` values)
- Auth still fails with the original `OAuth2AuthenticationException` —
no change to control flow, just added diagnostic logging

Unit test (`CustomOAuth2UserServiceDebugLoggingTest`) covers both
branches.

## Reviewer notes

- **No new public APIs.** The flag is config-only; no servlet endpoints
exposed.
- **PII is logged when the flag is on.** This is the whole point —
operators need to see the claims to fix their config — but it's gated,
defaults off, and the dump self-documents with a `WARNING: ... Set
security.oauth2.debugLogging=false once troubleshooting is complete.`
footer.
- **Why log everything, not just sub/email?** Because the operator
doesn't know in advance which claim they actually want. ADFS uses `upn`
in some configs and `preferred_username` in others; Azure AD uses `oid`;
the customer here had neither. Dumping the full set is the only way to
make the diagnostic self-service.
- **Out of scope for this PR (follow-ups):**
- The `UsernameAttribute` enum doesn't include `upn` / `unique_name`
(common ADFS claims). If the customer's token only has `upn`, the Hint
will be empty even though the operator can see `upn` in the dump. Worth
a separate PR to extend the enum.
- The known-provider validator in `Provider.java` (rejects e.g.
`useAsUsername: mail` for `provider: keycloak` at startup) bypasses our
diagnostic for those provider names. ADFS customers using `provider:
<name>` fall into the `default` branch so are not affected — but it's a
sharp edge worth documenting.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable) — N/A, backend-only change
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] Doc-repo update (if functionality has heavily changed) —
diagnostic flag is self-documenting via the `settings.yml.template`
comment and the in-log warning; happy to add a doc-repo entry if
reviewers want one
- [ ] Translation tags — N/A

### UI Changes (if applicable)

- [ ] N/A — backend-only

### Testing (if applicable)

- [x] Unit test added (`CustomOAuth2UserServiceDebugLoggingTest`)
covering on/off paths and Hint correctness
- [x] End-to-end verified locally against bundled Keycloak compose with
intentionally misconfigured `useAsUsername`
- [x] Full `:proprietary:test` suite passes
2026-05-27 13:01:51 +00:00
Anthony Stirling d42b779644 Add server-side folders and files page UI (#6383) 2026-05-27 12:52:46 +01:00
Anthony Stirling 930dc5c018 Use universal Mac installer + centralized installer URLs 2026-05-26 19:44:10 +01:00
Anthony Stirling c2cc7ede10 Merge branch 'main' into desktopInstallButton 2026-05-26 17:50:56 +01:00
Anthony Stirling a95db1aa8b Restore download URL fallback for non-desktop installs 2026-05-26 15:14:29 +01:00
Anthony Stirling 45da220060 desktop install button 2026-05-26 15:13:59 +01:00
174 changed files with 19825 additions and 1651 deletions
+2
View File
@@ -38,6 +38,8 @@ project: &project
- frontend/**
- docker/**
- scripts/RestartHelper.java
- scripts/db-migration/**
- .github/workflows/db-migration-test.yml
frontend: &frontend
- frontend/**
+2 -2
View File
@@ -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
@@ -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_**")
+2 -1
View File
@@ -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
+14
View File
@@ -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
@@ -208,6 +221,7 @@ jobs:
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 }}
+93
View File
@@ -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
+9 -7
View File
@@ -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
+11 -6
View File
@@ -157,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
@@ -417,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.
+7 -1
View File
@@ -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
@@ -174,7 +178,6 @@ venv.bak/
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
.env*
!.env.saas.example
# VS Code
/.vscode/**/*
@@ -274,3 +277,6 @@ docs/type3/signatures/
# Playwright MCP screenshots / traces
.playwright-mcp/
*.playwright-mcp.png
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/screenshots/
+3 -3
View File
@@ -40,10 +40,10 @@ tasks:
platforms: [linux, darwin]
dev:saas:
desc: "Start backend in SaaS flavor against Supabase (loads .env.saas.local)"
desc: "Start backend in SaaS flavor against Supabase"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`. Drop the file at the repo root.
dotenv: ['.env.saas.local']
# subtaskfile is included with `dir: .`.
dotenv: ['app/.env.saas.local', 'app/.env.saas']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
+7
View File
@@ -78,6 +78,13 @@ tasks:
cmds:
- 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
+17
View File
@@ -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:
+9 -9
View File
@@ -1,20 +1,20 @@
###############################################################################
# Stirling-PDF SaaS local environment template.
# Stirling-PDF SaaS environment defaults.
#
# Copy this file to `.env.saas.local` (gitignored) and fill in real values.
# Loaded by `task backend:dev:saas` via Taskfile's `dotenv:` directive, then
# read by Spring Boot's `${...}` placeholders in application-saas.properties
# and application-dev.properties.
# 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.example` is checked in.
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
###############################################################################
# ---------- Supabase project ----------
# Project reference (the subdomain part of <ref>.supabase.co). Required.
# Example dev project:
# Set in .env.saas.local.
SAAS_DB_PROJECT_REF=
# Edge function secret used by billing/license rollup calls.
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
SUPABASE_EDGE_FUNCTION_SECRET=
# ---------- Database (saas profile) ----------
@@ -28,7 +28,7 @@ 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 here.
# password must still be provided in .env.saas.local.
SAAS_DEV_DB_URL=
SAAS_DEV_DB_USERNAME=postgres
SAAS_DEV_DB_PASSWORD=
+3
View File
@@ -0,0 +1,3 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
+4
View File
@@ -44,6 +44,10 @@
"moduleName": ".*",
"moduleLicense": "The MIT License"
},
{
"moduleName": ".*",
"moduleLicense": "MIT-0"
},
{
"moduleName": "com.github.jai-imageio:jai-imageio-core",
"moduleLicense": "LICENSE.txt"
@@ -528,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<String> scopesList =
Arrays.stream(scopes.split(",")).map(String::trim).toList();
@@ -778,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();
@@ -787,6 +798,57 @@ public class ApplicationProperties {
private String basePath = InstallationPathConfig.getPath() + "storage";
}
@Data
public static class S3 {
/**
* Optional custom endpoint (e.g. {@code https://<account>.r2.cloudflarestorage.com},
* {@code https://<project>.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 <endpoint>/<bucket>/<key>}) instead of
* virtual-hosted ({@code <bucket>.<endpoint>/<key>}). 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;
@@ -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/<folder-uuid>) 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",
@@ -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/[^/]+/?$");
@@ -98,6 +98,17 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
}
@Test
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
// /files and /files/<folder-uuid> 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(
@@ -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<String, PDRectangle> 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<String, PDRectangle> getSizeMap() {
Map<String, PDRectangle> 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);
@@ -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,44 @@ 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 + ":" + appConfig.getServerPort();
}
return "";
}
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 +147,7 @@ public class ConfigController {
}
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig() {
public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest request) {
Map<String, Object> configData = new HashMap<>();
try {
@@ -124,17 +164,7 @@ public class ConfigController {
configData.put("serverPort", appConfig.getServerPort());
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(
@@ -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/<folder-uuid>
// 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<String> 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<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@@ -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<ProblemDetail> 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<ProblemDetail> handleRuntimeException(
RuntimeException ex, HttpServletRequest request) {
@@ -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";
}
@@ -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
@@ -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='<your-region>' pathStyleAccess=false
# Cloudflare R2 -> endpoint='https://<acct>.r2.cloudflarestorage.com' region='auto'
# pathStyleAccess=false; if uploads fail with 'unsupported header
# x-amz-checksum-*' set requestChecksumCalculation=WHEN_REQUIRED
# Supabase Storage -> endpoint='https://<project>.supabase.co/storage/v1/s3'
# region='<project-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.<region>.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://<region>.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
@@ -335,6 +369,8 @@ 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:
@@ -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();
@@ -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,71 @@ 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"));
}
}
+2
View File
@@ -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/
+9
View File
@@ -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') {}
@@ -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()
? "<aws-default>"
: 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;
}
}
}
@@ -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<GetObjectResponse> 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<GetObjectResponse> 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;
}
}
@@ -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);
}
}
@@ -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
@@ -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");
}
}
}
@@ -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<OidcUserReques
@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
boolean debugLogging = Boolean.TRUE.equals(oauth2Properties.getDebugLogging());
// Resolved inside the try so a bad/null useAsUsername (IllegalArgumentException from
// valueOf, or NPE on toUpperCase) is caught and wrapped as OAuth2AuthenticationException
// by the existing handlers below, matching the pre-debugLogging behaviour.
String usernameAttributeKey = null;
try {
OidcUser user = delegate.loadUser(userRequest);
String usernameAttributeKey =
usernameAttributeKey =
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
.getName();
OidcUser user = delegate.loadUser(userRequest);
if (debugLogging) {
logClaimDump(
"OAuth2/OIDC login claims received",
registrationId,
usernameAttributeKey,
user.getIdToken(),
user.getUserInfo(),
user.getAttributes(),
false);
}
// Extract SSO provider information
String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim
String ssoProvider = userRequest.getClientRegistration().getRegistrationId();
String username = user.getAttribute(usernameAttributeKey);
log.debug(
"OAuth2 login - Provider: {}, ProviderId: {}, Username: {}",
ssoProvider,
registrationId,
ssoProviderId,
username);
@@ -79,10 +102,154 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
usernameAttributeKey);
} catch (IllegalArgumentException e) {
log.error("Error loading OIDC user: {}", e.getMessage());
// Only emit the claim dump if we successfully resolved usernameAttributeKey. A null
// value here means UsernameAttribute.valueOf rejected the configured useAsUsername
// before delegate.loadUser ran — that error message is self-explanatory and a claim
// dump would have no resolved-key to compare against.
if (debugLogging && usernameAttributeKey != null) {
// The DefaultOidcUser constructor (or our own checks) rejected the chosen
// username attribute. Dump the claims we DID receive so the operator can pick
// a different value for security.oauth2.useAsUsername.
logClaimDump(
"OAuth2/OIDC login FAILED - dumping received claims",
registrationId,
usernameAttributeKey,
userRequest.getIdToken(),
null,
userRequest.getIdToken() == null
? Collections.emptyMap()
: userRequest.getIdToken().getClaims(),
true);
}
throw new OAuth2AuthenticationException(new OAuth2Error(e.getMessage()), e);
} catch (Exception e) {
log.error("Unexpected error loading OIDC user", e);
if (debugLogging && usernameAttributeKey != null && userRequest.getIdToken() != null) {
logClaimDump(
"OAuth2/OIDC login FAILED (unexpected error) - dumping ID token claims",
registrationId,
usernameAttributeKey,
userRequest.getIdToken(),
null,
userRequest.getIdToken().getClaims(),
true);
}
throw new OAuth2AuthenticationException("Unexpected error during authentication");
}
}
/**
* Emits a multi-line diagnostic dump of the claims returned by the OAuth2/OIDC provider. Only
* invoked when {@code security.oauth2.debugLogging=true}.
*
* @param banner short title for the log block
* @param registrationId Spring client registration id (e.g. "demarest", "keycloak")
* @param usernameAttributeKey the claim key the application is configured to use as username
* @param idToken the decoded ID token, may be null on unexpected failures
* @param userInfo the decoded UserInfo response, may be null if the provider returned none
* @param mergedAttributes the merged attribute map Spring uses for {@code getAttribute()}
* @param failure true if logging in the error path (uses ERROR level), false for INFO
*/
private void logClaimDump(
String banner,
String registrationId,
String usernameAttributeKey,
OidcIdToken idToken,
OidcUserInfo userInfo,
Map<String, Object> 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<String, Object> 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: <null> --\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 ? "<NULL — this is why login fails>" : resolved)
.append('\n');
if (resolved == null) {
Set<String> 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<String, Object> 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<String> suggestUsernameClaims(Set<String> availableClaimKeys) {
Set<String> supported = new TreeSet<>();
for (UsernameAttribute attr : UsernameAttribute.values()) {
if (availableClaimKeys.contains(attr.getName())) {
supported.add(attr.getName());
}
}
return supported;
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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<Void> 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<BulkMoveResponse> 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<Long> fileIds;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class BulkMoveResponse {
private List<Long> movedFileIds;
private List<Long> skippedFileIds;
}
}
@@ -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<ResponseEntity<org.springframework.core.io.Resource>> 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<ResponseEntity<org.springframework.core.io.Resource>> 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<ResponseEntity<org.springframework.core.io.Resource>> tryRedirectToSignedUrl(
StoredFile file, boolean inline) {
if (file == null || file.getStorageKey() == null || file.getStorageKey().isBlank()) {
return Optional.empty();
}
try {
Optional<URI> 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<org.springframework.core.io.Resource> 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();
}
}
}
@@ -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).
*
* <p>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<FolderResponse> listFolders() {
return folderService.listFolders();
}
@PostMapping
public ResponseEntity<FolderResponse> 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<FolderResponse> updateFolder(
@PathVariable UUID folderId, @Valid @RequestBody UpdateFolderRequest request) {
return ResponseEntity.ok(folderService.updateFolder(folderId, request));
}
@DeleteMapping("/{folderId}")
public ResponseEntity<DeleteFolderResponse> deleteFolder(@PathVariable UUID folderId) {
List<UUID> removed = folderService.deleteFolder(folderId);
return ResponseEntity.ok(new DeleteFolderResponse(removed));
}
public record DeleteFolderResponse(List<UUID> removedFolderIds) {}
}
@@ -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).
*
* <p>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;
}
@@ -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<FileShare> 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.
*
* <p>{@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;
@@ -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;
}
@@ -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());
}
}
@@ -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<SharedUserResponse> sharedUsers;
private final List<ShareLinkResponse> 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;
}
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
@@ -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;
}
}
@@ -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<GetObjectResponse> 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<URI> signedDownloadUrl(String storageKey, Duration ttl) throws IOException {
return signedDownloadUrl(storageKey, ttl, false, null);
}
@Override
public Optional<URI> 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;
}
}
@@ -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<URI> 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<URI> signedDownloadUrl(
String storageKey, Duration ttl, boolean inline, String originalFilename)
throws IOException {
return Optional.empty();
}
}
@@ -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<Folder, UUID> {
Optional<Folder> findByIdAndOwner(UUID id, User owner);
List<Folder> 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.
*
* <p>{@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<UUID> folderIds);
}
@@ -59,6 +59,13 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
List<StoredFile> 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<StoredFile> findAllByIdInAndOwner(List<Long> ids, User owner);
@Modifying
@Transactional
@Query(
@@ -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();
}
@@ -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<FolderResponse> 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<Folder> 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<UUID> 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<UUID, List<UUID>> 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<UUID> removed = new ArrayList<>();
Set<UUID> seen = new HashSet<>();
Deque<UUID> stack = new ArrayDeque<>();
stack.push(folder.getId());
while (!stack.isEmpty()) {
UUID cur = stack.pop();
if (!seen.add(cur)) continue;
removed.add(cur);
List<UUID> 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<Long> 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<StoredFile> owned = storedFileRepository.findAllByIdInAndOwner(fileIds, user);
Set<Long> 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<Long> moved = owned.stream().map(StoredFile::getId).toList();
List<Long> 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<Long> movedFileIds, List<Long> 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<UUID> 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;
}
}
@@ -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();
}
}
}
@@ -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<Path> 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;
}
}
}
@@ -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:
*
* <ul>
* <li>{@code S3StorageProvider} CRUD: store / load / delete / presigned URL
* <li>{@code S3FileStore} CRUD (cluster artifact path)
* <li>Folder semantics simulated via key prefixes (matches production usage)
* <li>Negative paths: wrong secret, missing bucket, missing key, traversal IDs
* <li>Edge cases: zero-byte, unicode filename, multi-megabyte streaming
* <li>Configuration guards: SSRF endpoint rejection, bucket validation
* </ul>
*
* 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<String> 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<java.net.URI> url =
provider.signedDownloadUrl(obj.getStorageKey(), Duration.ofMinutes(5));
assertThat(url).isPresent();
HttpResponse<byte[]> 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<java.util.concurrent.Future<StoredObject>> 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<String> keys = new java.util.HashSet<>();
for (java.util.concurrent.Future<StoredObject> 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<byte[]> 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<byte[]> 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&amp.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<java.net.URI> url =
provider.signedDownloadUrl(
obj.getStorageKey(), Duration.ofMinutes(2), false, "report.pdf");
assertThat(url).isPresent();
HttpResponse<byte[]> 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<java.net.URI> url =
provider.signedDownloadUrl(
obj.getStorageKey(), Duration.ofMinutes(2), true, "preview.pdf");
assertThat(url).isPresent();
HttpResponse<byte[]> 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<byte[]> resp =
HttpClient.newHttpClient()
.send(
HttpRequest.newBuilder(presigned.url().toURI()).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).isEqualTo(payload);
}
}
@@ -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<byte[]> 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.
}
}
}
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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<ILoggingEvent> 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<String, Object> 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<String, Object> 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<ILoggingEvent> 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("<NULL — this is why login fails>");
// 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<String, Object> baseClaims() {
Map<String, Object> 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<String, Object> adfsStyleClaims() {
Map<String, Object> 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<String, Object> 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);
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<URI> 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;
}
}
@@ -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:
*
* <ul>
* <li>storage-enabled gate must be enforced (added in the same PR)
* <li>cross-user folder access must 404, not leak existence
* <li>cycle detection on reparent must 400
* <li>depth cap must reject chains past MAX_FOLDER_DEPTH
* <li>per-user folder count cap must 409
* </ul>
*
* 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<Long> 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;
}
}
@@ -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.
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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; \
+2 -1
View File
@@ -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; \
+2 -2
View File
@@ -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
@@ -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
+6
View File
@@ -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
+13
View File
@@ -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."
)
+8 -8
View File
@@ -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"
@@ -494,6 +494,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 +1472,7 @@ settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
welcome = "Welcome to the <strong>Admin Tour</strong>! 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 <strong>Settings</strong> and find it here in the <strong>Tours</strong> 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 <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
[adminUserSettings]
actions = "Actions"
@@ -1525,7 +1530,7 @@ comment = "Comment"
comments = "Comments"
contents = "Text"
delete = "Delete"
desc = "Use highlight, pen, text, and notes. Changes stay liveno flattening required."
desc = "Use highlight, pen, text, and notes. Changes stay live-no flattening required."
drawing = "Drawing"
duplicate = "Duplicate"
editCircle = "Edit Circle"
@@ -1851,6 +1856,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 +2094,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"
@@ -2780,13 +2792,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 +2865,7 @@ title = "Still working…"
[compare.status]
complete = "Comparison ready"
error = "Comparison failed"
extracting = "Extracting text..."
processing = "Analysing differences..."
@@ -2862,9 +2876,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 +3198,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 youhelping 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 +3211,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 assuredStirling 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 cannotand will nevertrack 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 formswhich 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 +3773,206 @@ 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"
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 +5111,7 @@ toolInterface = "This is the <strong>Crop</strong> tool interface. As you can se
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
viewSwitcher = "Use these controls to select how you want to view your PDFs."
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
wrapUp = "You're all set! You can replay this tour anytime just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
wrapUp = "You're all set! You can replay this tour anytime - just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
[onboarding.buttons]
back = "Back"
@@ -5244,9 +5451,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 +5641,7 @@ modified = "Edited"
unsaved = "Edited"
[pdfTextEditor.disclaimer]
alpha = "This alpha viewer is still evolvingcertain 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 +5719,7 @@ paragraph = "Paragraph page"
sparse = "Sparse text"
[pdfTextEditor.tooltip.alpha]
text = "This alpha viewer is still evolvingcertain 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 +6776,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 +6914,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 imageno 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 +7069,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 +7214,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"
@@ -8249,8 +8411,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 +8622,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"
@@ -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}
>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
<FolderProvider>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FolderProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
@@ -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<FileManagerProps> = ({ selectedTool }) => {
@@ -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<void>;
}
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<string | null>(null);
useEffect(() => {
if (opened) {
setDeleteContents(false);
setSubmitting(false);
setError(null);
}
}, [opened]);
if (!folder) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("filesPage.deleteFolderTitle", "Delete folder?")}
centered
size="md"
>
<Stack gap="md">
<Text size="sm">
{t("filesPage.deleteFolderBody", 'Delete folder "{{name}}"?', {
name: folder.name,
})}
</Text>
{fileCount > 0 && (
<Checkbox
checked={deleteContents}
onChange={(e) => setDeleteContents(e.currentTarget.checked)}
disabled={submitting}
label={t(
"filesPage.deleteFolderContents",
"Also delete {{count}} file(s) inside the folder",
{ count: fileCount },
)}
/>
)}
{fileCount > 0 && (
<Text size="xs" c="dimmed">
{deleteContents
? t(
"filesPage.deleteFolderContentsWarning",
"Files will be permanently removed and cannot be recovered.",
)
: t(
"filesPage.deleteFolderKeepHint",
"Files inside will be moved to All files.",
)}
</Text>
)}
{error && (
<Alert
color="red"
icon={<ErrorOutlineIcon fontSize="small" />}
variant="light"
role="alert"
>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={submitting}>
{t("filesPage.cancel", "Cancel")}
</Button>
<Button
color="red"
loading={submitting}
onClick={async () => {
setSubmitting(true);
setError(null);
try {
await onConfirm(deleteContents);
onClose();
} catch (err) {
setError(
err instanceof Error
? err.message
: t(
"filesPage.deleteFolderError",
"Could not delete the folder. Try again.",
),
);
} finally {
setSubmitting(false);
}
}}
>
{t("filesPage.delete", "Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,658 @@
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<FileId, StirlingFileStub>;
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;
}
export function FileDetailsPanel({
selectedFileIds,
fileMap,
currentFolder,
onClose,
onAddToWorkspace,
onQuickView,
onMove,
onRemove,
onSaveToServer,
}: 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<StirlingFileStub[]>([]);
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 (
<aside
className="files-page-details"
aria-label={t("filesPage.details", "Details")}
>
<div className="files-page-details-header">
<strong>
{single
? t("filesPage.details", "Details")
: t("filesPage.detailsCount", "{{count}} files selected", {
count: files.length,
})}
</strong>
<Tooltip
label={t("filesPage.closeDetails", "Close details")}
withinPortal
>
<ActionIcon variant="subtle" size="sm" onClick={onClose}>
<CloseIcon fontSize="small" />
</ActionIcon>
</Tooltip>
</div>
<div className="files-page-details-body">
{single ? (
<>
<div className="files-page-details-thumb">
{single.thumbnailUrl ? (
<img src={single.thumbnailUrl} alt="" />
) : (
<PictureAsPdfIcon
style={{ fontSize: "3rem", color: "var(--text-muted)" }}
/>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
flexWrap: "wrap",
}}
>
<h3 style={{ margin: 0, wordBreak: "break-word", flex: 1 }}>
{single.name}
</h3>
{ext && (
// Custom span; Mantine Badge default rendered invisible in dark mode.
<span className="files-page-details-ext-tag">{ext}</span>
)}
{(single.versionNumber ?? 1) > 1 && (
<Badge size="sm" variant="filled" color="blue">
v{single.versionNumber}
</Badge>
)}
</div>
<div className="files-page-details-fieldlist">
<DetailField
label={t("filesPage.field.size", "Size")}
value={formatFileSize(single.size)}
/>
<DetailField
label={t("filesPage.field.type", "Type")}
value={single.type || "-"}
/>
<DetailField
label={t("filesPage.field.modified", "Modified")}
value={getFileDate({ lastModified: single.lastModified })}
/>
<DetailField
label={t("filesPage.field.added", "Added")}
value={
single.createdAt
? getFileDate({ lastModified: single.createdAt })
: "-"
}
/>
<DetailField
label={t("filesPage.field.folder", "Folder")}
value={
currentFolder
? currentFolder.name
: t("filesPage.allFiles", "All files")
}
/>
</div>
{single.toolHistory && single.toolHistory.length > 0 && (
<div className="files-page-details-tool-history">
<div className="files-page-details-tool-history-label">
{t("filesPage.field.toolHistory", "Tool history")}
</div>
<ToolChain
toolChain={single.toolHistory}
displayStyle="badges"
size="xs"
/>
</div>
)}
{/* Version journey. Each tool run writes a new StirlingFile
with the same `originalFileId` and an incremented
`versionNumber`, so the chain reconstructs the edit
timeline. The previous file manager exposed this and the
refactored one had silently dropped it; this revival also
shows WHICH tool was added at each step (the delta from
the prior version) so the user can read the journey
top-to-bottom. Long chains (> 6) collapse the middle. */}
{versionChain.length > 1 && (
<VersionTimeline
chain={versionChain}
currentId={single.id}
onQuickView={onQuickView}
onAddToWorkspace={onAddToWorkspace}
onRemove={onRemove}
/>
)}
</>
) : (
<div className="files-page-details-fieldlist">
<DetailField
label={t("filesPage.field.totalSize", "Total size")}
value={formatFileSize(totalSize)}
/>
<DetailField
label={t("filesPage.field.count", "Files")}
value={String(files.length)}
/>
</div>
)}
<div
style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}
>
<Button
leftSection={<OpenInNewIcon fontSize="small" />}
variant="filled"
onClick={() => onAddToWorkspace(selectedFileIds)}
>
{files.length === 1
? t("filesPage.addToWorkspace", "Add to workspace")
: t(
"filesPage.addToWorkspaceCount",
"Add {{count}} to workspace",
{ count: files.length },
)}
</Button>
{single && (
<Button
leftSection={<VisibilityIcon fontSize="small" />}
variant="subtle"
onClick={() => onQuickView(single.id)}
>
{t("filesPage.quickView", "Quick view")}
</Button>
)}
<Button
leftSection={<DownloadIcon fontSize="small" />}
variant="default"
onClick={handleDownload}
loading={downloading}
>
{single
? t("filesPage.download", "Download")
: t("filesPage.downloadAll", "Download all")}
</Button>
{/* Share is single-file only. When sharing is disabled in
server config (storage.sharing.enabled=false) we still
render the button - disabled with an explanatory tooltip -
so users discover the feature exists and know how to
enable it, rather than wondering why "share" is missing
from the action stack on their build. */}
{single && (
<Tooltip
label={t(
"filesPage.shareDisabledHint",
"File sharing isn't enabled on this server. Ask your admin to enable it.",
)}
disabled={sharingEnabled}
withinPortal
multiline
w={260}
>
<Button
leftSection={<LinkIcon fontSize="small" />}
variant="default"
disabled={!sharingEnabled}
onClick={() => setShareModalOpen(true)}
styles={{
root: {
// Keep tooltip hoverable while button is disabled.
pointerEvents: sharingEnabled ? undefined : "auto",
},
}}
>
{t("filesPage.shareManage", "Manage sharing")}
</Button>
</Tooltip>
)}
<Button
leftSection={<DriveFileMoveIcon fontSize="small" />}
variant="default"
onClick={() => onMove(selectedFileIds)}
>
{t("filesPage.moveTo", "Move to…")}
</Button>
{/* Save to server; shown when any selected file is local-only. */}
{onSaveToServer && localOnlyFiles.length > 0 && (
<Button
leftSection={<CloudUploadIcon fontSize="small" />}
variant="default"
onClick={() => onSaveToServer(localOnlyFiles)}
>
{t("filesPage.saveToServer", "Save to server")}
</Button>
)}
<Button
leftSection={<DeleteIcon fontSize="small" />}
color="red"
variant="light"
onClick={() => onRemove(selectedFileIds)}
>
{t("filesPage.remove", "Delete")}
</Button>
</div>
</div>
{/* Single panel-level mount; gated on sharingEnabled. */}
{single && sharingEnabled && (
<ShareManagementModal
opened={shareModalOpen}
onClose={() => setShareModalOpen(false)}
file={single}
/>
)}
</aside>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div className="files-page-details-field">
<span className="files-page-details-field-label">{label}</span>
<span className="files-page-details-field-value">{value}</span>
</div>
);
}
/** 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<Set<FileId>>(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<number, StirlingFileStub>();
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 (
<div className="files-page-details-version-timeline">
<div className="files-page-details-version-timeline-label">
<HistoryIcon fontSize="small" />
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
<span className="files-page-details-version-timeline-count">
{t("filesPage.versionsCount", "{{count}} versions", {
count: ordered.length,
})}
</span>
</div>
<ol className="files-page-details-version-timeline-list">
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
if (row.kind === "ellipsis") {
return (
<li
key="ellipsis"
className="files-page-details-version-timeline-ellipsis"
>
<div className="files-page-details-version-timeline-rail">
<span className="files-page-details-version-timeline-rail-dot is-ellipsis" />
{!isLast && (
<span className="files-page-details-version-timeline-rail-line" />
)}
</div>
<button
type="button"
className="files-page-details-version-timeline-ellipsis-btn"
onClick={() => setShowAllCollapsed(true)}
>
{t(
"filesPage.versionShowHidden",
"Show {{count}} earlier versions",
{ count: row.hidden },
)}
</button>
</li>
);
}
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 (
<li
key={v.id}
className={`files-page-details-version-timeline-row${
isActive ? " is-active" : ""
}`}
>
<div className="files-page-details-version-timeline-rail">
<span
className={`files-page-details-version-timeline-rail-dot${
isActive ? " is-active" : ""
}`}
/>
{!isLast && (
<span className="files-page-details-version-timeline-rail-line" />
)}
</div>
<div className="files-page-details-version-timeline-body">
<button
type="button"
className="files-page-details-version-timeline-summary"
onClick={() => toggleExpand(v.id)}
aria-expanded={isExpanded}
>
<Badge
size="xs"
variant={isActive ? "filled" : "outline"}
color="blue"
>
v{v.versionNumber ?? 1}
</Badge>
{delta ? (
<span className="files-page-details-version-timeline-delta">
<span className="files-page-details-version-timeline-delta-plus">
+
</span>
<ToolLabel toolId={delta.toolId} />
</span>
) : (
<span className="files-page-details-version-timeline-delta is-origin">
{t("filesPage.versionOrigin", "Original upload")}
</span>
)}
<span className="files-page-details-version-timeline-spacer" />
<KeyboardArrowDownIcon
className={`files-page-details-version-timeline-chevron${
isExpanded ? " is-expanded" : ""
}`}
fontSize="small"
/>
</button>
<div className="files-page-details-version-timeline-meta-line">
<span>{formatFileSize(v.size)}</span>
{v.lastModified ? (
<>
<span>·</span>
<span>
{getFileDate({ lastModified: v.lastModified })}
</span>
</>
) : null}
{!isActive && (
<>
<span className="files-page-details-version-timeline-spacer" />
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon
variant="subtle"
size="sm"
aria-label={t(
"filesPage.versionActions",
"Version actions",
)}
onClick={(e) => e.stopPropagation()}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<VisibilityIcon fontSize="small" />}
onClick={() => onQuickView(v.id)}
>
{t("filesPage.viewVersion", "View this version")}
</Menu.Item>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={() => onAddToWorkspace([v.id])}
>
{t(
"filesPage.openVersionInWorkspace",
"Open in workspace",
)}
</Menu.Item>
<Menu.Item
leftSection={<DownloadIcon fontSize="small" />}
onClick={() => {
void downloadFileFromStorage(v);
}}
>
{t(
"filesPage.downloadVersion",
"Download this version",
)}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={() => onRemove([v.id])}
>
{t(
"filesPage.removeVersion",
"Remove this version",
)}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
)}
</div>
{isExpanded && (
// Filename + full cumulative tool chain.
<div className="files-page-details-version-timeline-expanded">
<DetailField
label={t("filesPage.field.name", "Name")}
value={v.name}
/>
{v.toolHistory && v.toolHistory.length > 0 && (
<div className="files-page-details-version-timeline-toolchain">
<span className="files-page-details-version-timeline-toolchain-label">
{t(
"filesPage.field.toolHistoryAtVersion",
"Cumulative tool chain",
)}
</span>
<ToolChain
toolChain={v.toolHistory}
displayStyle="badges"
size="xs"
/>
</div>
)}
</div>
)}
</div>
</li>
);
})}
</ol>
{collapsible && showAllCollapsed && (
<button
type="button"
className="files-page-details-version-timeline-collapse-btn"
onClick={() => setShowAllCollapsed(false)}
>
{t("filesPage.versionCollapse", "Collapse middle versions")}
</button>
)}
</div>
);
}
/** Translated tool name via `home.{toolId}.title`. */
function ToolLabel({ toolId }: { toolId: ToolId }) {
const { t } = useTranslation();
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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: <CloudDoneIcon style={{ fontSize: "0.85rem" }} />,
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: <GroupIcon style={{ fontSize: "0.85rem" }} />,
style: styles.shared,
tooltip: t("filesPage.origin.sharedHint", "Shared with you via link"),
};
case "local":
default:
return {
label: t("filesPage.origin.local", "Local"),
icon: <ComputerIcon style={{ fontSize: "0.85rem" }} />,
style: styles.local,
tooltip: t(
"filesPage.origin.localHint",
"Only stored in this browser",
),
};
}
})();
const badge = (
<span style={{ ...styles.base, ...config.style }}>
{config.icon}
{!compact && config.label}
</span>
);
return (
<Tooltip label={config.tooltip} withinPortal>
{badge}
</Tooltip>
);
}
File diff suppressed because it is too large Load Diff
@@ -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 (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
padding: "0.5rem 0.75rem",
minWidth: "16rem",
opacity: disabled ? 0.55 : 1,
pointerEvents: disabled ? "none" : undefined,
}}
aria-disabled={disabled || undefined}
>
<Section label={t("filesPage.appearance.colour", "Colour")}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(8, 1fr)",
gap: "0.35rem",
}}
>
{FOLDER_COLOR_PALETTE.map((c) => (
<button
key={c}
type="button"
disabled={disabled}
aria-label={t(
"filesPage.appearance.useColour",
"Use colour {{c}}",
{ c },
)}
onClick={(e) => {
e.stopPropagation();
onChange({ color: c });
}}
style={{
width: "1.6rem",
height: "1.6rem",
borderRadius: "50%",
border:
folder.color === c
? "2px solid var(--text-primary)"
: "2px solid transparent",
background: c,
cursor: disabled ? "not-allowed" : "pointer",
padding: 0,
outlineOffset: "2px",
}}
/>
))}
</div>
</Section>
<Section label={t("filesPage.appearance.icon", "Icon")}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
gap: "0.25rem",
}}
>
{FOLDER_ICONS.map((icon) => (
<IconButton
key={icon.id}
icon={icon}
disabled={disabled}
selected={
(icon.id === "none" && !folder.icon) || folder.icon === icon.id
}
onClick={() =>
onChange({ icon: icon.id === "none" ? null : icon.id })
}
/>
))}
</div>
</Section>
</div>
);
}
function Section({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
<span
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted)",
fontWeight: 600,
}}
>
{label}
</span>
{children}
</div>
);
}
function IconButton({
icon,
selected,
onClick,
disabled = false,
}: {
icon: FolderIconOption;
selected: boolean;
onClick: () => void;
disabled?: boolean;
}) {
return (
<Tooltip label={icon.label} withinPortal>
<button
type="button"
disabled={disabled}
aria-label={icon.label}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{
width: "2rem",
height: "2rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.1rem",
borderRadius: "0.4rem",
background: selected ? "var(--hover-bg)" : "transparent",
border: selected
? "1px solid var(--accent-interactive, #6366f1)"
: "1px solid transparent",
cursor: disabled ? "not-allowed" : "pointer",
padding: 0,
color: "var(--text-primary)",
}}
>
{icon.glyph || "-"}
</button>
</Tooltip>
);
}
@@ -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<void>;
}
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<string | null>(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 (
<Modal
opened={opened}
onClose={onClose}
title={title}
centered
size="sm"
keepMounted
transitionProps={{ duration: 0 }}
>
<Stack gap="sm">
<TextInput
autoFocus
value={value}
onChange={(e) => 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 && (
<Alert
color="red"
icon={<ErrorOutlineIcon fontSize="small" />}
variant="light"
role="alert"
>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t("filesPage.folderName.cancel", "Cancel")}
</Button>
<Button
onClick={submit}
loading={submitting}
disabled={!value.trim()}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -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<NonNullable<FolderThumbnailProps["size"]>, 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 (
<div
style={{
position: "relative",
width: px,
height: Math.round(px * 0.8),
display: "inline-block",
}}
aria-hidden="true"
>
<svg
viewBox="0 0 100 80"
preserveAspectRatio="xMidYMid meet"
style={{ display: "block", width: "100%", height: "100%" }}
>
<defs>
<linearGradient id={backId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={accent} stopOpacity="0.95" />
<stop offset="100%" stopColor={accent} stopOpacity="0.75" />
</linearGradient>
<linearGradient id={frontId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={accent} stopOpacity="0.85" />
<stop offset="100%" stopColor={accent} stopOpacity="1" />
</linearGradient>
</defs>
{/* Back panel - symmetric viewBox so the folder is visually
centred (6px breathing room on every side). */}
<path
d="M4 12 Q4 6 10 6 H38 L46 14 H90 Q96 14 96 20 V68 Q96 74 90 74 H10 Q4 74 4 68 Z"
fill={`url(#${backId})`}
/>
{/* Paper peeking out (lighter) */}
<rect
x="14"
y="22"
width="72"
height="36"
rx="4"
fill="rgba(255, 255, 255, 0.85)"
/>
<rect
x="18"
y="18"
width="64"
height="32"
rx="4"
fill="rgba(255, 255, 255, 0.55)"
/>
{/* Front pocket */}
<path
d="M4 30 Q4 24 10 24 H90 Q96 24 96 30 V68 Q96 74 90 74 H10 Q4 74 4 68 Z"
fill={`url(#${frontId})`}
/>
{/* Subtle highlight on the lip */}
<path
d="M4 30 Q4 24 10 24 H90 Q96 24 96 30 V32 H4 Z"
fill="rgba(255, 255, 255, 0.18)"
/>
</svg>
{iconGlyph && size === "thumb" && (
<span
style={{
position: "absolute",
inset: "28% 0 0 0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: `${px * 0.28}px`,
lineHeight: 1,
pointerEvents: "none",
filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.25))",
}}
aria-hidden="true"
>
{iconGlyph}
</span>
)}
{iconGlyph && size === "row" && (
<span
style={{
position: "absolute",
inset: "30% 0 0 0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: `${px * 0.45}px`,
lineHeight: 1,
pointerEvents: "none",
}}
aria-hidden="true"
>
{iconGlyph}
</span>
)}
{showBadge && (
<span
style={{
position: "absolute",
top: "-0.35rem",
right: "-0.35rem",
minWidth: "1.4rem",
height: "1.4rem",
borderRadius: "999px",
background: "var(--bg-surface, #fff)",
border: `1px solid ${accent}`,
color: accent,
fontSize: "0.7rem",
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0 0.35rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.12)",
lineHeight: 1,
}}
>
{fileCount}
</span>
)}
</div>
);
}
@@ -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);
}
}
@@ -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<number>(() => {
const persisted = loadPersistedWidth();
return persisted ?? 256;
});
const userSetRef = useRef<boolean>(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 (
<div
className="folder-tree-panel"
data-active={String(active)}
aria-hidden={!active}
style={
active
? ({
"--folder-tree-panel-width": `${width}px`,
} as React.CSSProperties)
: undefined
}
>
<div className="folder-tree-panel-inner">
<div className="folder-tree-panel-header">
<span className="folder-tree-panel-title">
{t("filesPage.myFiles", "My Files")}
</span>
</div>
<FolderTreeSidebar
fileCounts={fileCountsByFolder}
onRequestNewFolder={openNewFolderDialog}
onRenameFolder={(folder: FolderRecord) =>
openRenameFolderDialog(folder)
}
onDeleteFolder={promptDeleteFolder}
onMoveFilesIntoFolder={async (
targetId: FolderId | null,
fileIds: FileId[],
) => {
if (fileIds.length === 0) return;
await moveFilesTo(fileIds, targetId);
}}
/>
</div>
{active && (
<div
className="folder-tree-panel-resizer"
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH}
aria-valuenow={width}
aria-label={t(
"filesPage.resizeFolderTree",
"Resize folder tree (arrow keys, Shift for bigger steps)",
)}
tabIndex={0}
onMouseDown={onMouseDown}
onKeyDown={onKeyDown}
onDoubleClick={() => {
const auto = computeAutoFitWidth(folders.folders, rootLabel);
userSetRef.current = false;
setWidth(auto);
savePersistedWidth(auto);
}}
/>
)}
</div>
);
}
@@ -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<FolderId | null, number>;
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> | void;
}
// This component is always rendered inside FolderTreePanel, which supplies
// its own <aside> chrome and "New folder at root" toolbar control. An
// earlier `embed` prop selected between an embedded list and a standalone
// aside+header layout; the standalone layout was unused and its "New
// folder at root" ActionIcon was not gated by `serverReachable`, so if
// anyone re-wired the component into a non-embed surface they'd ship an
// always-enabled mutation button against a possibly-offline server.
// Deleted to remove the trap.
export function FolderTreeSidebar({
fileCounts,
onRequestNewFolder,
onRenameFolder,
onDeleteFolder,
onMoveFilesIntoFolder,
}: FolderTreeSidebarProps) {
const { t } = useTranslation();
const { tree, currentFolderId, setCurrentFolderId } = useFolders();
const { currentTab, setCurrentTab, moveFolderTo } = useFilesPage();
return (
<div
className="files-page-tree-list"
role="tree"
aria-label={t("filesPage.tree", "Folders")}
>
<RootRow
fileCount={fileCounts.get(ROOT_FOLDER_ID) ?? 0}
isActive={
currentFolderId === ROOT_FOLDER_ID &&
(currentTab === "all" || currentTab === "cloud")
}
onSelect={() => {
// Picking the root re-enters the cloud bucket - also switch out
// of any virtual tab so the user lands somewhere consistent.
if (currentTab !== "all" && currentTab !== "cloud") {
setCurrentTab("all");
}
setCurrentFolderId(ROOT_FOLDER_ID);
}}
onDropFiles={(fileIds) =>
onMoveFilesIntoFolder(ROOT_FOLDER_ID, fileIds)
}
/>
<LocalRow
isActive={currentTab === "local"}
onSelect={() => setCurrentTab("local")}
/>
{tree.map((node) => (
<TreeNodeRow
key={node.folder.id}
node={node}
fileCounts={fileCounts}
currentFolderId={currentFolderId}
// Same dance as RootRow: clicking a cloud folder must drop the
// virtual-tab highlight (Local/Recent/Shared), otherwise the row
// AND the tab both look "active" simultaneously.
onSelect={(id) => {
if (currentTab !== "all" && currentTab !== "cloud") {
setCurrentTab("all");
}
setCurrentFolderId(id);
}}
onMoveFolder={async (folderId, newParentId) => {
// Route through filesPage.moveFolderTo so the cycle case
// surfaces an error banner instead of silently no-op'ing.
await moveFolderTo(folderId, newParentId);
}}
onMoveFiles={onMoveFilesIntoFolder}
onRequestNewFolder={onRequestNewFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
/>
))}
</div>
);
}
interface RootRowProps {
fileCount: number;
isActive: boolean;
onSelect: () => void;
onDropFiles: (fileIds: FileId[]) => Promise<void> | void;
}
function RootRow({ fileCount, isActive, onSelect, onDropFiles }: RootRowProps) {
const { t } = useTranslation();
const { setError } = useFolders();
const { handlers, isOver } = 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) => {
console.error("[RootRow] file drop failed", err);
setError(
err instanceof Error
? t("filesPage.error.moveFilesFailedDetail", {
message: err.message,
defaultValue: `Could not move files: ${err.message}`,
})
: t("filesPage.error.moveFilesFailed", "Could not move files."),
);
});
}
},
});
return (
<div
role="treeitem"
aria-selected={isActive}
tabIndex={0}
className={`files-page-tree-node${isActive ? " is-active" : ""}${
isOver ? " is-drop-target" : ""
}`}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
{...handlers}
>
<span className="files-page-tree-spacer" />
<span className="files-page-tree-icon">
<HomeIcon fontSize="small" />
</span>
<span className="files-page-tree-name">
{t("filesPage.allFiles", "All files")}
</span>
<span className="files-page-tree-count">{fileCount}</span>
</div>
);
}
interface LocalRowProps {
isActive: boolean;
onSelect: () => void;
}
/**
* Pinned pseudo-folder row that selects the Local tab. Local files don't
* belong to a folder (folders are a cloud concept) so this row is not a
* drop target and has no count badge - the Local view scopes by predicate
* (`remoteStorageId == null`), not by folderId.
*/
function LocalRow({ isActive, onSelect }: LocalRowProps) {
const { t } = useTranslation();
return (
<div
role="treeitem"
aria-selected={isActive}
tabIndex={0}
className={`files-page-tree-node${isActive ? " is-active" : ""}`}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
>
<span className="files-page-tree-spacer" />
<span className="files-page-tree-icon">
<DevicesOtherIcon fontSize="small" />
</span>
<span className="files-page-tree-name">
{t("filesPage.tabName.local", "Local")}
</span>
</div>
);
}
interface TreeNodeRowProps {
node: FolderTreeNode;
fileCounts: Map<FolderId | null, number>;
currentFolderId: FolderId | null;
onSelect: (id: FolderId) => void;
onMoveFolder: (
folderId: FolderId,
newParentId: FolderId | null,
) => Promise<void> | void;
onMoveFiles: (
folderId: FolderId | null,
fileIds: FileId[],
) => Promise<void> | void;
onRequestNewFolder: (parentId: FolderId | null) => void;
onRenameFolder: (folder: FolderRecord) => void;
onDeleteFolder: (folder: FolderRecord) => void;
}
function TreeNodeRow({
node,
fileCounts,
currentFolderId,
onSelect,
onMoveFolder,
onMoveFiles,
onRequestNewFolder,
onRenameFolder,
onDeleteFolder,
}: TreeNodeRowProps) {
const { t } = useTranslation();
const { serverReachable, setError } = useFolders();
const { currentTab } = useFilesPage();
const offlineHint = t(
"filesPage.offlineNoFolderEdits",
"Offline - folder changes are disabled.",
);
const [open, setOpen] = useState(true);
// Only highlight the folder row when we're actually in a cloud-rooted
// view. Otherwise (Local/Recent/Shared tabs) it'd compete with the tab
// highlight and confuse the user about "where they are".
const isActive =
currentFolderId === node.folder.id &&
(currentTab === "all" || currentTab === "cloud");
const hasChildren = node.children.length > 0;
const indent = useMemo(
() => ({ paddingLeft: `${14 + node.depth * 16}px` }),
[node.depth],
);
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") {
// Use payload.fileIds - not the current selection - so dragging a
// non-selected file moves *that* file. Surface failures via the
// shared error banner rather than letting them become unhandled
// rejections that only the dev console sees.
Promise.resolve(onMoveFiles(node.folder.id, payload.fileIds)).catch(
(err) => {
console.error("[TreeNodeRow] file drop failed", err);
setError(
err instanceof Error
? t("filesPage.error.moveFilesFailedDetail", {
message: err.message,
defaultValue: `Could not move files: ${err.message}`,
})
: t("filesPage.error.moveFilesFailed", "Could not move files."),
);
},
);
} else if (payload.kind === "folder") {
Promise.resolve(onMoveFolder(payload.folderId, node.folder.id)).catch(
(err) => {
console.error("[TreeNodeRow] folder drop failed", err);
setError(
err instanceof Error
? t("filesPage.error.moveFolderFailedDetail", {
message: err.message,
defaultValue: `Could not move folder: ${err.message}`,
})
: t(
"filesPage.error.moveFolderFailed",
"Could not move folder.",
),
);
},
);
}
},
});
const handleDragStart = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData(
FILES_PAGE_DRAG_TYPE,
serialiseFilesPageDragPayload({
kind: "folder",
folderId: node.folder.id,
}),
);
e.dataTransfer.effectAllowed = "move";
},
[node.folder.id],
);
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
<div
role="treeitem"
aria-selected={isActive}
aria-expanded={hasChildren ? open : undefined}
tabIndex={0}
draggable
style={indent}
className={`files-page-tree-node${isActive ? " is-active" : ""}${
isDropTarget ? " is-drop-target" : ""
}`}
onClick={() => onSelect(node.folder.id)}
onDoubleClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
onContextMenu={(e) => {
// Open the action menu on right-click rather than a native
// window.prompt (unstyled, untranslatable, unusable on mobile).
e.preventDefault();
setMenuOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(node.folder.id);
} else if (e.key === "ArrowRight" && hasChildren) {
setOpen(true);
} else if (e.key === "ArrowLeft") {
setOpen(false);
}
}}
{...dropHandlers}
onDragStart={handleDragStart}
>
{hasChildren ? (
<span
className="files-page-tree-toggle"
aria-hidden="true"
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
>
{open ? (
<KeyboardArrowDownIcon fontSize="small" />
) : (
<KeyboardArrowRightIcon fontSize="small" />
)}
</span>
) : (
<span className="files-page-tree-spacer" />
)}
<span className="files-page-tree-icon">
<FolderThumbnail color={node.folder.color} size="tree" />
</span>
<span className="files-page-tree-name">{node.folder.name}</span>
<span className="files-page-tree-count">
{fileCounts.get(node.folder.id) ?? 0}
</span>
<Menu
opened={menuOpen}
onChange={setMenuOpen}
withinPortal
position="bottom-end"
shadow="md"
width={200}
>
<Menu.Target>
<ActionIcon
size="xs"
variant="subtle"
className="files-page-tree-kebab"
aria-label={t(
"filesPage.treeMenu.actions",
"Folder actions for {{name}}",
{ name: node.folder.name },
)}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((o) => !o);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<EditIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRenameFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.rename", "Rename")}
</Menu.Item>
<Menu.Item
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRequestNewFolder(node.folder.id);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.newSubfolder", "New subfolder")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.delete", "Delete folder")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
{open &&
// Cap render recursion at MAX_TREE_DEPTH to guarantee a finite
// call stack even if a future bug (or a hand-edited IDB cache)
// produces a folder chain deeper than the server enforces. Any
// realistic user tree stays well under this; the cap exists so
// the renderer fails closed rather than blowing the JS stack.
node.depth < MAX_TREE_DEPTH &&
node.children.map((child) => (
<TreeNodeRow
key={child.folder.id}
node={child}
fileCounts={fileCounts}
currentFolderId={currentFolderId}
onSelect={onSelect}
onMoveFolder={onMoveFolder}
onMoveFiles={onMoveFiles}
onRequestNewFolder={onRequestNewFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
/>
))}
</>
);
}
@@ -0,0 +1,357 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Alert,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import HomeIcon from "@mui/icons-material/Home";
import FolderIcon from "@mui/icons-material/Folder";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
interface MoveToFolderDialogProps {
opened: boolean;
onClose: () => void;
folders: FolderRecord[];
/** Folder being moved; excludes its descendants from destinations. */
disabledFolderId?: FolderId | null;
initialFolderId?: FolderId | null;
onConfirm: (folderId: FolderId | null) => void | Promise<void>;
/** Inline-create folder; new folder becomes the move target. */
onCreateFolder?: (
name: string,
parentFolderId: FolderId | null,
) => Promise<FolderRecord>;
}
export function MoveToFolderDialog({
opened,
onClose,
folders,
disabledFolderId,
initialFolderId = ROOT_FOLDER_ID,
onConfirm,
onCreateFolder,
}: MoveToFolderDialogProps) {
const { t } = useTranslation();
const [target, setTarget] = useState<FolderId | null>(initialFolderId);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Inline create-folder state; revealed by the toggle.
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [creating, setCreating] = useState(false);
// Reset when reopening with a new initial.
React.useEffect(() => {
if (opened) {
setTarget(initialFolderId);
setSubmitting(false);
setError(null);
setCreatingFolder(false);
setNewFolderName("");
setCreating(false);
}
}, [opened, initialFolderId]);
/** Single-pass build of parent index, depths, and blocked descendants. */
const { depthById, blocked, treeOrder } = useMemo(() => {
const byParent = new Map<FolderId | null, FolderRecord[]>();
for (const folder of folders) {
const list = byParent.get(folder.parentFolderId) ?? [];
list.push(folder);
byParent.set(folder.parentFolderId, list);
}
for (const list of byParent.values()) {
list.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
}
// Pre-order DFS; truncates past MAX_TREE_DEPTH to prevent stack overflow.
const MAX_TREE_DEPTH = 50;
const order: FolderRecord[] = [];
const depths = new Map<FolderId, number>();
const visit = (parent: FolderId | null, depth: number) => {
if (depth >= MAX_TREE_DEPTH) return;
for (const child of byParent.get(parent) ?? []) {
order.push(child);
depths.set(child.id, depth);
visit(child.id, depth + 1);
}
};
visit(null, 0);
const blockedSet = new Set<FolderId>();
if (disabledFolderId) {
const stack: FolderId[] = [disabledFolderId];
while (stack.length > 0) {
const cur = stack.pop()!;
if (blockedSet.has(cur)) continue;
blockedSet.add(cur);
for (const child of byParent.get(cur) ?? []) {
stack.push(child.id);
}
}
}
return { depthById: depths, blocked: blockedSet, treeOrder: order };
}, [disabledFolderId, folders]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t("filesPage.moveDialog.title", "Move to folder")}
centered
size="md"
keepMounted
transitionProps={{ duration: 0 }}
>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t(
"filesPage.moveDialog.hint",
"Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left.",
)}
</Text>
<div
style={{
border: "1px solid var(--border-subtle)",
borderRadius: "0.5rem",
maxHeight: "20rem",
overflowY: "auto",
}}
>
<FolderPick
label={t("filesPage.allFiles", "All files")}
isActive={target === ROOT_FOLDER_ID}
disabled={false}
depth={0}
isRoot
onPick={() => setTarget(ROOT_FOLDER_ID)}
/>
{treeOrder.map((folder) => (
<FolderPick
key={folder.id}
label={folder.name}
color={folder.color}
isActive={target === folder.id}
disabled={blocked.has(folder.id)}
depth={depthById.get(folder.id) ?? 0}
onPick={() => setTarget(folder.id)}
/>
))}
</div>
{/* Inline Create new folder; new folder becomes the move target. */}
{onCreateFolder &&
(() => {
const trimmedName = newFolderName.trim();
const handleCreate = async () => {
if (trimmedName.length === 0) return;
setCreating(true);
setError(null);
try {
const created = await onCreateFolder(
trimmedName,
// ROOT becomes null parent.
target === ROOT_FOLDER_ID ? null : target,
);
setTarget(created.id);
setCreatingFolder(false);
setNewFolderName("");
} catch (err) {
setError(
err instanceof Error
? err.message
: t(
"filesPage.moveDialog.newFolderError",
"Could not create folder. Try again.",
),
);
} finally {
setCreating(false);
}
};
const handleCancel = () => {
setCreatingFolder(false);
setNewFolderName("");
};
return creatingFolder ? (
<Group gap="xs" align="flex-end" wrap="nowrap">
<TextInput
label={t(
"filesPage.moveDialog.newFolderLabel",
"New folder name",
)}
value={newFolderName}
onChange={(e) => setNewFolderName(e.currentTarget.value)}
placeholder={t(
"filesPage.moveDialog.newFolderPlaceholder",
"Folder name",
)}
style={{ flex: 1 }}
disabled={creating}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreate();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
}}
autoFocus
/>
<Button
loading={creating}
disabled={trimmedName.length === 0}
onClick={handleCreate}
>
{t("filesPage.moveDialog.newFolderCreate", "Create")}
</Button>
{/* X collapses the inline create row only. */}
<Tooltip
label={t("filesPage.moveDialog.newFolderCancel", "Discard")}
withinPortal
>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={handleCancel}
disabled={creating}
aria-label={t(
"filesPage.moveDialog.newFolderCancel",
"Discard",
)}
>
<CloseIcon fontSize="small" />
</ActionIcon>
</Tooltip>
</Group>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() => {
setCreatingFolder(true);
setNewFolderName("");
}}
styles={{ root: { alignSelf: "flex-start" } }}
>
{t(
"filesPage.moveDialog.newFolderToggle",
"Create new folder…",
)}
</Button>
);
})()}
{error && (
<Alert
color="red"
icon={<ErrorOutlineIcon fontSize="small" />}
variant="light"
role="alert"
>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={submitting}>
{t("filesPage.moveDialog.cancel", "Cancel")}
</Button>
<Button
loading={submitting}
onClick={async () => {
setSubmitting(true);
setError(null);
try {
await onConfirm(target);
onClose();
} catch (err) {
setError(
err instanceof Error
? err.message
: t(
"filesPage.moveDialog.error",
"Could not move. Try again.",
),
);
} finally {
setSubmitting(false);
}
}}
>
{t("filesPage.moveDialog.confirm", "Move here")}
</Button>
</Group>
</Stack>
</Modal>
);
}
interface FolderPickProps {
label: string;
color?: string;
isActive: boolean;
disabled: boolean;
depth: number;
isRoot?: boolean;
onPick: () => void;
}
function FolderPick({
label,
color,
isActive,
disabled,
depth,
isRoot,
onPick,
}: FolderPickProps) {
return (
<button
type="button"
onClick={onPick}
disabled={disabled}
style={{
all: "unset",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: `0.4rem 0.75rem 0.4rem ${0.75 + depth * 0.85}rem`,
width: "100%",
background: isActive ? "var(--hover-bg)" : "transparent",
borderBottom: "1px solid var(--border-subtle)",
boxSizing: "border-box",
fontWeight: isActive ? 600 : 400,
}}
>
{isRoot ? (
<HomeIcon fontSize="small" />
) : isActive ? (
<FolderOpenIcon fontSize="small" style={{ color }} />
) : (
<FolderIcon fontSize="small" style={{ color }} />
)}
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
</button>
);
}
@@ -0,0 +1,39 @@
import { FileId } from "@app/types/file";
import { FolderId } from "@app/types/folder";
/**
* Custom MIME used to flag drag operations originating inside the
* file manager page. Using a custom type avoids clashing with native
* file/text drags from the OS.
*/
export const FILES_PAGE_DRAG_TYPE = "application/x-stirling-files-page";
export type FilesPageDragPayload =
| { kind: "files"; fileIds: FileId[] }
| { kind: "folder"; folderId: FolderId };
export function serialiseFilesPageDragPayload(
payload: FilesPageDragPayload,
): string {
return JSON.stringify(payload);
}
export function parseFilesPageDragPayload(
dataTransfer: DataTransfer,
): FilesPageDragPayload | null {
if (!dataTransfer.types.includes(FILES_PAGE_DRAG_TYPE)) return null;
const raw = dataTransfer.getData(FILES_PAGE_DRAG_TYPE);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as FilesPageDragPayload;
if (parsed.kind === "files" && Array.isArray(parsed.fileIds)) {
return parsed;
}
if (parsed.kind === "folder" && typeof parsed.folderId === "string") {
return parsed;
}
return null;
} catch {
return null;
}
}
@@ -0,0 +1,24 @@
/**
* Classifies where a stored file lives. The UI uses this to badge each file
* (Local vs Cloud) and to drive the origin filter chip.
*
* - "local" - only in the browser's IndexedDB
* - "cloud" - also exists on the server (uploaded), still owned by the user
* - "shared-with-me" - opened from a share link / not owned by current user
*/
import { StirlingFileStub } from "@app/types/fileContext";
export type FileOrigin = "local" | "cloud" | "shared-with-me";
export const FILE_ORIGINS: FileOrigin[] = ["local", "cloud", "shared-with-me"];
export function getFileOrigin(file: StirlingFileStub): FileOrigin {
if (file.remoteSharedViaLink || file.remoteOwnedByCurrentUser === false) {
return "shared-with-me";
}
if (file.remoteStorageId) {
return "cloud";
}
return "local";
}
@@ -0,0 +1,91 @@
/**
* Stores the route the user came from when they open files into the
* workbench from My Files. Lets the workbench show a "Back to My Files"
* affordance and return to the exact folder they were browsing.
*
* Persisted in sessionStorage so a hard reload keeps the return path
* (matches user mental model - Cmd+R shouldn't lose the breadcrumb).
*/
const SESSION_KEY = "stirling.filesPage.returnRoute";
const SESSION_LABEL_KEY = "stirling.filesPage.returnLabel";
export interface FilesPageReturnRoute {
route: string;
label?: string;
}
/**
* Cached snapshot so `useSyncExternalStore` returns a stable reference
* across consecutive renders. The cache is invalidated whenever the
* sessionStorage entry changes (via set/clear or storage event).
*/
let cachedSnapshot: FilesPageReturnRoute | null = null;
let cachedSerialised = "";
function readFromStorage(): FilesPageReturnRoute | null {
try {
const route = sessionStorage.getItem(SESSION_KEY);
if (!route) return null;
const label = sessionStorage.getItem(SESSION_LABEL_KEY) ?? undefined;
return { route, label };
} catch {
return null;
}
}
function refreshSnapshot(): void {
const next = readFromStorage();
const serialised = next ? `${next.route}|${next.label ?? ""}` : "";
if (serialised !== cachedSerialised) {
cachedSnapshot = next;
cachedSerialised = serialised;
}
}
export function setFilesPageReturnRoute(route: string, label?: string): void {
try {
sessionStorage.setItem(SESSION_KEY, route);
if (label) sessionStorage.setItem(SESSION_LABEL_KEY, label);
else sessionStorage.removeItem(SESSION_LABEL_KEY);
refreshSnapshot();
window.dispatchEvent(new CustomEvent("stirling-filespage-return-changed"));
} catch {
/* ignore */
}
}
export function clearFilesPageReturnRoute(): void {
try {
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(SESSION_LABEL_KEY);
refreshSnapshot();
window.dispatchEvent(new CustomEvent("stirling-filespage-return-changed"));
} catch {
/* ignore */
}
}
export function getFilesPageReturnRoute(): FilesPageReturnRoute | null {
return cachedSnapshot;
}
/** Subscribe to changes (storage + same-tab CustomEvent). */
export function subscribeFilesPageReturnRoute(
listener: () => void,
): () => void {
const handler = () => {
refreshSnapshot();
listener();
};
window.addEventListener("storage", handler);
window.addEventListener("stirling-filespage-return-changed", handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener("stirling-filespage-return-changed", handler);
};
}
// Initial read on module load so the first useSyncExternalStore snapshot
// is correct even before any setters fire.
refreshSnapshot();
@@ -0,0 +1,41 @@
/**
* Folder icon presets. Each is a single emoji that overlays the folder
* thumbnail. Picked to cover the most common organisational uses of a
* folder without resorting to a custom icon system.
*
* Kept small (≤24) so the picker is one glanceable row in the menu.
*/
export interface FolderIconOption {
id: string;
glyph: string;
label: string;
}
export const FOLDER_ICONS: FolderIconOption[] = [
{ id: "none", glyph: "", label: "No icon" },
{ id: "star", glyph: "★", label: "Star" },
{ id: "heart", glyph: "♥", label: "Heart" },
{ id: "work", glyph: "💼", label: "Work" },
{ id: "home", glyph: "🏠", label: "Home" },
{ id: "tax", glyph: "💰", label: "Money" },
{ id: "receipt", glyph: "🧾", label: "Receipt" },
{ id: "contract", glyph: "📝", label: "Contract" },
{ id: "id", glyph: "🪪", label: "ID" },
{ id: "house", glyph: "🏡", label: "House" },
{ id: "travel", glyph: "✈️", label: "Travel" },
{ id: "photos", glyph: "🖼️", label: "Photos" },
{ id: "music", glyph: "🎵", label: "Music" },
{ id: "code", glyph: "💻", label: "Code" },
{ id: "health", glyph: "🏥", label: "Health" },
{ id: "school", glyph: "🎓", label: "School" },
{ id: "warning", glyph: "⚠️", label: "Warning" },
{ id: "archive", glyph: "📦", label: "Archive" },
];
export function findFolderIcon(
id: string | undefined,
): FolderIconOption | null {
if (!id) return null;
return FOLDER_ICONS.find((i) => i.id === id) ?? null;
}
@@ -0,0 +1,83 @@
import { FolderRecord } from "@app/types/folder";
const STORAGE_KEY = "stirling-folder-tree-width";
export const MIN_WIDTH = 210;
export const MAX_WIDTH = 480;
const DEFAULT_WIDTH = 272;
const ROW_FONT =
'14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
const COUNT_FONT =
'12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
/** Per-row chrome: left padding + toggle + icon + name margin + count margin + right padding. */
const ROW_CHROME = 14 + 16 + 18 + 8 + 12 + 8 + 14;
const INDENT_PER_LEVEL = 16;
function loadCanvas(): CanvasRenderingContext2D | null {
if (typeof document === "undefined") return null;
const canvas = document.createElement("canvas");
return canvas.getContext("2d");
}
function depthOf(
folder: FolderRecord,
byId: Map<string, FolderRecord>,
): number {
let depth = 0;
let cursor: FolderRecord | undefined = folder;
while (cursor && cursor.parentFolderId) {
depth += 1;
cursor = byId.get(cursor.parentFolderId as string);
if (depth > 50) break;
}
return depth;
}
/** Width needed to fit the longest folder row, clamped to [MIN, MAX]. */
export function computeAutoFitWidth(
folders: FolderRecord[],
rootLabel: string,
): number {
const ctx = loadCanvas();
if (!ctx) return DEFAULT_WIDTH;
const byId = new Map(folders.map((f) => [f.id as string, f]));
let maxName = 0;
let maxDepth = 0;
ctx.font = ROW_FONT;
const measure = (name: string) => Math.ceil(ctx.measureText(name).width);
maxName = Math.max(maxName, measure(rootLabel));
for (const f of folders) {
const w = measure(f.name);
const d = depthOf(f, byId);
if (w > maxName) maxName = w;
if (d > maxDepth) maxDepth = d;
}
ctx.font = COUNT_FONT;
// 4 digits covers typical counts (9999).
const countWidth = Math.ceil(ctx.measureText("9999").width);
const width = ROW_CHROME + maxDepth * INDENT_PER_LEVEL + maxName + countWidth;
return clamp(width);
}
export function clamp(width: number): number {
if (Number.isNaN(width)) return DEFAULT_WIDTH;
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(width)));
}
export function loadPersistedWidth(): number | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage?.getItem(STORAGE_KEY);
if (raw == null) return null;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return null;
return clamp(parsed);
}
export function savePersistedWidth(width: number): void {
if (typeof window === "undefined") return;
try {
window.localStorage?.setItem(STORAGE_KEY, String(clamp(width)));
} catch {
// localStorage can throw in private mode or when over quota; ignore.
}
}
@@ -0,0 +1,121 @@
/**
* useDropTarget - stable hover-state tracking for drop targets.
*
* The default HTML5 drag-and-drop API fires `dragenter`/`dragleave` for every
* child element the pointer crosses, which makes naive `setHover(true/false)`
* implementations flicker. This hook tracks enter/leave with a counter so the
* hover state only resets when the pointer truly leaves the bounding box, and
* caches the last `dropEffect`/payload to avoid redundant state updates.
*/
import {
DragEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from "react";
interface UseDropTargetOptions {
/**
* MIME type of the drag payload to react to. dragover/drop events for
* other payloads are ignored so external file drags still bubble up to
* the page-level drop zone.
*/
dragType: string;
/** Fired when the user releases over the target. */
onDrop: (event: React.DragEvent<HTMLElement>) => void;
/** Optional CSS effect - defaults to "move". */
dropEffect?: DataTransfer["dropEffect"];
/** Disable the target without unmounting. */
disabled?: boolean;
}
export interface DropTargetBinding {
/** Bind to the element you want to act as a drop target. */
handlers: {
onDragEnter: DragEventHandler<HTMLElement>;
onDragOver: DragEventHandler<HTMLElement>;
onDragLeave: DragEventHandler<HTMLElement>;
onDrop: DragEventHandler<HTMLElement>;
};
/** True while the pointer is over the element (or any of its children). */
isOver: boolean;
}
export function useDropTarget({
dragType,
onDrop,
dropEffect = "move",
disabled,
}: UseDropTargetOptions): DropTargetBinding {
const [isOver, setIsOver] = useState(false);
const counter = useRef(0);
// If the element gets unmounted mid-drag, reset state.
useEffect(
() => () => {
counter.current = 0;
},
[],
);
const accepts = useCallback(
(e: React.DragEvent<HTMLElement>) =>
e.dataTransfer.types.includes(dragType),
[dragType],
);
const handleDragEnter = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
e.preventDefault();
counter.current += 1;
if (!isOver) setIsOver(true);
},
[accepts, disabled, isOver],
);
const handleDragOver = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = dropEffect;
if (!isOver) setIsOver(true);
},
[accepts, disabled, dropEffect, isOver],
);
const handleDragLeave = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
counter.current -= 1;
if (counter.current <= 0) {
counter.current = 0;
setIsOver(false);
}
},
[accepts, disabled],
);
const handleDrop = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
e.preventDefault();
counter.current = 0;
setIsOver(false);
onDrop(e);
},
[accepts, disabled, onDrop],
);
return {
handlers: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
isOver,
};
}
@@ -27,6 +27,9 @@ const PageEditorControls = lazy(
() => import("@app/components/pageEditor/PageEditorControls"),
);
const Viewer = lazy(() => import("@app/components/viewer/Viewer"));
const FileManagerView = lazy(
() => import("@app/components/filesPage/FileManagerView"),
);
// No props needed - component uses contexts directly
export default function Workbench() {
@@ -99,6 +102,12 @@ export default function Workbench() {
}
}
// The "My Files" workbench is available regardless of whether files are
// currently loaded into the workbench - it lives on top of the IDB store.
if (currentView === "myFiles") {
return <FileManagerView />;
}
if (activeFiles.length === 0) {
return <LandingPage />;
}
@@ -183,27 +192,31 @@ export default function Workbench() {
data-tour="workbench"
style={
isRainbowMode
? {} // No background color in rainbow mode
: { backgroundColor: "var(--bg-background)" }
? // No background color in rainbow mode, but still pin min-width:0
// so inner flex children (files-page toolbar, etc.) actually
// shrink on narrow viewports.
{ minWidth: 0 }
: { backgroundColor: "var(--bg-background)", minWidth: 0 }
}
>
{/* Workbench Bar - animates in/out based on file presence */}
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
{currentView !== "myFiles" &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
</div>
</div>
</div>
)}
)}
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
@@ -213,6 +226,11 @@ export default function Workbench() {
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
style={{
transition: "opacity 0.15s ease-in-out",
// Force min-width:0 so flex children (notably the files page
// toolbar with its 5 bulk-action buttons + 2 selects + view
// toggle) can shrink below their intrinsic content size on
// narrow viewports instead of overflowing horizontally.
minWidth: 0,
...(currentView === "pageEditor" && { height: 0 }),
}}
>
@@ -74,12 +74,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
}
}, [opened]);
// Handle custom events for backwards compatibility
// Handle custom events for backwards compatibility.
// Use replace when already on /settings/* so external tab-switches
// don't pile up history entries that would break close-by-back.
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
navigate(`/settings/${detail.key}`);
const alreadyInSettings =
window.location.pathname.startsWith("/settings");
navigate(`/settings/${detail.key}`, {
replace: alreadyInSettings,
});
}
};
window.addEventListener("appConfig:navigate", handler as EventListener);
@@ -112,10 +118,17 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
const canProceed = await confirmIfDirty();
if (!canProceed) return;
// Navigate back to home when closing modal
navigate("/", { replace: true });
// Pop back to whatever the user came from (files / viewer / tools).
// location.key === "default" means /settings was the first entry in
// this tab (deep link / refresh), so there's nothing to pop to;
// fall back to home in that case.
if (location.key === "default") {
navigate("/", { replace: true });
} else {
navigate(-1);
}
onClose();
}, [confirmIfDirty, navigate, onClose]);
}, [confirmIfDirty, location.key, navigate, onClose]);
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
const handleCloseSync = useCallback(() => {
@@ -152,7 +165,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
if (!canProceed) return;
setActive(key);
navigate(`/settings/${key}`);
// First in-modal nav (when current path isn't `/settings/*` yet) must
// PUSH so the originating page stays in history and close-by-back can
// return to it. Subsequent tab switches REPLACE so they don't pile up
// history entries that handleClose's navigate(-1) can't unwind.
//
// Read window.location.pathname directly (not the React hook's
// location.pathname) so rapid successive clicks pick up the URL
// change from the previous click immediately. The hook snapshot is
// stale between render cycles - relying on it lets a second click
// PUSH again before React re-renders, producing a history pile-up.
const alreadyInSettings =
window.location.pathname.startsWith("/settings");
navigate(`/settings/${key}`, { replace: alreadyInSettings });
},
[confirmIfDirty, navigate],
);

Some files were not shown because too many files have changed in this diff Show More