Compare commits

..
Author SHA1 Message Date
EthanHealy01 600add1474 run frontend fix 2026-05-27 12:42:22 +01:00
EthanHealy01 8451b06d53 chore: set aiEngine env vars automatically in dev:all
When AIENGINE_URL is provided (i.e. task dev:all), also set
AIENGINE_ENABLED=true and AIENGINE_TIMEOUTSECONDS=120 so the backend
picks up the full AI engine config without needing it in settings.yml.
2026-05-27 12:12:42 +01:00
EthanHealy01 7bb3024dc9 feat: add midnight theme and fix theme locations
Adds midnight dark theme, fixes broken theme variable locations, adds
clean-unused-theme-vars script, and updates RainbowThemeProvider and
related theme constants.
2026-05-27 11:37:49 +01:00
EthanHealy01 18cd2baec0 feat: add Agents UI to proprietary right sidebar
Adds agents panel to the right sidebar, gated behind aiEngineEnabled.
Includes RightSidebar refactor, FullscreenToolPanel, core stubs, and
proprietary AgentsPanel with Stirling hero CTA and coming-soon agents.
2026-05-27 11:14:19 +01:00
252 changed files with 6775 additions and 22674 deletions
+9 -9
View File
@@ -1,20 +1,20 @@
###############################################################################
# Stirling-PDF SaaS environment defaults.
# Stirling-PDF SaaS local environment template.
#
# 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.
# 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.
#
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
# DO NOT commit `.env.saas.local`. Only `.env.saas.example` is checked in.
###############################################################################
# ---------- Supabase project ----------
# Project reference (the subdomain part of <ref>.supabase.co). Required.
# Set in .env.saas.local.
# Example dev project:
SAAS_DB_PROJECT_REF=
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
# Edge function secret used by billing/license rollup calls.
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 in .env.saas.local.
# password must still be provided here.
SAAS_DEV_DB_URL=
SAAS_DEV_DB_USERNAME=postgres
SAAS_DEV_DB_PASSWORD=
-2
View File
@@ -38,8 +38,6 @@ 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/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
# 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
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/editor/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/public/locales/en-GB/translation.toml)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
+1 -2
View File
@@ -287,7 +287,6 @@ 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"
@@ -310,7 +309,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,storage}
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
# 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,18 +68,6 @@ 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]
@@ -196,7 +184,6 @@ jobs:
needs:
- files-changed
- build
- db-migration-test
- check-generateOpenApiDocs
- frontend-validation
- playwright-e2e
@@ -221,7 +208,6 @@ 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
@@ -1,93 +0,0 @@
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
+7 -9
View File
@@ -586,23 +586,21 @@ jobs:
if: always() && steps.digicert-setup.conclusion != 'failure'
shell: bash
run: |
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$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
+6 -11
View File
@@ -157,9 +157,6 @@ 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
@@ -420,22 +417,20 @@ jobs:
- name: Rename artifacts
shell: bash
run: |
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$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.
+3 -8
View File
@@ -23,10 +23,6 @@ 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
@@ -161,7 +157,9 @@ app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
frontend/editor/src-tauri/provisioner/target
frontend/src-tauri/provisioner/target
frontend/src-tauri/target
frontend/src-tauri/runtime/
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -277,6 +275,3 @@ docs/type3/signatures/
# Playwright MCP screenshots / traces
.playwright-mcp/
*.playwright-mcp.png
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/screenshots/
+5 -5
View File
@@ -25,9 +25,9 @@ tasks:
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS=120 {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS=120 {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -40,10 +40,10 @@ tasks:
platforms: [linux, darwin]
dev:saas:
desc: "Start backend in SaaS flavor against Supabase"
desc: "Start backend in SaaS flavor against Supabase (loads .env.saas.local)"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`.
dotenv: ['app/.env.saas.local', 'app/.env.saas']
# subtaskfile is included with `dir: .`. Drop the file at the repo root.
dotenv: ['.env.saas.local']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
-7
View File
@@ -78,13 +78,6 @@ 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
+15
View File
@@ -257,6 +257,7 @@ tasks:
- task: lint
- task: format:check
- task: test
- task: theme:check-unused-vars
check:all:
desc: "Full CI quality gate"
@@ -298,3 +299,17 @@ tasks:
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
theme:clean-unused-vars:
desc: "Remove unused CSS variables from theme.css"
deps: [install]
vars:
REMOVE: '{{.REMOVE | default "false"}}'
cmds:
- node editor/scripts/clean-unused-theme-vars.js{{if eq .REMOVE "true"}} --remove{{end}} {{.CLI_ARGS}}
theme:check-unused-vars:
desc: "Fail if theme.css contains unused CSS variables"
deps: [install]
cmds:
- node editor/scripts/clean-unused-theme-vars.js --check
+1 -18
View File
@@ -58,23 +58,6 @@ 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:
@@ -91,7 +74,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: frontend:dev:prototypes
- task: frontend:dev
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
-3
View File
@@ -1,3 +0,0 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
-4
View File
@@ -44,10 +44,6 @@
"moduleName": ".*",
"moduleLicense": "The MIT License"
},
{
"moduleName": ".*",
"moduleLicense": "MIT-0"
},
{
"moduleName": "com.github.jai-imageio:jai-imageio-core",
"moduleLicense": "LICENSE.txt"
-3
View File
@@ -78,9 +78,6 @@ dependencies {
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
}
@@ -1,24 +0,0 @@
package stirling.software.common.cluster;
/** Health and identity facade for the active cluster backplane. */
public interface ClusterBackplane {
/** Returns {@code true} when the backplane is reachable; used for health endpoints. */
boolean isHealthy();
/** Returns {@code "inprocess"} or {@code "valkey"}. */
String backplaneType();
/** Returns this JVM's stable node id (matches {@code Cluster.resolvedNodeId()}). */
String localNodeId();
/**
* Whether this JVM should run the local {@link
* stirling.software.common.service.TaskManager#cleanupOldJobs()} loop. Distributed backplanes
* own job expiry via their own TTL, so they should override this to return {@code false}.
* Defaults to {@code true} so in-process behavior is preserved without an explicit override.
*/
default boolean shouldRunLocalCleanup() {
return true;
}
}
@@ -1,63 +0,0 @@
package stirling.software.common.cluster;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
/**
* Validates that cluster mode is internally consistent.
*
* <p>Cluster settings are bound on the central {@link ApplicationProperties} under {@code
* cluster.*}; this class reads {@link ApplicationProperties#getCluster()} and runs guards in {@link
* PostConstruct}. When {@code cluster.enabled=false} (the default) all checks are skipped so a
* single-instance install needs no new config.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class ClusterConfig {
private final ApplicationProperties applicationProperties;
@PostConstruct
void validate() {
Cluster cluster = applicationProperties.getCluster();
if (!cluster.isEnabled()) {
return;
}
String backplane = cluster.getBackplane();
if ("valkey".equalsIgnoreCase(backplane)) {
String url = cluster.getValkey() == null ? null : cluster.getValkey().getUrl();
if (url == null || url.isBlank()) {
throw new IllegalStateException(
"cluster.enabled=true with backplane=valkey requires"
+ " cluster.valkey.url to be set (e.g."
+ " redis://valkey:6379).");
}
} else if ("inprocess".equalsIgnoreCase(backplane)) {
// enabled+inprocess only coordinates the local JVM; cross-node lookups will 410.
log.warn(
"cluster.enabled=true with backplane=inprocess - only the local"
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
+ " Use backplane=valkey for real multi-node deployments.");
} else {
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
throw new IllegalStateException(
"cluster.enabled=true with unknown backplane '"
+ backplane
+ "'. Valid values: inprocess | valkey.");
}
log.info(
"Cluster mode enabled (backplane={}, nodeRole={}, nodeId={}).",
backplane,
cluster.resolvedRole(),
cluster.resolvedNodeId());
}
}
@@ -1,12 +0,0 @@
package stirling.software.common.cluster;
import java.time.Instant;
/**
* Snapshot of a peer node as recorded in the {@link InstanceRegistry}.
*
* @param internalAddress {@code host:port} the node listens on for {@code /internal/cluster/**}
* @param role one of {@code WEB}, {@code WORKER}, {@code BOTH}
*/
public record ClusterNode(
String nodeId, String internalAddress, Instant lastHeartbeat, String role) {}
@@ -1,21 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Optional;
/** Cluster-wide mutual exclusion primitive; non-reentrant by contract. */
public interface DistributedLock {
Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime);
interface LockHandle extends AutoCloseable {
void release();
boolean renew(Duration leaseTime);
@Override
default void close() {
release();
}
}
}
@@ -1,45 +0,0 @@
package stirling.software.common.cluster;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
/** Low-level storage seam for result/job files. */
public interface FileStore {
/** Stored file record. */
record Stored(String fileId, long size) {}
/** Store the given stream and return a generated file id and total bytes written. */
Stored store(InputStream in, String originalName) throws IOException;
/**
* Store the file at {@code source} and return a generated file id and total bytes written.
*
* <p>Default implementation opens {@code source} as a stream and delegates to {@link
* #store(InputStream, String)}. Local-disk implementations should override to use a direct
* file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux),
* which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap.
*/
default Stored store(Path source, String originalName) throws IOException {
try (InputStream in = Files.newInputStream(source)) {
return store(in, originalName);
}
}
/** Open the stored file for streaming reads. Caller closes. */
InputStream retrieve(String fileId) throws IOException;
/** Load the stored file into a byte array. */
byte[] retrieveBytes(String fileId) throws IOException;
/** Size of the stored file in bytes. */
long size(String fileId) throws IOException;
/** Delete the stored file. Returns true if a file was removed. */
boolean delete(String fileId);
/** Whether the file id exists in the store. */
boolean exists(String fileId);
}
@@ -1,18 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Collection;
import java.util.Optional;
/** Maps {@code nodeId} to its internal cluster address, with TTL'd heartbeats. */
public interface InstanceRegistry {
/** Register or refresh this node. Idempotent so a wiped backplane self-heals on next tick. */
void register(ClusterNode node, Duration heartbeatTtl);
Optional<ClusterNode> lookup(String nodeId);
Collection<ClusterNode> activeNodes();
void deregister(String nodeId);
}
@@ -1,24 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Collection;
import java.util.Optional;
/** Cluster-visible storage for job status and result metadata, with TTL'd entries. */
public interface JobStore {
/** Persist or overwrite a job entry. {@code ttl} sets the lifetime of the entry. */
void put(JobStoreEntry entry, Duration ttl);
Optional<JobStoreEntry> get(String jobId);
void delete(String jobId);
boolean exists(String jobId);
/** Reverse lookup: which job owns this result file id? */
Optional<String> findJobIdByFileId(String fileId);
/** Snapshot of every active entry. Used by admin/stats endpoints; may be O(n). */
Collection<JobStoreEntry> all();
}
@@ -1,30 +0,0 @@
package stirling.software.common.cluster;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* Cluster-visible projection of a job's status and result metadata, as persisted in {@link
* JobStore}.
*
* @param owningNodeId the node id that originally executed the job
*/
public record JobStoreEntry(
String jobId,
JobState state,
String owningNodeId,
Instant createdAt,
Instant completedAt,
String error,
List<String> fileIds,
Map<String, String> resultMeta) {
/** Lifecycle states for a job as observed by the cluster. */
public enum JobState {
PENDING,
RUNNING,
COMPLETE,
FAILED
}
}
@@ -1,16 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Optional;
/** Short-TTL namespaced key/value cache backed by the cluster backplane. */
public interface KeyValueCache {
void put(String namespace, String key, String value, Duration ttl);
Optional<String> get(String namespace, String key);
void evict(String namespace, String key);
void evictNamespace(String namespace);
}
@@ -1,18 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
/** Token-bucket rate limiting backed by the cluster backplane. */
public interface RateLimitStore {
/**
* Attempt to consume one token from the bucket identified by {@code bucketKey}.
*
* @param bucketKey opaque key identifying the bucket (e.g. {@code api:user:123})
* @param capacity bucket capacity
* @param refillPeriod time window over which {@code capacity} tokens refill
*/
RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod);
record RateLimitDecision(boolean allowed, long remainingTokens, long nanosToWaitForRefill) {}
}
@@ -1,7 +0,0 @@
package stirling.software.common.cluster;
/** Records one increment per sticky-session miss (a 410 Gone for a job owned by another node). */
@FunctionalInterface
public interface StickyMissRecorder {
void recordStickyMiss();
}
@@ -1,31 +0,0 @@
package stirling.software.common.cluster.inprocess;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
public class InProcessClusterBackplane implements ClusterBackplane {
private final ApplicationProperties applicationProperties;
public InProcessClusterBackplane(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Override
public boolean isHealthy() {
return true;
}
@Override
public String backplaneType() {
return "inprocess";
}
@Override
public String localNodeId() {
return applicationProperties.getCluster().resolvedNodeId();
}
}
@@ -1,65 +0,0 @@
package stirling.software.common.cluster.inprocess;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.DistributedLock;
import stirling.software.common.cluster.InstanceRegistry;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.KeyValueCache;
import stirling.software.common.cluster.RateLimitStore;
import stirling.software.common.model.ApplicationProperties;
/**
* Default cluster backplane wiring: every interface gets an {@code InProcess*} bean. Active when
* cluster mode is off or {@code cluster.backplane=inprocess}.
*/
@Slf4j
@Configuration
@ConditionalOnExpression(
"!${cluster.enabled:false} ||"
+ " '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')")
public class InProcessClusterConfiguration {
@Bean
@ConditionalOnMissingBean
public ClusterBackplane clusterBackplane(ApplicationProperties applicationProperties) {
log.info("Cluster backplane: in-process (single node)");
return new InProcessClusterBackplane(applicationProperties);
}
@Bean
@ConditionalOnMissingBean
public JobStore jobStore() {
return new InProcessJobStore();
}
@Bean
@ConditionalOnMissingBean
public RateLimitStore rateLimitStore() {
return new InProcessRateLimitStore();
}
@Bean
@ConditionalOnMissingBean
public DistributedLock distributedLock() {
return new InProcessDistributedLock();
}
@Bean
@ConditionalOnMissingBean
public KeyValueCache keyValueCache() {
return new InProcessKeyValueCache();
}
@Bean
@ConditionalOnMissingBean
public InstanceRegistry instanceRegistry() {
return new InProcessInstanceRegistry();
}
}
@@ -1,128 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import stirling.software.common.cluster.DistributedLock;
/**
* In-process {@link DistributedLock}, non-reentrant per the interface contract, with lease-expiry
* semantics that mirror a SET-NX-EX style distributed backend.
*
* <p>Each lock state carries a per-acquire {@code ownerToken} and an {@code expiryNanos}; another
* caller can take over once the lease has elapsed even if the original holder never called {@link
* LockHandle#release()}. This matters mostly for parity with the Valkey-backed implementation
* (Redis {@code SETEX} auto-expires the key); within a single JVM a crashed holder takes its lock
* state with it, but tests and code that rely on the {@code leaseTime} parameter still need it to
* be honored.
*
* <p>Expiry is lazy: an expired lock state lingers in the map until the next acquire attempt for
* the same key replaces it. Per-key cleanup also happens on explicit {@link LockHandle#release()},
* so a balanced acquire/release workload keeps the map size bounded.
*/
public class InProcessDistributedLock implements DistributedLock {
private final ConcurrentHashMap<String, LockState> locks = new ConcurrentHashMap<>();
private final AtomicLong tokenSeq = new AtomicLong();
/**
* Lease state for a single acquired lock. {@code ownerToken} prevents a former holder from
* releasing or renewing a lock now owned by someone else after lease expiry; {@code
* expiryNanos} is read/written only inside {@link ConcurrentHashMap#compute} so the bin lock
* provides the necessary happens-before guarantee.
*/
private static final class LockState {
final long ownerToken;
long expiryNanos;
LockState(long ownerToken, long expiryNanos) {
this.ownerToken = ownerToken;
this.expiryNanos = expiryNanos;
}
}
@Override
public Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime) {
long token = tokenSeq.incrementAndGet();
long nowNanos = System.nanoTime();
long expiryNanos = nowNanos + leaseTime.toNanos();
boolean[] acquired = {false};
locks.compute(
lockKey,
(k, existing) -> {
if (existing == null || existing.expiryNanos - nowNanos <= 0L) {
// No lock, or the previous lease has expired - we take it. Subtraction
// form avoids the long-overflow trap that would bite a naive
// expiryNanos <= nowNanos comparison around System.nanoTime() rollover.
acquired[0] = true;
return new LockState(token, expiryNanos);
}
return existing;
});
if (!acquired[0]) {
return Optional.empty();
}
return Optional.of(new InProcessHandle(lockKey, token));
}
private void releaseInternal(String lockKey, long token) {
locks.compute(
lockKey,
(k, existing) -> {
if (existing == null || existing.ownerToken != token) {
// Already removed, expired-and-replaced, or never ours.
return existing;
}
return null;
});
}
private boolean renewInternal(String lockKey, long token, Duration leaseTime) {
long nowNanos = System.nanoTime();
boolean[] renewed = {false};
locks.compute(
lockKey,
(k, existing) -> {
if (existing == null
|| existing.ownerToken != token
|| existing.expiryNanos - nowNanos <= 0L) {
// Lock is gone or expired; renewal is a no-op so the caller can detect it.
return existing;
}
existing.expiryNanos = nowNanos + leaseTime.toNanos();
renewed[0] = true;
return existing;
});
return renewed[0];
}
private final class InProcessHandle implements LockHandle {
private final String lockKey;
private final long token;
private boolean released;
InProcessHandle(String lockKey, long token) {
this.lockKey = lockKey;
this.token = token;
}
@Override
public synchronized void release() {
if (released) {
return;
}
released = true;
releaseInternal(lockKey, token);
}
@Override
public synchronized boolean renew(Duration leaseTime) {
if (released) {
return false;
}
return renewInternal(lockKey, token, leaseTime);
}
}
}
@@ -1,42 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import stirling.software.common.cluster.ClusterNode;
import stirling.software.common.cluster.InstanceRegistry;
public class InProcessInstanceRegistry implements InstanceRegistry {
private final AtomicReference<ClusterNode> self = new AtomicReference<>();
@Override
public void register(ClusterNode node, Duration heartbeatTtl) {
self.set(node);
}
@Override
public Optional<ClusterNode> lookup(String nodeId) {
ClusterNode current = self.get();
return current != null && current.nodeId().equals(nodeId)
? Optional.of(current)
: Optional.empty();
}
@Override
public Collection<ClusterNode> activeNodes() {
ClusterNode current = self.get();
return current == null ? Collections.emptyList() : Collections.singletonList(current);
}
@Override
public void deregister(String nodeId) {
ClusterNode current = self.get();
if (current != null && current.nodeId().equals(nodeId)) {
self.set(null);
}
}
}
@@ -1,96 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
@Slf4j
public class InProcessJobStore implements JobStore {
private final ConcurrentHashMap<String, Holder> entries = new ConcurrentHashMap<>();
@Override
public void put(JobStoreEntry entry, Duration ttl) {
Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl);
entries.put(entry.jobId(), new Holder(entry, expiry));
}
@Override
public Optional<JobStoreEntry> get(String jobId) {
Holder h = entries.get(jobId);
if (h == null) {
return Optional.empty();
}
if (h.isExpired()) {
entries.remove(jobId, h);
return Optional.empty();
}
return Optional.of(h.entry);
}
@Override
public void delete(String jobId) {
entries.remove(jobId);
}
@Override
public boolean exists(String jobId) {
return get(jobId).isPresent();
}
@Override
public Optional<String> findJobIdByFileId(String fileId) {
for (Map.Entry<String, Holder> e : entries.entrySet()) {
Holder h = e.getValue();
if (h.isExpired()) {
continue;
}
List<String> fileIds = h.entry.fileIds();
if (fileIds != null && fileIds.contains(fileId)) {
return Optional.of(e.getKey());
}
}
return Optional.empty();
}
@Override
public Collection<JobStoreEntry> all() {
List<JobStoreEntry> result = new ArrayList<>(entries.size());
for (Holder h : entries.values()) {
if (!h.isExpired()) {
result.add(h.entry);
}
}
return result;
}
/** Drop entries whose TTL has elapsed. Called by the {@code TaskManager} cleanup scheduler. */
public int purgeExpired() {
int removed = 0;
Instant now = Instant.now();
for (Map.Entry<String, Holder> e : entries.entrySet()) {
if (!e.getValue().expiry.equals(Instant.MAX) && e.getValue().expiry.isBefore(now)) {
if (entries.remove(e.getKey(), e.getValue())) {
removed++;
}
}
}
return removed;
}
private record Holder(JobStoreEntry entry, Instant expiry) {
boolean isExpired() {
return !expiry.equals(Instant.MAX) && expiry.isBefore(Instant.now());
}
}
}
@@ -1,55 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.common.cluster.KeyValueCache;
public class InProcessKeyValueCache implements KeyValueCache {
private final ConcurrentHashMap<String, ConcurrentHashMap<String, Expiring>> namespaces =
new ConcurrentHashMap<>();
@Override
public void put(String namespace, String key, String value, Duration ttl) {
Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl);
namespaces
.computeIfAbsent(namespace, n -> new ConcurrentHashMap<>())
.put(key, new Expiring(value, expiry));
}
@Override
public Optional<String> get(String namespace, String key) {
Map<String, Expiring> ns = namespaces.get(namespace);
if (ns == null) {
return Optional.empty();
}
Expiring e = ns.get(key);
if (e == null) {
return Optional.empty();
}
if (e.expiry.isBefore(Instant.now())) {
ns.remove(key, e);
return Optional.empty();
}
return Optional.of(e.value);
}
@Override
public void evict(String namespace, String key) {
Map<String, Expiring> ns = namespaces.get(namespace);
if (ns != null) {
ns.remove(key);
}
}
@Override
public void evictNamespace(String namespace) {
namespaces.remove(namespace);
}
private record Expiring(String value, Instant expiry) {}
}
@@ -1,49 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.local.LocalBucketBuilder;
import stirling.software.common.cluster.RateLimitStore;
/** Bucket4j-backed token bucket implementation of {@link RateLimitStore}. */
public class InProcessRateLimitStore implements RateLimitStore {
/** Cap to bound memory; oldest accessed buckets are evicted. */
private static final int MAX_BUCKETS = 10_000;
private final Map<String, Bucket> buckets =
Collections.synchronizedMap(
new LinkedHashMap<String, Bucket>(256, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Bucket> eldest) {
return size() > MAX_BUCKETS;
}
});
@Override
public RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod) {
String compositeKey = bucketKey + "|" + capacity + "|" + refillPeriod.toNanos();
Bucket bucket =
buckets.computeIfAbsent(compositeKey, k -> buildBucket(capacity, refillPeriod));
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
return new RateLimitDecision(
probe.isConsumed(),
probe.getRemainingTokens(),
probe.isConsumed() ? 0L : probe.getNanosToWaitForRefill());
}
private static Bucket buildBucket(long capacity, Duration refillPeriod) {
Bandwidth limit =
Bandwidth.builder().capacity(capacity).refillGreedy(capacity, refillPeriod).build();
LocalBucketBuilder builder = Bucket.builder();
builder.addLimit(limit);
return builder.build();
}
}
@@ -1,128 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
/** Local-disk {@link FileStore} storing files under a base directory keyed by a UUID file id. */
@Slf4j
public class LocalDiskFileStore implements FileStore {
private final String baseDirPath;
public LocalDiskFileStore(String baseDirPath) {
this.baseDirPath = baseDirPath;
}
@Override
public Stored store(InputStream in, String originalName) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
boolean success = false;
try {
long size = Files.copy(in, filePath);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
}
/**
* File-to-file copy. {@link Files#copy(Path, Path, java.nio.file.CopyOption...)} can use {@code
* sendfile(2)} on Linux for a zero-copy kernel transfer when source and destination share a
* filesystem, avoiding the streaming overhead of pulling the bytes through the JVM heap. Reads
* the source size before copying so the post-copy stat is unnecessary.
*/
@Override
public Stored store(Path source, String originalName) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.size(source);
boolean success = false;
try {
Files.copy(source, filePath);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
}
@Override
public InputStream retrieve(String fileId) throws IOException {
return new BufferedInputStream(Files.newInputStream(resolve(fileId)));
}
@Override
public byte[] retrieveBytes(String fileId) throws IOException {
Path filePath = resolve(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.readAllBytes(filePath);
}
@Override
public long size(String fileId) throws IOException {
Path filePath = resolve(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
@Override
public boolean delete(String fileId) {
try {
return Files.deleteIfExists(resolve(fileId));
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
@Override
public boolean exists(String fileId) {
return Files.exists(resolve(fileId));
}
public Path resolve(String fileId) {
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
}
}
@@ -1,29 +0,0 @@
package stirling.software.common.cluster.inprocess;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import stirling.software.common.cluster.FileStore;
/**
* Always-on wiring for the per-node local-disk {@link FileStore}. Active when {@code
* cluster.artifactStore=local} (the default; {@code matchIfMissing=true}). The S3 artifact-store
* supplies its own bean when {@code cluster.artifactStore=s3}.
*/
@Configuration
@ConditionalOnProperty(
prefix = "cluster",
name = "artifactStore",
havingValue = "local",
matchIfMissing = true)
public class LocalDiskFileStoreConfiguration {
@Bean
@ConditionalOnMissingBean
public FileStore fileStore(@Value("${stirling.tempDir:/tmp/stirling-files}") String tempDir) {
return new LocalDiskFileStore(tempDir);
}
}
@@ -13,7 +13,6 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -78,7 +77,6 @@ public class ApplicationProperties {
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -256,106 +254,6 @@ public class ApplicationProperties {
private int longRunningTimeoutSeconds = 600;
}
/**
* Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix
* (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to
* off; when off the in-process backplane is wired and no other cluster keys are required.
*/
@Data
public static class Cluster {
/** Master switch. When {@code false} (default) the in-process backplane is wired. */
private boolean enabled = false;
/** Backplane implementation selector. Valid values: {@code inprocess} | {@code valkey}. */
private String backplane = "inprocess";
/**
* Transient cluster job-artifact store selector. Valid values: {@code local} | {@code s3}.
*
* <p>This is distinct from {@code storage.provider}, which selects the backend for
* persistent user-uploaded files. The two switches exist because the user-facing storage
* feature is optional ({@code storage.enabled=false} is common) but every multi-node
* cluster still needs a shared artifact store to serve cross-node downloads. Both
* implementations share credentials from {@code storage.s3.*} when set to {@code s3}.
*/
private String artifactStore = "local";
private Valkey valkey = new Valkey();
private Node node = new Node();
private transient String cachedNodeId;
public NodeRole resolvedRole() {
if (node == null || node.getRole() == null) {
return NodeRole.BOTH;
}
String value = node.getRole().trim().toUpperCase(Locale.ROOT);
try {
return NodeRole.valueOf(value);
} catch (IllegalArgumentException ex) {
return NodeRole.BOTH;
}
}
public synchronized String resolvedNodeId() {
if (node != null && node.getId() != null && !node.getId().isBlank()) {
return node.getId();
}
if (cachedNodeId == null) {
cachedNodeId = UUID.randomUUID().toString();
}
return cachedNodeId;
}
public enum NodeRole {
WEB,
WORKER,
BOTH
}
@Data
public static class Valkey {
/**
* {@code redis://host:6379} or {@code rediss://...} for TLS. Required when cluster mode
* is on and backplane is valkey.
*/
private String url = "";
private Tls tls = new Tls();
@Data
public static class Tls {
/**
* When {@code true}, skip Valkey/Redis TLS certificate verification (dev/test
* only). Leave {@code false} in production.
*/
private boolean skipCertVerification = false;
}
}
@Data
public static class Node {
/** Optional explicit node id. Blank = auto-generated UUID at startup. */
private String id = "";
/** {@code web} | {@code worker} | {@code both}. */
private String role = "both";
/**
* Internal cluster address advertised in the instance registry (host:port). Blank =
* derived at startup.
*/
private String internalAddress = "";
/** {@code http} | {@code https} - scheme used when peers call this node. */
private String scheme = "http";
/** Heartbeat publish interval for the instance registry, in milliseconds. */
private long heartbeatIntervalMs = 5000;
}
}
/**
* HTTP timeouts for loopback calls to internal Stirling API endpoints, used by the AI workflow
* executor and the pipeline processor. A bounded read timeout prevents a hung tool (e.g. an
@@ -528,16 +426,6 @@ 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();
@@ -788,7 +676,6 @@ 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();
@@ -798,57 +685,6 @@ 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;
@@ -1,13 +1,15 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -16,11 +18,9 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
* system to handle file references. Disk I/O is delegated to the injected {@link FileStore} bean.
* system to handle file references.
*/
@Service
@RequiredArgsConstructor
@@ -30,150 +30,251 @@ public class FileStorage {
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
public record StoredFile(String fileId, long size) {}
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
private final FileOrUploadService fileOrUploadService;
private final FileStore fileStore;
/**
* Store a file and return its unique ID
*
* @param file The file to store
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeFile(MultipartFile file) throws IOException {
// Fast path: when Spring buffered the multipart to disk (typical for large uploads), the
// backing Resource exposes a real File. Hand the Path to the FileStore so it can do a
// file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming
// the bytes through an 8K buffer. Falls back to the InputStream path for in-memory
// multiparts, exotic Resource impls, and anything that does not back onto a File.
Resource res;
try {
res = file.getResource();
} catch (RuntimeException ignored) {
res = null;
}
if (res != null && res.isFile()) {
try {
FileStore.Stored stored =
fileStore.store(res.getFile().toPath(), file.getOriginalFilename());
log.debug("Stored file with ID: {} (fast path)", stored.fileId());
return stored.fileId();
} catch (IOException ex) {
// Some Resource impls advertise isFile()=true but throw on getFile(); fall through.
log.debug("Resource fast path failed, falling back to stream copy", ex);
}
}
try (InputStream in = file.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename());
log.debug("Stored file with ID: {}", stored.fileId());
return stored.fileId();
}
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Transfer the file to the storage location
file.transferTo(filePath.toFile());
log.debug("Stored file with ID: {}", fileId);
return fileId;
}
/**
* Store a byte array as a file and return its unique ID
*
* @param bytes The byte array to store
* @param originalName The original name of the file (for extension)
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeBytes(byte[] bytes, String originalName) throws IOException {
FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName);
log.debug("Stored byte array with ID: {}", stored.fileId());
return stored.fileId();
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Write the bytes to the file
Files.write(filePath, bytes);
log.debug("Stored byte array with ID: {}", fileId);
return fileId;
}
/**
* Retrieve a file by its ID as a MultipartFile
*
* @param fileId The ID of the file to retrieve
* @return The file as a MultipartFile
* @throws IOException If the file doesn't exist or can't be read
*/
public MultipartFile retrieveFile(String fileId) throws IOException {
byte[] fileData = fileStore.retrieveBytes(fileId);
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
byte[] fileData = Files.readAllBytes(filePath);
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
}
/**
* Retrieve a file by its ID as a byte array
*
* @param fileId The ID of the file to retrieve
* @return The file as a byte array
* @throws IOException If the file doesn't exist or can't be read
*/
public byte[] retrieveBytes(String fileId) throws IOException {
return fileStore.retrieveBytes(fileId);
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.readAllBytes(filePath);
}
/**
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
* the returned stream.
*
* @param fileId The ID of the file to retrieve
* @return A buffered InputStream for the file
* @throws IOException If the file doesn't exist or can't be read
*/
public InputStream retrieveInputStream(String fileId) throws IOException {
return fileStore.retrieve(fileId);
Path filePath = getFilePath(fileId);
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
// between exists-check and open when another thread may delete concurrently.
return new BufferedInputStream(Files.newInputStream(filePath));
}
/**
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
* directly to disk without buffering the entire content in heap.
*
* @param inputStream The input stream to read from
* @param originalName The original name of the file (unused, kept for API symmetry)
* @return A {@link StoredFile} containing the file ID and the number of bytes written
* @throws IOException If there is an error storing the file
*/
public StoredFile storeInputStream(InputStream inputStream, String originalName)
throws IOException {
FileStore.Stored stored = fileStore.store(inputStream, originalName);
log.debug("Stored input stream with ID: {}", stored.fileId());
return new StoredFile(stored.fileId(), stored.size());
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.copy(inputStream, filePath);
log.debug("Stored input stream with ID: {}", fileId);
return new StoredFile(fileId, size);
}
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
throws IOException {
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
// from the body writer would otherwise close the pipe with EOF and the consumer would
// return a truncated file with no error surfaced to the caller.
AtomicReference<Throwable> bodyError = new AtomicReference<>();
try (PipedOutputStream out = new PipedOutputStream();
PipedInputStream in = new PipedInputStream(out, 8192)) {
var executor = Executors.newSingleThreadExecutor(Thread.ofVirtual().factory());
java.util.concurrent.Future<?> task = null;
try {
task =
executor.submit(
() -> {
try {
body.writeTo(out);
} catch (Throwable ex) {
bodyError.set(ex);
} finally {
try {
out.close();
} catch (IOException ignored) {
// closed on the consumer side too
}
}
});
FileStore.Stored stored = fileStore.store(in, originalName);
Throwable writerErr = bodyError.get();
if (writerErr != null) {
// Body failed mid-write: the FileStore persisted a truncated entry.
// Best-effort delete so we don't leak partial files; never let cleanup
// mask the original writer error.
try {
fileStore.delete(stored.fileId());
} catch (RuntimeException cleanupEx) {
log.warn(
"Failed to delete partial file {} after writer error: {}",
stored.fileId(),
cleanupEx.getMessage());
}
if (writerErr instanceof IOException ioe) {
throw ioe;
}
throw new IOException(
"StreamingResponseBody writer failed: " + writerErr.getMessage(),
writerErr);
}
log.debug("Stored StreamingResponseBody with ID: {}", stored.fileId());
return stored.fileId();
} finally {
// Interrupt and join the writer task: shutdown() alone returns immediately and a
// failed store leaves the writer running, leaking a thread per failed upload.
if (task != null && !task.isDone()) {
task.cancel(true);
}
executor.shutdown();
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
boolean success = false;
try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(filePath))) {
body.writeTo(os);
success = true;
} finally {
if (!success) {
try {
if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException ie) {
executor.shutdownNow();
Thread.currentThread().interrupt();
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
log.debug("Stored StreamingResponseBody with ID: {}", fileId);
return fileId;
}
/**
* Persist a {@link Resource} body to disk, returning the generated file ID. Used by the async
* job pipeline to capture {@code ResponseEntity<Resource>} results produced by controllers.
*/
public String storeFromResource(Resource resource, String originalName) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
boolean success = false;
try (InputStream in = resource.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, originalName);
log.debug("Stored Resource with ID: {}", stored.fileId());
return stored.fileId();
Files.copy(in, filePath);
success = true;
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
log.debug("Stored Resource with ID: {}", fileId);
return fileId;
}
/**
* Delete a file by its ID
*
* @param fileId The ID of the file to delete
* @return true if the file was deleted, false otherwise
*/
public boolean deleteFile(String fileId) {
try {
Path filePath = getFilePath(fileId);
return Files.deleteIfExists(filePath);
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
public boolean deleteFile(String fileId) {
return fileStore.delete(fileId);
}
/**
* Check if a file exists by its ID
*
* @param fileId The ID of the file to check
* @return true if the file exists, false otherwise
*/
public boolean fileExists(String fileId) {
return fileStore.exists(fileId);
Path filePath = getFilePath(fileId);
return Files.exists(filePath);
}
/**
* Get the size of a file by its ID without loading the content into memory
*
* @param fileId The ID of the file
* @return The size of the file in bytes
* @throws IOException If the file doesn't exist or can't be read
*/
public long getFileSize(String fileId) throws IOException {
return fileStore.size(fileId);
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
/**
* Get the path for a file ID
*
* @param fileId The ID of the file
* @return The path to the file
* @throws IllegalArgumentException if fileId contains path traversal characters or resolves
* outside base directory
*/
private Path getFilePath(String fileId) {
// Validate fileId to prevent path traversal
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
// Ensure resolved path is within the base directory
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
}
/**
* Generate a unique file ID
*
* @return A unique file ID
*/
private String generateFileId() {
return UUID.randomUUID().toString();
}
}
@@ -3,13 +3,9 @@ package stirling.software.common.service;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -20,7 +16,6 @@ import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
@@ -31,10 +26,6 @@ import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.JobStoreEntry.JobState;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
@@ -49,20 +40,20 @@ public class TaskManager {
private int jobResultExpiryMinutes = 30;
private final FileStorage fileStorage;
private final JobStore jobStore;
private final ClusterBackplane clusterBackplane;
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("task-cleanup-", 0).factory());
@Autowired
public TaskManager(
FileStorage fileStorage, JobStore jobStore, ClusterBackplane clusterBackplane) {
/** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) {
this.fileStorage = fileStorage;
this.jobStore = jobStore;
this.clusterBackplane = clusterBackplane;
cleanupExecutor.scheduleAtFixedRate(this::cleanupOldJobs, 10, 10, TimeUnit.MINUTES);
// Schedule periodic cleanup of old job results
cleanupExecutor.scheduleAtFixedRate(
this::cleanupOldJobs,
10, // Initial delay
10, // Interval
TimeUnit.MINUTES);
log.debug(
"Task manager initialized with job result expiry of {} minutes",
@@ -75,9 +66,7 @@ public class TaskManager {
* @param jobId The job ID
*/
public void createTask(String jobId) {
JobResult result = JobResult.createNew(jobId);
jobResults.put(jobId, result);
writeThrough(jobId, result);
jobResults.put(jobId, JobResult.createNew(jobId));
log.debug("Created task with job ID: {}", jobId);
}
@@ -90,7 +79,6 @@ public class TaskManager {
public void setResult(String jobId, Object result) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithResult(result);
writeThrough(jobId, jobResult);
log.debug("Set result for job ID: {}", jobId);
}
@@ -113,7 +101,6 @@ public class TaskManager {
extractZipToIndividualFiles(fileId, originalFileName);
if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles);
writeThrough(jobId, jobResult);
log.debug(
"Set multiple file results for job ID: {} with {} files extracted from"
+ " ZIP",
@@ -140,7 +127,6 @@ public class TaskManager {
"Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage());
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0);
}
writeThrough(jobId, jobResult);
}
/**
@@ -152,7 +138,6 @@ public class TaskManager {
public void setMultipleFileResults(String jobId, List<ResultFile> resultFiles) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFiles(resultFiles);
writeThrough(jobId, jobResult);
log.debug(
"Set multiple file results for job ID: {} with {} files",
jobId,
@@ -168,7 +153,6 @@ public class TaskManager {
public void setError(String jobId, String error) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.failWithError(error);
writeThrough(jobId, jobResult);
log.debug("Set error for job ID: {}: {}", jobId, error);
}
@@ -185,7 +169,6 @@ public class TaskManager {
// If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully");
}
writeThrough(jobId, jobResult);
log.debug("Marked job ID: {} as complete", jobId);
}
@@ -222,7 +205,6 @@ public class TaskManager {
JobResult jobResult = jobResults.get(jobId);
if (jobResult != null) {
jobResult.addNote(note);
writeThrough(jobId, jobResult);
log.debug("Added note to job ID: {}: {}", jobId, note);
return true;
}
@@ -313,11 +295,8 @@ public class TaskManager {
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
}
/** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */
/** Clean up old completed job results */
public void cleanupOldJobs() {
if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) {
return;
}
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
@@ -336,9 +315,6 @@ public class TaskManager {
// Remove the job result
jobResults.remove(entry.getKey());
if (jobStore != null) {
jobStore.delete(entry.getKey());
}
removedCount++;
}
}
@@ -351,53 +327,6 @@ public class TaskManager {
}
}
/** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */
private void writeThrough(String jobId, JobResult result) {
if (jobStore == null) {
return;
}
try {
jobStore.put(toEntry(jobId, result), Duration.ofMinutes(jobResultExpiryMinutes));
} catch (RuntimeException ex) {
log.warn("JobStore write-through failed for job {}: {}", jobId, ex.getMessage());
}
}
private JobStoreEntry toEntry(String jobId, JobResult result) {
JobState state;
if (result.isComplete()) {
state = result.getError() != null ? JobState.FAILED : JobState.COMPLETE;
} else {
state = JobState.PENDING;
}
Instant createdAt = toInstant(result.getCreatedAt());
Instant completedAt = toInstant(result.getCompletedAt());
List<String> fileIds = new ArrayList<>();
if (result.hasFiles()) {
for (ResultFile rf : result.getAllResultFiles()) {
fileIds.add(rf.getFileId());
}
}
Map<String, String> meta = new HashMap<>();
if (result.getNotes() != null && !result.getNotes().isEmpty()) {
meta.put("notesCount", Integer.toString(result.getNotes().size()));
}
String owningNodeId = clusterBackplane == null ? "local" : clusterBackplane.localNodeId();
return new JobStoreEntry(
jobId,
state,
owningNodeId,
createdAt,
completedAt,
result.getError(),
fileIds,
meta);
}
private Instant toInstant(LocalDateTime ldt) {
return ldt == null ? null : ldt.atZone(ZoneId.systemDefault()).toInstant();
}
/** Shutdown the cleanup executor */
@PreDestroy
public void shutdown() {
@@ -441,7 +370,7 @@ public class TaskManager {
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
String contentType = determineContentType(entry.getName());
// storeInputStream returns the fileId and byte count - no extra stat needed
// storeInputStream returns the fileId and byte count — no extra stat needed
FileStorage.StoredFile stored =
fileStorage.storeInputStream(zipIn, entry.getName());
@@ -529,8 +458,7 @@ public class TaskManager {
}
/**
* Find the job key that owns a given file ID. Checks the local in-memory map first, then falls
* back to the cluster-visible {@link JobStore}.
* Find the job key that owns a given file ID.
*
* @param fileId file identifier to look up
* @return scoped job key if found, otherwise null
@@ -546,18 +474,6 @@ public class TaskManager {
}
}
}
if (jobStore != null) {
// Propagate JobStore failures: returning null on a backplane outage would conflate
// "no such file" with "lookup unavailable" and the caller would respond 404 to a
// transient blip that should be retried. Let Spring's exception handler surface a
// 5xx so clients know to retry.
try {
return jobStore.findJobIdByFileId(fileId).orElse(null);
} catch (RuntimeException e) {
log.warn("JobStore findJobIdByFileId failed for {}: {}", fileId, e.getMessage());
throw e;
}
}
return null;
}
}
@@ -83,16 +83,7 @@ public class RequestUriUtils {
return false;
}
// 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.
// Blocklist of backend/non-frontend paths that should still go through filters
String[] backendOnlyPrefixes = {
"/register",
"/pipeline",
@@ -100,6 +91,7 @@ public class RequestUriUtils {
"/pdfjs-legacy",
"/fonts",
"/images",
"/files",
"/css",
"/js",
"/swagger",
@@ -189,7 +181,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/[^/]+/?$");
@@ -56,17 +56,4 @@ class ArchitectureTest {
.resideInAPackage("stirling.software.saas..");
rule.check(commonClasses);
}
@Test
void clusterInterfacesHaveNoImplementationDependencies() {
ArchRule rule =
noClasses()
.that()
.resideInAPackage("stirling.software.common.cluster..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"stirling.software.proprietary..", "stirling.software.saas..");
rule.check(commonClasses);
}
}
@@ -1,51 +0,0 @@
package stirling.software.common.cluster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class BackplaneContractCompilationTest {
@Test
void jobStoreEntryRecordRoundTrips() {
Instant now = Instant.now();
JobStoreEntry entry =
new JobStoreEntry(
"job-1",
JobStoreEntry.JobState.PENDING,
"node-a",
now,
null,
null,
List.of("file-1"),
Map.of("k", "v"));
assertEquals("job-1", entry.jobId());
assertEquals(JobStoreEntry.JobState.PENDING, entry.state());
assertEquals("node-a", entry.owningNodeId());
assertEquals(now, entry.createdAt());
assertEquals(List.of("file-1"), entry.fileIds());
assertEquals("v", entry.resultMeta().get("k"));
}
@Test
void clusterNodeRecordRoundTrips() {
Instant heartbeat = Instant.now();
ClusterNode node = new ClusterNode("node-a", "10.0.0.1:8080", heartbeat, "BOTH");
assertEquals("node-a", node.nodeId());
assertEquals("10.0.0.1:8080", node.internalAddress());
assertEquals(heartbeat, node.lastHeartbeat());
assertEquals("BOTH", node.role());
}
@Test
void rateLimitDecisionRecordRoundTrips() {
RateLimitStore.RateLimitDecision d = new RateLimitStore.RateLimitDecision(true, 7, 0L);
assertEquals(true, d.allowed());
assertEquals(7, d.remainingTokens());
assertEquals(0L, d.nanosToWaitForRefill());
}
}
@@ -1,65 +0,0 @@
package stirling.software.common.cluster;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
class ClusterConfigValidationTest {
@Test
void validationPassesWhenDisabled() {
ApplicationProperties props = new ApplicationProperties();
ClusterConfig config = new ClusterConfig(props);
assertDoesNotThrow(() -> invokeValidate(config));
}
@Test
void validationFailsWhenValkeyEnabledWithoutUrl() {
ApplicationProperties props = new ApplicationProperties();
Cluster cluster = props.getCluster();
cluster.setEnabled(true);
cluster.setBackplane("valkey");
ClusterConfig config = new ClusterConfig(props);
assertThrows(IllegalStateException.class, () -> invokeValidate(config));
}
@Test
void validationPassesWhenValkeyEnabledWithUrl() {
ApplicationProperties props = new ApplicationProperties();
Cluster cluster = props.getCluster();
cluster.setEnabled(true);
cluster.setBackplane("valkey");
cluster.getValkey().setUrl("redis://localhost:6379");
ClusterConfig config = new ClusterConfig(props);
assertDoesNotThrow(() -> invokeValidate(config));
}
@Test
void validationPassesWhenInProcessEnabled() {
ApplicationProperties props = new ApplicationProperties();
Cluster cluster = props.getCluster();
cluster.setEnabled(true);
cluster.setBackplane("inprocess");
ClusterConfig config = new ClusterConfig(props);
assertDoesNotThrow(() -> invokeValidate(config));
}
private void invokeValidate(ClusterConfig config) throws Exception {
Method m = ClusterConfig.class.getDeclaredMethod("validate");
m.setAccessible(true);
try {
m.invoke(config);
} catch (java.lang.reflect.InvocationTargetException ex) {
if (ex.getCause() instanceof RuntimeException re) {
throw re;
}
throw ex;
}
}
}
@@ -1,62 +0,0 @@
package stirling.software.common.cluster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
class ClusterPropertiesTest {
@Test
void defaultsAreDisabledAndInprocess() {
Cluster props = new ApplicationProperties().getCluster();
assertFalse(props.isEnabled());
assertEquals("inprocess", props.getBackplane());
assertEquals("local", props.getArtifactStore());
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
assertEquals("", props.getValkey().getUrl());
assertFalse(props.getValkey().getTls().isSkipCertVerification());
assertEquals("both", props.getNode().getRole());
assertEquals("http", props.getNode().getScheme());
assertEquals(5000L, props.getNode().getHeartbeatIntervalMs());
}
@Test
void resolvedRoleParsesCaseInsensitively() {
Cluster props = new ApplicationProperties().getCluster();
props.getNode().setRole("WEB");
assertEquals(Cluster.NodeRole.WEB, props.resolvedRole());
props.getNode().setRole("web");
assertEquals(Cluster.NodeRole.WEB, props.resolvedRole());
props.getNode().setRole("Worker");
assertEquals(Cluster.NodeRole.WORKER, props.resolvedRole());
props.getNode().setRole("garbage");
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
props.getNode().setRole(null);
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
}
@Test
void resolvedNodeIdIsStableAcrossCalls() {
Cluster props = new ApplicationProperties().getCluster();
String first = props.resolvedNodeId();
String second = props.resolvedNodeId();
assertNotNull(first);
assertEquals(first, second);
}
@Test
void resolvedNodeIdHonoursExplicitId() {
Cluster props = new ApplicationProperties().getCluster();
props.getNode().setId("abc");
assertEquals("abc", props.resolvedNodeId());
}
}
@@ -1,90 +0,0 @@
package stirling.software.common.cluster;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import stirling.software.common.cluster.inprocess.InProcessClusterConfiguration;
import stirling.software.common.model.ApplicationProperties;
/**
* Verifies the {@link InProcessClusterConfiguration} conditional wiring: in-process beans wire when
* cluster mode is off or {@code backplane=inprocess}, and are skipped when {@code
* backplane=valkey}.
*/
class InProcessConfigurationConditionalTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(
org.springframework.boot.autoconfigure.AutoConfigurations.of(
PropertyPlaceholderAutoConfiguration.class))
.withUserConfiguration(
TestAppPropertiesConfig.class,
ClusterConfig.class,
InProcessClusterConfiguration.class);
@Test
void inProcessBeansWireWhenClusterDisabled() {
runner.run(
context ->
assertThat(context)
.hasNotFailed()
.hasSingleBean(ClusterBackplane.class)
.hasSingleBean(JobStore.class)
.hasSingleBean(RateLimitStore.class)
.hasSingleBean(DistributedLock.class)
.hasSingleBean(KeyValueCache.class)
.hasSingleBean(InstanceRegistry.class));
}
@Test
void inProcessBeansWireWhenEnabledWithInProcessBackplane() {
runner.withPropertyValues("cluster.enabled=true", "cluster.backplane=inprocess")
.run(
context ->
assertThat(context)
.hasNotFailed()
.hasSingleBean(ClusterBackplane.class)
.hasSingleBean(JobStore.class)
.hasSingleBean(RateLimitStore.class)
.hasSingleBean(DistributedLock.class)
.hasSingleBean(KeyValueCache.class)
.hasSingleBean(InstanceRegistry.class));
}
@Test
void inProcessBeansSkippedWhenEnabledWithDistributedBackplane() {
runner.withPropertyValues(
"cluster.enabled=true",
"cluster.backplane=valkey",
"cluster.valkey.url=redis://localhost:6379")
.run(
context ->
assertThat(context)
.hasNotFailed()
.doesNotHaveBean(ClusterBackplane.class)
.doesNotHaveBean(JobStore.class)
.doesNotHaveBean(RateLimitStore.class)
.doesNotHaveBean(DistributedLock.class)
.doesNotHaveBean(KeyValueCache.class)
.doesNotHaveBean(InstanceRegistry.class));
}
/**
* Hand-rolled {@link ApplicationProperties} bean: the production class loads YAML at startup
* via a {@code @PostConstruct} hook that isn't appropriate for the slice runner, so we wire a
* defaults-only instance here.
*/
@Configuration
static class TestAppPropertiesConfig {
@Bean
ApplicationProperties applicationProperties() {
return new ApplicationProperties();
}
}
}
@@ -1,167 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.DistributedLock;
class InProcessDistributedLockTest {
@Test
void acquireReleaseAcquire() {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
h1.release();
assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
}
@Test
void reentryFromSameThreadFails() {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
Optional<DistributedLock.LockHandle> reentry = lock.tryAcquire("k", Duration.ofSeconds(30));
assertFalse(reentry.isPresent(), "in-process lock must be non-reentrant");
h1.release();
// After release, anyone can acquire again.
assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
}
@Test
void secondAcquireFromAnotherThreadFails() throws InterruptedException {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
CountDownLatch done = new CountDownLatch(1);
AtomicBoolean acquired = new AtomicBoolean(true);
Thread t =
new Thread(
() -> {
Optional<DistributedLock.LockHandle> attempt =
lock.tryAcquire("k", Duration.ofSeconds(30));
acquired.set(attempt.isPresent());
attempt.ifPresent(DistributedLock.LockHandle::release);
done.countDown();
});
t.start();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertFalse(acquired.get());
h1.release();
}
@Test
void leaseExpiryAllowsTakeoverEvenWithoutRelease() throws InterruptedException {
// Acquire with a short lease, never call release, then try to acquire again after the
// lease has elapsed. Matches Redis SET-NX-EX semantics - the second caller gets the lock
// because the first lease auto-expired. 250ms lease + 350ms wait gives CI generous slack.
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(250)).orElseThrow();
Thread.sleep(350);
Optional<DistributedLock.LockHandle> takeover =
lock.tryAcquire("k", Duration.ofSeconds(30));
assertTrue(
takeover.isPresent(),
"expired lease must release the lock so a new caller can take over");
// Calling release() on the original handle after takeover must be a no-op (token check).
h1.release();
// The takeover holder is still the legitimate owner.
assertFalse(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
takeover.get().release();
}
@Test
void renewExtendsLease() throws InterruptedException {
// Acquire with a short lease, renew it before it expires, then verify the lock is still
// held past the original expiry point. 200ms initial + renew to 2s + wait 350ms.
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(200)).orElseThrow();
assertTrue(h1.renew(Duration.ofSeconds(2)), "renew on a held lease must succeed");
Thread.sleep(350);
assertFalse(
lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent(),
"renew should have pushed expiry well past the original 200ms");
h1.release();
}
@Test
void renewAfterReleaseFails() {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
h1.release();
assertFalse(h1.renew(Duration.ofSeconds(30)), "renew on a released handle must fail");
}
/**
* Concurrency stress: many threads contending on the same key with each holder respecting the
* lease (hold &lt;&lt; lease). The lock behaves as a strict mutex in this regime so asserting
* mutual exclusion is meaningful. A separate test ({@link
* #leaseExpiryAllowsTakeoverEvenWithoutRelease}) covers the takeover-across-expiry branch,
* which legitimately allows two holders momentarily and is split-brain behaviour inherent to
* any lease-based lock.
*/
@Test
void concurrentContentionPreservesMutualExclusion() throws InterruptedException {
DistributedLock lock = new InProcessDistributedLock();
int threads = 16;
int attemptsPerThread = 200;
// Lease far exceeds any plausible hold time, so the takeover branch never triggers in
// this test and the lock acts as a strict mutex.
Duration lease = Duration.ofSeconds(5);
java.util.concurrent.atomic.AtomicInteger concurrentHolders =
new java.util.concurrent.atomic.AtomicInteger();
java.util.concurrent.atomic.AtomicInteger maxConcurrent =
new java.util.concurrent.atomic.AtomicInteger();
java.util.concurrent.atomic.AtomicInteger acquires =
new java.util.concurrent.atomic.AtomicInteger();
java.util.concurrent.atomic.AtomicReference<Throwable> firstFailure =
new java.util.concurrent.atomic.AtomicReference<>();
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(
() -> {
try {
start.await();
for (int j = 0; j < attemptsPerThread; j++) {
Optional<DistributedLock.LockHandle> h =
lock.tryAcquire("hot", lease);
if (h.isPresent()) {
int now = concurrentHolders.incrementAndGet();
maxConcurrent.accumulateAndGet(now, Math::max);
acquires.incrementAndGet();
// Trivial critical section; well within lease.
concurrentHolders.decrementAndGet();
h.get().release();
}
}
} catch (Throwable t) {
firstFailure.compareAndSet(null, t);
} finally {
done.countDown();
}
},
"lock-stress-" + i)
.start();
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS), "stress workers must finish in time");
org.junit.jupiter.api.Assertions.assertNull(firstFailure.get(), "no worker may throw");
org.junit.jupiter.api.Assertions.assertEquals(
1,
maxConcurrent.get(),
"mutual exclusion violated: more than one holder observed simultaneously");
assertTrue(
acquires.get() > 0,
"at least some acquires must succeed under contention (saw "
+ acquires.get()
+ ")");
}
}
@@ -1,28 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.ClusterNode;
class InProcessInstanceRegistryTest {
@Test
void registerThenLookupAndActiveNodes() {
InProcessInstanceRegistry registry = new InProcessInstanceRegistry();
ClusterNode node = new ClusterNode("node-1", "127.0.0.1:8080", Instant.now(), "BOTH");
registry.register(node, Duration.ofSeconds(30));
assertTrue(registry.lookup("node-1").isPresent());
assertEquals("node-1", registry.lookup("node-1").get().nodeId());
assertEquals(1, registry.activeNodes().size());
registry.deregister("node-1");
assertTrue(registry.lookup("node-1").isEmpty());
}
}
@@ -1,91 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.JobStoreEntry;
class InProcessJobStoreTest {
private final InProcessJobStore store = new InProcessJobStore();
@Test
void putGetDeleteExistsRoundTrip() {
JobStoreEntry entry = entry("job-1");
store.put(entry, Duration.ofMinutes(30));
assertTrue(store.exists("job-1"));
assertEquals(entry, store.get("job-1").orElseThrow());
store.delete("job-1");
assertFalse(store.exists("job-1"));
}
@Test
void ttlExpiry() throws InterruptedException {
store.put(entry("job-2"), Duration.ofMillis(50));
Thread.sleep(100);
assertFalse(store.get("job-2").isPresent());
}
@Test
void purgeExpiredRemovesOnlyStaleEntries() throws InterruptedException {
store.put(entry("job-fresh"), Duration.ofMinutes(30));
store.put(entry("job-stale"), Duration.ofMillis(20));
Thread.sleep(80);
int removed = store.purgeExpired();
assertEquals(1, removed);
assertTrue(store.exists("job-fresh"));
}
@Test
void findJobIdByFileIdReturnsTheRightJob() {
store.put(
new JobStoreEntry(
"job-a",
JobStoreEntry.JobState.COMPLETE,
"node-1",
Instant.now(),
Instant.now(),
null,
List.of("file-1", "file-2"),
Map.of()),
Duration.ofMinutes(30));
store.put(
new JobStoreEntry(
"job-b",
JobStoreEntry.JobState.COMPLETE,
"node-1",
Instant.now(),
Instant.now(),
null,
List.of("file-3"),
Map.of()),
Duration.ofMinutes(30));
assertEquals("job-a", store.findJobIdByFileId("file-1").orElseThrow());
assertEquals("job-b", store.findJobIdByFileId("file-3").orElseThrow());
assertFalse(store.findJobIdByFileId("missing").isPresent());
}
private JobStoreEntry entry(String id) {
return new JobStoreEntry(
id,
JobStoreEntry.JobState.PENDING,
"node-1",
Instant.now(),
null,
null,
List.of(),
Map.of());
}
}
@@ -1,41 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.KeyValueCache;
class InProcessKeyValueCacheTest {
@Test
void putGetEvict() {
KeyValueCache cache = new InProcessKeyValueCache();
cache.put("apikey", "a", "userA", Duration.ofMinutes(1));
assertEquals("userA", cache.get("apikey", "a").orElseThrow());
cache.evict("apikey", "a");
assertFalse(cache.get("apikey", "a").isPresent());
}
@Test
void ttlExpiry() throws InterruptedException {
KeyValueCache cache = new InProcessKeyValueCache();
cache.put("ns", "k", "v", Duration.ofMillis(40));
Thread.sleep(80);
assertFalse(cache.get("ns", "k").isPresent());
}
@Test
void evictNamespace() {
KeyValueCache cache = new InProcessKeyValueCache();
cache.put("ns", "a", "1", Duration.ofMinutes(1));
cache.put("ns", "b", "2", Duration.ofMinutes(1));
cache.evictNamespace("ns");
assertFalse(cache.get("ns", "a").isPresent());
assertFalse(cache.get("ns", "b").isPresent());
}
}
@@ -1,56 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.RateLimitStore;
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
class InProcessRateLimitStoreTest {
@Test
void firstNConsumesAllowed() {
RateLimitStore store = new InProcessRateLimitStore();
for (int i = 0; i < 5; i++) {
assertTrue(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed(), "i=" + i);
}
assertFalse(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed());
}
@Test
void remainingTokensDecrements() {
RateLimitStore store = new InProcessRateLimitStore();
RateLimitDecision d1 = store.tryConsume("k", 5, Duration.ofSeconds(60));
RateLimitDecision d2 = store.tryConsume("k", 5, Duration.ofSeconds(60));
assertTrue(d1.allowed());
assertTrue(d2.allowed());
assertEquals(4, d1.remainingTokens());
assertEquals(3, d2.remainingTokens());
}
@Test
void refillRestoresTokens() throws InterruptedException {
RateLimitStore store = new InProcessRateLimitStore();
// Capacity 2 with smooth refill over 100 ms -> ~1 token per 50 ms.
for (int i = 0; i < 2; i++) {
assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
}
assertFalse(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
Thread.sleep(150);
assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
}
@Test
void deniedConsumeReportsWaitNanos() {
RateLimitStore store = new InProcessRateLimitStore();
assertTrue(store.tryConsume("wait", 1, Duration.ofSeconds(10)).allowed());
RateLimitDecision denied = store.tryConsume("wait", 1, Duration.ofSeconds(10));
assertFalse(denied.allowed());
assertTrue(denied.nanosToWaitForRefill() > 0L);
}
}
@@ -1,42 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.cluster.FileStore;
class LocalDiskFileStoreTest {
@Test
void storeRetrieveSizeDeleteExistsRoundTrip(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
byte[] payload = "hello-bytes".getBytes();
FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "x.txt");
assertEquals(payload.length, stored.size());
assertTrue(store.exists(stored.fileId()));
assertEquals(payload.length, store.size(stored.fileId()));
assertArrayEquals(payload, store.retrieveBytes(stored.fileId()));
assertTrue(store.delete(stored.fileId()));
assertFalse(store.exists(stored.fileId()));
}
@Test
void traversalIdsAreRejected(@TempDir Path dir) {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
assertThrows(IllegalArgumentException.class, () -> store.resolve("../foo"));
assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b"));
assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b"));
}
}
@@ -1,27 +0,0 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.mockito.Mockito.mock;
import java.io.IOException;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
class FileStorageDelegationTest {
@Test
void storeBytesThenRetrieveBytesRoundTripsThroughFileStore(@TempDir Path tempDir)
throws IOException {
FileStorage fs =
new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()));
byte[] payload = "round-trip".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
}
@@ -3,7 +3,6 @@ package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -14,30 +13,29 @@ import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
class FileStorageTest {
@TempDir Path tempDir;
@Mock private FileOrUploadService fileOrUploadService;
private FileStorage fileStorage;
@InjectMocks private FileStorage fileStorage;
private MultipartFile mockFile;
@BeforeEach
void setUp() throws IOException {
void setUp() {
MockitoAnnotations.openMocks(this);
fileStorage =
new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString()));
ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString());
// Create a mock MultipartFile
mockFile = mock(MultipartFile.class);
@@ -49,7 +47,17 @@ class FileStorageTest {
void testStoreFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
when(mockFile.getInputStream()).thenReturn(new ByteArrayInputStream(fileContent));
when(mockFile.getBytes()).thenReturn(fileContent);
// Set up mock to handle transferTo by writing the file
doAnswer(
invocation -> {
java.io.File file = invocation.getArgument(0);
Files.write(file.toPath(), fileContent);
return null;
})
.when(mockFile)
.transferTo(any(java.io.File.class));
// Act
String fileId = fileStorage.storeFile(mockFile);
@@ -57,7 +65,7 @@ class FileStorageTest {
// Assert
assertNotNull(fileId);
assertTrue(Files.exists(tempDir.resolve(fileId)));
assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId)));
verify(mockFile).transferTo(any(java.io.File.class));
}
@Test
@@ -239,11 +247,11 @@ class FileStorageTest {
filesBefore = s.count();
}
// Act + Assert: IOException must propagate out - not be swallowed.
// Act + Assert: IOException must propagate out — not be swallowed.
assertThrows(
IOException.class, () -> fileStorage.storeFromResource(flakyResource, "n.pdf"));
// Assert: no partial file lingers under the storage directory - the finally
// Assert: no partial file lingers under the storage directory — the finally
// branch's deleteIfExists must have cleaned it up.
long filesAfter;
try (Stream<Path> s = Files.list(tempDir)) {
@@ -1,120 +0,0 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.JobStoreEntry.JobState;
import stirling.software.common.cluster.inprocess.InProcessClusterBackplane;
import stirling.software.common.cluster.inprocess.InProcessJobStore;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResult;
class TaskManagerJobStoreDelegationTest {
@Mock private FileStorage fileStorage;
private InProcessJobStore jobStore;
private ClusterBackplane backplane;
private TaskManager taskManager;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
jobStore = spy(new InProcessJobStore());
backplane = new InProcessClusterBackplane(new ApplicationProperties());
taskManager = new TaskManager(fileStorage, jobStore, backplane);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
}
@Test
void createTaskWritesPendingEntry() {
taskManager.createTask("job-1");
JobStoreEntry entry = jobStore.get("job-1").orElseThrow();
assertEquals(JobState.PENDING, entry.state());
assertEquals(backplane.localNodeId(), entry.owningNodeId());
}
@Test
void setCompleteFlipsToComplete() {
taskManager.createTask("job-2");
taskManager.setResult("job-2", "ok");
taskManager.setComplete("job-2");
JobStoreEntry entry = jobStore.get("job-2").orElseThrow();
assertEquals(JobState.COMPLETE, entry.state());
}
@Test
void setErrorFlipsToFailed() {
taskManager.createTask("job-3");
taskManager.setError("job-3", "boom");
JobStoreEntry entry = jobStore.get("job-3").orElseThrow();
assertEquals(JobState.FAILED, entry.state());
assertEquals("boom", entry.error());
}
@Test
void cleanupOldJobsIsNoopWhenBackplaneIsNotInProcess() {
ClusterBackplane mockedValkeyBackplane =
new ClusterBackplane() {
@Override
public boolean isHealthy() {
return true;
}
@Override
public String backplaneType() {
return "valkey";
}
@Override
public String localNodeId() {
return "node-1";
}
@Override
public boolean shouldRunLocalCleanup() {
return false;
}
};
TaskManager tm = new TaskManager(fileStorage, jobStore, mockedValkeyBackplane);
ReflectionTestUtils.setField(tm, "jobResultExpiryMinutes", 30);
tm.createTask("job-4");
tm.setComplete("job-4");
ageJobPastExpiry(tm, "job-4");
tm.cleanupOldJobs();
// cleanup must short-circuit before touching jobStore in cluster mode; the backplane
// TTL owns expiry there. If the gate fired correctly, delete is never called.
verify(jobStore, never()).delete(any());
}
@Test
void cleanupOldJobsDeletesFromJobStoreWhenBackplaneIsInProcess() {
taskManager.createTask("job-5");
taskManager.setComplete("job-5");
ageJobPastExpiry(taskManager, "job-5");
taskManager.cleanupOldJobs();
verify(jobStore).delete("job-5");
}
@SuppressWarnings("unchecked")
private static void ageJobPastExpiry(TaskManager tm, String jobId) {
var jobResults =
(java.util.Map<String, JobResult>) ReflectionTestUtils.getField(tm, "jobResults");
JobResult result = jobResults.get(jobId);
ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(2));
ReflectionTestUtils.setField(result, "complete", true);
}
}
@@ -1,28 +1,20 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.JobStoreEntry.JobState;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
@@ -30,8 +22,6 @@ import stirling.software.common.model.job.ResultFile;
class TaskManagerTest {
@Mock private FileStorage fileStorage;
@Mock private JobStore jobStore;
@Mock private ClusterBackplane clusterBackplane;
@InjectMocks private TaskManager taskManager;
@@ -40,10 +30,6 @@ class TaskManagerTest {
@BeforeEach
void setUp() {
closeable = MockitoAnnotations.openMocks(this);
// Treat the backplane as in-process so cleanupOldJobs is not short-circuited.
lenient().when(clusterBackplane.backplaneType()).thenReturn("inprocess");
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
}
@@ -284,33 +270,6 @@ class TaskManagerTest {
verify(fileStorage).deleteFile("file-id");
}
@Test
void testCleanupOldJobs_NoOpWhenBackplaneOwnsExpiry() {
// When the backplane reports it should NOT run local cleanup (e.g. a distributed
// backplane with its own TTL), the cleanup loop must leave local state untouched.
when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false);
// Seed an old completed job that would normally be removed.
String oldJobId = "old-job-distributed";
taskManager.createTask(oldJobId);
JobResult oldJob = taskManager.getJobResult(oldJobId);
ReflectionTestUtils.setField(oldJob, "completedAt", LocalDateTime.now().minusHours(1));
ReflectionTestUtils.setField(oldJob, "complete", true);
Map<String, JobResult> jobResultsMap =
(Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
assertNotNull(jobResultsMap);
assertTrue(jobResultsMap.containsKey(oldJobId));
// Act
taskManager.cleanupOldJobs();
// Assert: nothing was removed locally, and no jobStore.delete was issued.
assertTrue(jobResultsMap.containsKey(oldJobId));
verify(jobStore, never()).delete(anyString());
verify(fileStorage, never()).deleteFile(anyString());
}
@Test
void testShutdown() {
// This mainly tests that the shutdown method doesn't throw exceptions
@@ -351,33 +310,4 @@ class TaskManagerTest {
// Assert
assertFalse(result);
}
@Test
void testWriteThroughOnUpdate() {
// Mutating calls must write through to the injected JobStore.
String jobId = "write-through-job";
taskManager.createTask(jobId);
taskManager.setResult(jobId, "done");
ArgumentCaptor<JobStoreEntry> captor = ArgumentCaptor.forClass(JobStoreEntry.class);
verify(jobStore, atLeast(2)).put(captor.capture(), any());
JobStoreEntry last = captor.getValue();
assertEquals(jobId, last.jobId());
assertEquals(JobState.COMPLETE, last.state());
assertEquals("test-node", last.owningNodeId());
}
@Test
void testFindJobKeyByFileId_FallsBackToJobStore() {
// When the file id is not in the local map, TaskManager delegates to JobStore.
String fileId = "remote-file-id";
String expectedJobKey = "remote-job-key";
when(jobStore.findJobIdByFileId(fileId)).thenReturn(Optional.of(expectedJobKey));
String actual = taskManager.findJobKeyByFileId(fileId);
assertEquals(expectedJobKey, actual);
verify(jobStore).findJobIdByFileId(fileId);
}
}
@@ -98,17 +98,6 @@ 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"));
@@ -194,7 +183,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/", ""));
}
@@ -208,7 +197,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,8 +40,7 @@ public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private static PDRectangle getTargetSize(
String targetPDRectangle, String orientation, PDDocument sourceDocument) {
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
if ("KEEP".equals(targetPDRectangle)) {
if (sourceDocument.getNumberOfPages() == 0) {
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
@@ -58,19 +57,18 @@ public class ScalePagesController {
}
Map<String, PDRectangle> sizeMap = getSizeMap();
PDRectangle base = sizeMap.get(targetPDRectangle);
if (base == null) {
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
if (sizeMap.containsKey(targetPDRectangle)) {
return sizeMap.get(targetPDRectangle);
}
if ("LANDSCAPE".equalsIgnoreCase(orientation)) {
return new PDRectangle(base.getHeight(), base.getWidth());
}
return base;
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
}
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);
@@ -78,8 +76,42 @@ 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;
}
@@ -96,14 +128,13 @@ 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, orientation, sourceDocument);
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
// Create LayerUtility once outside the loop for better performance
LayerUtility layerUtility = new LayerUtility(outputDocument);
@@ -12,8 +12,6 @@ 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;
@@ -93,44 +91,6 @@ 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
@@ -147,7 +107,7 @@ public class ConfigController {
}
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest request) {
public ResponseEntity<Map<String, Object>> getAppConfig() {
Map<String, Object> configData = new HashMap<>();
try {
@@ -164,7 +124,17 @@ public class ConfigController {
configData.put("serverPort", appConfig.getServerPort());
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
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 : "");
// Add mobile scanner settings
configData.put(
@@ -307,6 +277,9 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// AI Engine settings
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
applicationProperties.getSecurity().getTimestamp();
@@ -160,19 +160,14 @@ 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|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|files|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|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
"/{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:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@@ -22,7 +22,6 @@ 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;
@@ -197,12 +196,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();
@@ -1041,43 +1040,6 @@ 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,11 +18,4 @@ 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,7 +20,6 @@ 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
@@ -246,39 +245,6 @@ 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
@@ -364,24 +330,6 @@ aiEngine:
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
# Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults.
cluster:
enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here.
backplane: inprocess # Backplane implementation: 'inprocess' (single JVM only) or 'valkey' (multi-node via Valkey/Redis)
artifactStore: local # Transient cluster job-artifact backend: 'local' (per-node disk; single-node only) or 's3' (shared object store; required for multi-node). Distinct from 'storage.provider' which controls persistent user uploads - when both are 's3' they share the storage.s3.* credentials block. Multi-node deployments MUST set this to 's3'.
s3:
keyPrefix: transient/ # Bucket key prefix used by the cluster artifact store when artifactStore=s3. Trailing slash recommended. Lets a single bucket host both persistent uploads (storage.s3.*) and transient job artifacts under separate prefixes.
valkey:
url: "" # Valkey/Redis URL, e.g. 'redis://valkey:6379' or 'rediss://...' for TLS. Required when enabled=true and backplane=valkey.
tls:
skipCertVerification: false # set to 'true' to skip TLS certificate verification on Valkey connections (dev/test only)
node:
id: "" # Optional explicit node id. Blank = auto-generated UUID at startup.
role: both # 'web' (serves HTTP), 'worker' (runs jobs), or 'both' (default)
internalAddress: "" # host:port advertised in the instance registry for peer-to-peer cluster traffic. Blank = derived at startup.
scheme: http # 'http' or 'https' - scheme peers use to call this node's /internal/cluster/** endpoints
heartbeatIntervalMs: 5000 # Heartbeat publish interval for the instance registry (ms)
pdfEditor:
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
cache:
@@ -237,8 +237,7 @@ class ScalePagesControllerTest {
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A4");
request.setOrientation("LANDSCAPE");
request.setPageSize("A4_LANDSCAPE");
request.setScaleFactor(1.0f);
setupFactory();
@@ -15,14 +15,10 @@ 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;
@@ -177,71 +173,4 @@ 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,8 +123,6 @@ 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,8 +5,6 @@ repositories {
ext {
jwtVersion = '0.13.0'
awsSdkVersion = '2.44.12'
testcontainersMinioVersion = '1.21.4'
}
bootRun {
@@ -73,13 +71,6 @@ 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') {}
@@ -1,200 +0,0 @@
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;
}
}
}
@@ -1,226 +0,0 @@
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;
}
}
@@ -1,37 +0,0 @@
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,11 +49,7 @@ public class EEAppConfig {
@Profile("security & !saas")
@Bean(name = "SSOAutoLogin")
public boolean ssoAutoLogin() {
boolean enabled = applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
if (enabled) {
licenseKeyChecker.requireProOrEnterprise("premium.proFeatures.ssoAutoLogin=true");
}
return enabled;
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
// TODO: Remove post migration
@@ -32,10 +32,7 @@ public class LicenseKeyChecker {
private final UserLicenseSettingsService licenseSettingsService;
// 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;
private License premiumEnabledResult = License.NORMAL;
public LicenseKeyChecker(
KeygenLicenseVerifier licenseService,
@@ -136,16 +133,4 @@ 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,10 +1,6 @@
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;
@@ -12,8 +8,6 @@ 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;
@@ -45,37 +39,20 @@ 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 {
usernameAttributeKey =
OidcUser user = delegate.loadUser(userRequest);
String 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: {}",
registrationId,
ssoProvider,
ssoProviderId,
username);
@@ -102,154 +79,10 @@ 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;
}
}
@@ -1,95 +0,0 @@
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,11 +15,8 @@ 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;
@@ -30,9 +27,8 @@ public class StorageProviderConfig {
private final ApplicationProperties applicationProperties;
private final StoredFileBlobRepository storedFileBlobRepository;
private final LicenseKeyChecker licenseKeyChecker;
@Bean(destroyMethod = "close")
@Bean
public StorageProvider storageProvider() {
boolean storageEnabled = applicationProperties.getStorage().isEnabled();
String providerName =
@@ -41,13 +37,8 @@ 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);
}
@@ -80,9 +71,4 @@ 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());
}
}
@@ -1,91 +0,0 @@
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,11 +1,7 @@
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;
@@ -29,7 +25,6 @@ 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;
@@ -40,22 +35,17 @@ 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",
@@ -101,9 +91,7 @@ public class FileStorageController {
User user = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getAccessibleFile(user, fileId);
fileStorageService.requireReadAccess(user, file);
Optional<ResponseEntity<org.springframework.core.io.Resource>> redirect =
tryRedirectToSignedUrl(file, inline);
return redirect.orElseGet(() -> buildFileResponse(file, inline));
return buildFileResponse(file, inline);
}
@DeleteMapping("/files/{fileId}")
@@ -201,9 +189,7 @@ public class FileStorageController {
fileStorageService.requireReadAccess(share);
fileStorageService.recordShareAccess(share, authentication, inline);
StoredFile file = share.getFile();
Optional<ResponseEntity<org.springframework.core.io.Resource>> redirect =
tryRedirectToSignedUrl(file, inline);
return redirect.orElseGet(() -> buildFileResponse(file, inline));
return buildFileResponse(file, inline);
}
@GetMapping("/share-links/{token}/metadata")
@@ -286,34 +272,4 @@ 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();
}
}
}
@@ -1,70 +0,0 @@
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) {}
}
@@ -1,104 +0,0 @@
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,8 +6,6 @@ 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;
@@ -37,8 +35,7 @@ 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_folder", columnList = "folder_id")
@Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id")
})
@NoArgsConstructor
@Getter
@@ -109,20 +106,6 @@ 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;
@@ -1,43 +0,0 @@
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;
}
@@ -1,38 +0,0 @@
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,7 +2,6 @@ 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;
@@ -23,10 +22,4 @@ 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;
}
@@ -1,58 +0,0 @@
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,9 +24,6 @@ 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()
@@ -80,7 +77,6 @@ public class LocalStorageProvider implements StorageProvider {
if (filename == null || filename.isBlank()) {
return "file";
}
String stripped = Paths.get(filename).getFileName().toString().replaceAll("\\p{Cntrl}", "");
return stripped.isBlank() ? "file" : stripped;
return Paths.get(filename).getFileName().toString();
}
}
@@ -1,190 +0,0 @@
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,45 +1,16 @@
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 extends AutoCloseable {
public interface StorageProvider {
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();
}
}
@@ -1,35 +0,0 @@
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,13 +59,6 @@ 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,7 +459,6 @@ public class FileStorageService {
file.getPurpose() != null
? file.getPurpose().name().toLowerCase(Locale.ROOT)
: null)
.folderId(file.getFolder() != null ? file.getFolder().getId() : null)
.build();
}
@@ -1,417 +0,0 @@
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;
}
}
@@ -1,147 +0,0 @@
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();
}
}
}
@@ -1,253 +0,0 @@
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;
}
}
}
@@ -1,779 +0,0 @@
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);
}
}
@@ -1,161 +0,0 @@
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.
}
}
}
}
}
@@ -1,59 +0,0 @@
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,7 +1,5 @@
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;
@@ -88,43 +86,4 @@ 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;
}
}
@@ -1,221 +0,0 @@
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);
}
}
@@ -1,250 +0,0 @@
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);
}
}
}
@@ -1,116 +0,0 @@
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);
}
}
@@ -1,130 +0,0 @@
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;
}
}

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