Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60c81e20d0 |
@@ -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=
|
||||
@@ -38,8 +38,6 @@ project: &project
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- scripts/RestartHelper.java
|
||||
- scripts/db-migration/**
|
||||
- .github/workflows/db-migration-test.yml
|
||||
|
||||
frontend: &frontend
|
||||
- frontend/**
|
||||
|
||||
@@ -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_**")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-7
@@ -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
|
||||
@@ -178,6 +174,7 @@ venv.bak/
|
||||
|
||||
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
|
||||
.env*
|
||||
!.env.saas.example
|
||||
|
||||
# VS Code
|
||||
/.vscode/**/*
|
||||
@@ -277,6 +274,3 @@ docs/type3/signatures/
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
*.playwright-mcp.png
|
||||
|
||||
# Local screenshot artifacts from *-screenshots.spec.ts
|
||||
frontend/screenshots/
|
||||
|
||||
@@ -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"}}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||
# stays ignored via the root .gitignore.
|
||||
!.env.saas
|
||||
@@ -44,10 +44,6 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The MIT License"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "MIT-0"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.github.jai-imageio:jai-imageio-core",
|
||||
"moduleLicense": "LICENSE.txt"
|
||||
|
||||
@@ -528,16 +528,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 +778,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 +787,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;
|
||||
|
||||
@@ -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/[^/]+/?$");
|
||||
|
||||
@@ -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(
|
||||
|
||||
+42
-11
@@ -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
-42
@@ -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(
|
||||
|
||||
+2
-7
@@ -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);
|
||||
|
||||
+2
-40
@@ -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
|
||||
@@ -369,8 +335,6 @@ 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:
|
||||
|
||||
+1
-2
@@ -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();
|
||||
|
||||
-71
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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') {}
|
||||
|
||||
-200
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-226
@@ -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;
|
||||
}
|
||||
}
|
||||
-37
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -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
|
||||
|
||||
+1
-16
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-171
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-95
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-15
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
-91
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-46
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-70
@@ -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) {}
|
||||
}
|
||||
-104
@@ -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;
|
||||
}
|
||||
+1
-18
@@ -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;
|
||||
|
||||
-43
@@ -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;
|
||||
}
|
||||
-38
@@ -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());
|
||||
}
|
||||
}
|
||||
-7
@@ -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;
|
||||
}
|
||||
|
||||
-58
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
-190
@@ -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
-30
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
-35
@@ -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);
|
||||
}
|
||||
-7
@@ -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(
|
||||
|
||||
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
-417
@@ -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;
|
||||
}
|
||||
}
|
||||
-147
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-253
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-779
@@ -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&.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);
|
||||
}
|
||||
}
|
||||
-161
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -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");
|
||||
}
|
||||
}
|
||||
-41
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-221
@@ -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);
|
||||
}
|
||||
}
|
||||
-250
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-116
@@ -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);
|
||||
}
|
||||
}
|
||||
-130
@@ -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;
|
||||
}
|
||||
}
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
package stirling.software.proprietary.storage.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3StorageProviderTest {
|
||||
|
||||
private static final String BUCKET = "stirling-test-bucket";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
@Container
|
||||
static MinIOContainer minio =
|
||||
new MinIOContainer("minio/minio:latest")
|
||||
.withUserName(ACCESS_KEY)
|
||||
.withPassword(SECRET_KEY);
|
||||
|
||||
private static S3Client s3Client;
|
||||
private static S3Presigner s3Presigner;
|
||||
private static S3StorageProvider provider;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
URI endpoint = URI.create(minio.getS3URL());
|
||||
AwsBasicCredentials creds = AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY);
|
||||
S3Configuration s3Config = S3Configuration.builder().pathStyleAccessEnabled(true).build();
|
||||
|
||||
s3Client =
|
||||
S3Client.builder()
|
||||
.endpointOverride(endpoint)
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(StaticCredentialsProvider.create(creds))
|
||||
.serviceConfiguration(s3Config)
|
||||
.build();
|
||||
|
||||
s3Presigner =
|
||||
S3Presigner.builder()
|
||||
.endpointOverride(endpoint)
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(StaticCredentialsProvider.create(creds))
|
||||
.serviceConfiguration(s3Config)
|
||||
.build();
|
||||
|
||||
s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
|
||||
provider = new S3StorageProvider(s3Client, s3Presigner, BUCKET);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (provider != null) {
|
||||
provider.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankBucket_constructorRejects() {
|
||||
assertThatThrownBy(() -> new S3StorageProvider(s3Client, s3Presigner, ""))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new S3StorageProvider(s3Client, s3Presigner, null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_thenLoad_roundTripsContent() throws Exception {
|
||||
User owner = new User();
|
||||
owner.setId(42L);
|
||||
byte[] content = "hello s3 round trip".getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", "sample.pdf", "application/pdf", content);
|
||||
|
||||
StoredObject stored = provider.store(owner, file);
|
||||
|
||||
// Key is intentionally opaque ({ownerId}/{uuid}) - the filename is preserved on
|
||||
// StoredObject.originalFilename for display, never in the S3 key, so vendors that
|
||||
// restrict key charset (e.g. Supabase: ASCII only) accept any filename.
|
||||
assertThat(stored.getStorageKey())
|
||||
.matches(
|
||||
"42/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
|
||||
assertThat(stored.getStorageKey()).doesNotContain("sample.pdf");
|
||||
assertThat(stored.getOriginalFilename()).isEqualTo("sample.pdf");
|
||||
assertThat(stored.getContentType()).isEqualTo("application/pdf");
|
||||
assertThat(stored.getSizeBytes()).isEqualTo(content.length);
|
||||
|
||||
Resource loaded = provider.load(stored.getStorageKey());
|
||||
try (InputStream in = loaded.getInputStream()) {
|
||||
assertThat(in.readAllBytes()).isEqualTo(content);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_unknownKey_throwsIOException() {
|
||||
assertThatThrownBy(() -> provider.load("does/not/exist.txt"))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_unicodeFilename_yieldsAsciiOnlyKey_andPreservesOriginalName() throws Exception {
|
||||
// Regression: Supabase Storage rejects S3 keys containing non-ASCII chars (400
|
||||
// Invalid key). Locking in that the storage key never embeds the filename so any
|
||||
// unicode display name still uploads successfully.
|
||||
User owner = new User();
|
||||
owner.setId(99L);
|
||||
String unicodeName = "résumé-日本語-é.pdf";
|
||||
byte[] payload = "u".getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", unicodeName, "application/pdf", payload);
|
||||
|
||||
StoredObject stored = provider.store(owner, file);
|
||||
|
||||
assertThat(stored.getStorageKey())
|
||||
.matches(
|
||||
"99/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
|
||||
assertThat(stored.getOriginalFilename()).isEqualTo(unicodeName);
|
||||
try (InputStream in = provider.load(stored.getStorageKey()).getInputStream()) {
|
||||
assertThat(in.readAllBytes()).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_removesObject() throws Exception {
|
||||
User owner = new User();
|
||||
owner.setId(7L);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file", "todelete.bin", "application/octet-stream", new byte[] {1, 2, 3});
|
||||
|
||||
StoredObject stored = provider.store(owner, file);
|
||||
provider.delete(stored.getStorageKey());
|
||||
|
||||
assertThatThrownBy(() -> provider.load(stored.getStorageKey()))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_unknownKey_isNoOp() {
|
||||
assertThat(catchIOException(() -> provider.delete("never-existed"))).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedDownloadUrl_returnsWorkingPresignedGet() throws Exception {
|
||||
User owner = new User();
|
||||
owner.setId(99L);
|
||||
byte[] content = "presigned payload".getBytes(StandardCharsets.UTF_8);
|
||||
StoredObject stored =
|
||||
provider.store(
|
||||
owner, new MockMultipartFile("file", "presign.txt", "text/plain", content));
|
||||
|
||||
Optional<URI> signed =
|
||||
provider.signedDownloadUrl(stored.getStorageKey(), Duration.ofMinutes(2));
|
||||
|
||||
assertThat(signed).isPresent();
|
||||
URI uri = signed.get();
|
||||
assertThat(uri.getScheme()).isIn("http", "https");
|
||||
assertThat(uri.getRawQuery()).contains("X-Amz-Signature");
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(uri.toString()).openConnection();
|
||||
try {
|
||||
assertThat(conn.getResponseCode()).isEqualTo(200);
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
assertThat(in.readAllBytes()).isEqualTo(content);
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedDownloadUrl_nullKey_returnsEmpty() throws Exception {
|
||||
assertThat(provider.signedDownloadUrl(null, Duration.ofMinutes(1))).isEmpty();
|
||||
assertThat(provider.signedDownloadUrl(" ", Duration.ofMinutes(1))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedDownloadUrl_nullOrZeroTtl_appliesDefault() throws Exception {
|
||||
User owner = new User();
|
||||
owner.setId(3L);
|
||||
StoredObject stored =
|
||||
provider.store(
|
||||
owner,
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"ttl.txt",
|
||||
"text/plain",
|
||||
"x".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
assertThat(provider.signedDownloadUrl(stored.getStorageKey(), null)).isPresent();
|
||||
assertThat(provider.signedDownloadUrl(stored.getStorageKey(), Duration.ZERO)).isPresent();
|
||||
assertThat(provider.signedDownloadUrl(stored.getStorageKey(), Duration.ofSeconds(-5)))
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedDownloadUrl_inlineFlagEncodesResponseContentDispositionInQuery() throws Exception {
|
||||
User owner = new User();
|
||||
owner.setId(55L);
|
||||
StoredObject stored =
|
||||
provider.store(
|
||||
owner,
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"stored-name.pdf",
|
||||
"application/pdf",
|
||||
"payload".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
URI attached =
|
||||
provider.signedDownloadUrl(
|
||||
stored.getStorageKey(), Duration.ofMinutes(2), false, "report.pdf")
|
||||
.orElseThrow();
|
||||
String attachedQuery =
|
||||
java.net.URLDecoder.decode(attached.getRawQuery(), StandardCharsets.UTF_8);
|
||||
assertThat(attachedQuery)
|
||||
.contains("response-content-disposition=attachment; filename=\"report.pdf\"");
|
||||
|
||||
URI inline =
|
||||
provider.signedDownloadUrl(
|
||||
stored.getStorageKey(), Duration.ofMinutes(2), true, "report.pdf")
|
||||
.orElseThrow();
|
||||
String inlineQuery =
|
||||
java.net.URLDecoder.decode(inline.getRawQuery(), StandardCharsets.UTF_8);
|
||||
assertThat(inlineQuery)
|
||||
.contains("response-content-disposition=inline; filename=\"report.pdf\"");
|
||||
|
||||
URI bare =
|
||||
provider.signedDownloadUrl(
|
||||
stored.getStorageKey(), Duration.ofMinutes(2), false, null)
|
||||
.orElseThrow();
|
||||
assertThat(bare.getRawQuery()).doesNotContain("response-content-disposition");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildContentDisposition_escapesQuotesAndStripsControlChars() {
|
||||
assertThat(S3StorageProvider.buildContentDisposition(true, "ev\"il\r\nname.pdf"))
|
||||
.isEqualTo("inline; filename=\"ev\\\"ilname.pdf\"");
|
||||
assertThat(S3StorageProvider.buildContentDisposition(false, null)).isNull();
|
||||
assertThat(S3StorageProvider.buildContentDisposition(false, " ")).isNull();
|
||||
}
|
||||
|
||||
private static IOException catchIOException(IOAction action) {
|
||||
try {
|
||||
action.run();
|
||||
return null;
|
||||
} catch (IOException e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface IOAction {
|
||||
void run() throws IOException;
|
||||
}
|
||||
}
|
||||
-298
@@ -1,298 +0,0 @@
|
||||
package stirling.software.proprietary.storage.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.storage.model.Folder;
|
||||
import stirling.software.proprietary.storage.model.api.CreateFolderRequest;
|
||||
import stirling.software.proprietary.storage.repository.FolderRepository;
|
||||
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link FolderService}. Covers the regressions Connor flagged in PR #6383:
|
||||
*
|
||||
* <ul>
|
||||
* <li>storage-enabled gate must be enforced (added in the same PR)
|
||||
* <li>cross-user folder access must 404, not leak existence
|
||||
* <li>cycle detection on reparent must 400
|
||||
* <li>depth cap must reject chains past MAX_FOLDER_DEPTH
|
||||
* <li>per-user folder count cap must 409
|
||||
* </ul>
|
||||
*
|
||||
* Hibernate is mocked: this is a pure-Mockito unit test, not a slice test. Adequate for the
|
||||
* service-layer behaviors above; full DB integration belongs in a separate {@code @DataJpaTest}.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FolderServiceTest {
|
||||
|
||||
@Mock private FolderRepository folderRepository;
|
||||
@Mock private StoredFileRepository storedFileRepository;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.Security security;
|
||||
@Mock private ApplicationProperties.Storage storage;
|
||||
|
||||
private FolderService service;
|
||||
private User user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Default to "storage enabled" so the unrelated tests don't have to repeat the wiring.
|
||||
// Individual tests override with disabled state.
|
||||
lenient().when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
lenient().when(applicationProperties.getStorage()).thenReturn(storage);
|
||||
lenient().when(security.isEnableLogin()).thenReturn(true);
|
||||
lenient().when(storage.isEnabled()).thenReturn(true);
|
||||
|
||||
service = new FolderService(folderRepository, storedFileRepository, applicationProperties);
|
||||
|
||||
user = new User();
|
||||
user.setId(42L);
|
||||
user.setUsername("alice");
|
||||
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||
ctx.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(user, null, java.util.List.of()));
|
||||
SecurityContextHolder.setContext(ctx);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFolders_rejects_when_login_disabled() {
|
||||
when(security.isEnableLogin()).thenReturn(false);
|
||||
assertThatThrownBy(() -> service.listFolders())
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFolders_rejects_when_storage_disabled() {
|
||||
when(storage.isEnabled()).thenReturn(false);
|
||||
assertThatThrownBy(() -> service.listFolders())
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFolder_under_unknown_parent_returns_400_without_leaking_existence() {
|
||||
// Parent UUID exists for ANOTHER user; current-user lookup misses it. The repository
|
||||
// returns Optional.empty() and the service must surface a generic 400, not a 404 that
|
||||
// could be used to probe for existence by id-guessing.
|
||||
UUID foreignParentId = UUID.randomUUID();
|
||||
when(folderRepository.findByIdAndOwner(eq(foreignParentId), eq(user)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
CreateFolderRequest req = new CreateFolderRequest();
|
||||
req.setName("Child");
|
||||
req.setParentFolderId(foreignParentId);
|
||||
|
||||
assertThatThrownBy(() -> service.createFolder(req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) e;
|
||||
assertThat(rse.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(rse.getReason()).doesNotContain(foreignParentId.toString());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFolder_409_when_user_at_folder_cap() {
|
||||
// Stub out an existing-id miss so we reach the cap check (no Mockito unnecessary-stub
|
||||
// warnings from the OTHER paths because we exit at the cap before the existsById call).
|
||||
UUID newId = UUID.randomUUID();
|
||||
when(folderRepository.findByIdAndOwner(eq(newId), eq(user))).thenReturn(Optional.empty());
|
||||
when(folderRepository.existsById(eq(newId))).thenReturn(false);
|
||||
when(folderRepository.countByOwner(eq(user))).thenReturn(5_000L);
|
||||
|
||||
CreateFolderRequest req = new CreateFolderRequest();
|
||||
req.setName("Overflow");
|
||||
req.setId(newId);
|
||||
|
||||
assertThatThrownBy(() -> service.createFolder(req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(409));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveParent_rejects_when_chain_exceeds_depth_cap() {
|
||||
// Build a chain Hibernate-proxy-style: 64 ancestor stubs reachable via getParent(). The
|
||||
// 65th createFolder attempt under the deepest existing folder should be rejected with
|
||||
// 400 before any further work.
|
||||
Folder root = makeFolder(UUID.randomUUID(), null);
|
||||
Folder cursor = root;
|
||||
for (int i = 0; i < 63; i++) {
|
||||
Folder child = makeFolder(UUID.randomUUID(), cursor);
|
||||
cursor = child;
|
||||
}
|
||||
// cursor is at depth 64 from root. Attempting to add another folder under cursor pushes
|
||||
// the new child to depth 65 - past the cap. resolveParent walks cursor->root counting
|
||||
// ancestors, which is exactly 64, and rejects.
|
||||
Folder deepest = cursor;
|
||||
when(folderRepository.findByIdAndOwner(eq(deepest.getId()), eq(user)))
|
||||
.thenReturn(Optional.of(deepest));
|
||||
|
||||
CreateFolderRequest req = new CreateFolderRequest();
|
||||
req.setName("Too deep");
|
||||
req.setParentFolderId(deepest.getId());
|
||||
|
||||
assertThatThrownBy(() -> service.createFolder(req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) e;
|
||||
assertThat(rse.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(rse.getReason()).containsIgnoringCase("nesting limit");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateFolder_rejects_cycle_on_reparent() {
|
||||
// A -> B -> C. Attempt to reparent A under C (i.e. set A.parent = C). C's chain to root
|
||||
// includes B which includes A, so the cycle check must fire with 400.
|
||||
Folder a = makeFolder(UUID.randomUUID(), null);
|
||||
Folder b = makeFolder(UUID.randomUUID(), a);
|
||||
Folder c = makeFolder(UUID.randomUUID(), b);
|
||||
|
||||
when(folderRepository.findByIdAndOwner(eq(a.getId()), eq(user))).thenReturn(Optional.of(a));
|
||||
when(folderRepository.findByIdAndOwner(eq(c.getId()), eq(user))).thenReturn(Optional.of(c));
|
||||
|
||||
stirling.software.proprietary.storage.model.api.UpdateFolderRequest req =
|
||||
new stirling.software.proprietary.storage.model.api.UpdateFolderRequest();
|
||||
req.setParentFolderId(c.getId());
|
||||
// shouldReparent() requires the explicit reparent flag - without it the
|
||||
// parent change is silently skipped (PATCH-style semantics).
|
||||
req.setReparent(true);
|
||||
|
||||
assertThatThrownBy(() -> service.updateFolder(a.getId(), req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) e;
|
||||
assertThat(rse.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(rse.getReason()).containsIgnoringCase("descendants");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateFolder_rejects_when_folder_not_owned() {
|
||||
// Owner mismatch surfaces as 404, NOT 403 - 403 would confirm the folder exists, leaking
|
||||
// ids to probing users. Stays consistent with the createFolder-under-unknown-parent test
|
||||
// above.
|
||||
UUID foreignId = UUID.randomUUID();
|
||||
when(folderRepository.findByIdAndOwner(eq(foreignId), eq(user)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
stirling.software.proprietary.storage.model.api.UpdateFolderRequest req =
|
||||
new stirling.software.proprietary.storage.model.api.UpdateFolderRequest();
|
||||
req.setName("Renamed");
|
||||
|
||||
assertThatThrownBy(() -> service.updateFolder(foreignId, req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404));
|
||||
}
|
||||
|
||||
@Test
|
||||
void moveFileToFolder_rejects_when_target_folder_not_owned() {
|
||||
// File belongs to current user but target folder belongs to someone else. Service must
|
||||
// 400, not move the file.
|
||||
UUID foreignFolderId = UUID.randomUUID();
|
||||
stirling.software.proprietary.storage.model.StoredFile file =
|
||||
mock(stirling.software.proprietary.storage.model.StoredFile.class);
|
||||
when(storedFileRepository.findByIdAndOwner(eq(100L), eq(user)))
|
||||
.thenReturn(Optional.of(file));
|
||||
when(folderRepository.findByIdAndOwner(eq(foreignFolderId), eq(user)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.moveFileToFolder(100L, foreignFolderId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMove_rejects_oversized_payload() {
|
||||
// Bypass the @Valid bound by calling the service directly - the cap must hold here too,
|
||||
// not just at the controller's request validator.
|
||||
java.util.List<Long> tooMany = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 1001; i++) tooMany.add((long) i);
|
||||
|
||||
assertThatThrownBy(() -> service.bulkMoveFilesToFolder(null, tooMany))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMove_returns_moved_and_skipped_split() {
|
||||
// Ownership filter on the repository returns a subset; the rest land in skippedFileIds.
|
||||
Folder target = makeFolder(UUID.randomUUID(), null);
|
||||
when(folderRepository.findByIdAndOwner(eq(target.getId()), eq(user)))
|
||||
.thenReturn(Optional.of(target));
|
||||
|
||||
stirling.software.proprietary.storage.model.StoredFile fileA =
|
||||
mock(stirling.software.proprietary.storage.model.StoredFile.class);
|
||||
when(fileA.getId()).thenReturn(1L);
|
||||
stirling.software.proprietary.storage.model.StoredFile fileB =
|
||||
mock(stirling.software.proprietary.storage.model.StoredFile.class);
|
||||
when(fileB.getId()).thenReturn(2L);
|
||||
when(storedFileRepository.findAllByIdInAndOwner(any(), eq(user)))
|
||||
.thenReturn(java.util.List.of(fileA, fileB));
|
||||
|
||||
FolderService.BulkMoveResult result =
|
||||
service.bulkMoveFilesToFolder(target.getId(), java.util.List.of(1L, 2L, 3L, 4L));
|
||||
|
||||
assertThat(result.movedFileIds()).containsExactly(1L, 2L);
|
||||
assertThat(result.skippedFileIds()).containsExactly(3L, 4L);
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private Folder makeFolder(UUID id, Folder parent) {
|
||||
Folder f = new Folder();
|
||||
f.setId(id);
|
||||
f.setOwner(user);
|
||||
f.setName("f-" + id.toString().substring(0, 8));
|
||||
f.setParent(parent);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
# DB migration test fixtures
|
||||
|
||||
These `.mv.db` files are H2 databases captured from past Stirling-PDF releases.
|
||||
They feed the CI smoke test that verifies a fresh build can still boot and
|
||||
authenticate against a database created by an older version.
|
||||
|
||||
| File | Source release | Tables | Notes |
|
||||
|---|---|---|---|
|
||||
| `stirling-pdf-v2.0.0.mv.db` | [v2.0.0](https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v2.0.0) | users, authorities, teams, sessions, audit_events, persistent_logins, invite_tokens, user_license_settings, user_settings | Pre-storage/workflow schema. |
|
||||
| `stirling-pdf-v2.5.0.mv.db` | [v2.5.0](https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v2.5.0) | same as v2.0.0 | Schema unchanged from v2.0.0; intentionally kept as a separate fixture to exercise the "skip every other minor" upgrade path. |
|
||||
| `stirling-pdf-v2.10.0.mv.db` | [v2.10.0](https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v2.10.0) | v2.5.0 tables + file_shares, file_share_accesses, stored_files, stored_file_blobs, storage_cleanup_entries, user_server_certificates, workflow_sessions, workflow_participants, participant_notifications | Adds the file-sharing and workflow signing schema. |
|
||||
|
||||
All three were generated against H2 `2.3.232` and use the same on-disk file
|
||||
format, so the runtime driver can open any of them without conversion.
|
||||
|
||||
## What's in each fixture
|
||||
|
||||
* `admin` user with the default password `stirling` (BCrypt `$2a$10$...`).
|
||||
* The internal API user `STIRLING-PDF-BACKEND-API-USER`.
|
||||
* `ROLE_ADMIN` authority row for the admin user.
|
||||
* `Default` and `Internal` teams.
|
||||
* `user_license_settings` row (singleton).
|
||||
|
||||
`audit_events`, `sessions`, and `user_settings` are empty in the OSS-flavored
|
||||
fixtures: those tables are written only on Enterprise builds (audit) or
|
||||
require an HTTP-session-creating flow (sessions / settings) that the OSS form
|
||||
login no longer exposes. The migration test only depends on the admin user
|
||||
existing, so leaving these empty is intentional.
|
||||
|
||||
## What the CI test checks
|
||||
|
||||
`.github/workflows/db-migration-test.yml` runs `scripts/db-migration/run-migration-test.sh`,
|
||||
which for each fixture:
|
||||
|
||||
1. Copies the fixture into `configs/stirling-pdf-DB-2.3.232.mv.db` of a clean
|
||||
working directory.
|
||||
2. Boots the current `:stirling-pdf:bootJar` against it on a free port.
|
||||
3. Waits for Spring to start (no `SchemaManagementException` in the log).
|
||||
4. POSTs `{"username":"admin","password":"stirling"}` to `/api/v1/auth/login`
|
||||
and asserts the response is `200 OK`.
|
||||
|
||||
A red CI on this job means a schema change in the PR is not backwards
|
||||
compatible with an existing user database. Common causes:
|
||||
|
||||
* Adding a non-nullable column without a default.
|
||||
* Renaming a column (Hibernate's `update` strategy adds the new column and
|
||||
leaves the old one orphaned with the data still in it).
|
||||
* Changing a column type in an incompatible way.
|
||||
* Dropping or renaming a foreign-key target.
|
||||
|
||||
## Regenerating fixtures
|
||||
|
||||
There's no automated regenerator script - fixtures are rare to refresh and the
|
||||
manual steps are short. For each version you want to capture:
|
||||
|
||||
```bash
|
||||
# 1. Download the JAR for that release (requires `gh` authenticated against
|
||||
# github.com/Stirling-Tools/Stirling-PDF).
|
||||
gh release download v2.10.0 \
|
||||
--repo Stirling-Tools/Stirling-PDF \
|
||||
--pattern 'Stirling-PDF-with-login.jar' \
|
||||
--output /tmp/stirling-v2.10.0.jar
|
||||
|
||||
# 2. Boot the JAR in a clean working directory. DB_CLOSE_ON_EXIT=TRUE is
|
||||
# the only override that matters - it makes the H2 file flush on JVM exit
|
||||
# even if you Ctrl-C instead of going through a graceful shutdown.
|
||||
workdir=$(mktemp -d)
|
||||
mkdir -p "$workdir/configs"
|
||||
cd "$workdir"
|
||||
java -jar /tmp/stirling-v2.10.0.jar \
|
||||
--server.port=8089 \
|
||||
--spring.datasource.url='jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL' \
|
||||
&
|
||||
|
||||
# 3. Wait until http://localhost:8089/login responds, then log in once to
|
||||
# materialize whatever rows the app writes on first boot.
|
||||
curl -sf -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"stirling"}' \
|
||||
http://localhost:8089/api/v1/auth/login
|
||||
|
||||
# 4. Shut it down (any kill works - DB_CLOSE_ON_EXIT=TRUE handles the flush).
|
||||
kill -TERM %1 && wait %1
|
||||
|
||||
# 5. Copy the .mv.db here, renamed for the version.
|
||||
cp "$workdir/configs/stirling-pdf-DB-2.3.232.mv.db" \
|
||||
app/proprietary/src/test/resources/db-migration-fixtures/stirling-pdf-v2.10.0.mv.db
|
||||
```
|
||||
|
||||
Requirements: Java 21+ (the historical JARs target Java 17 / 21).
|
||||
|
||||
## Adding a new fixture
|
||||
|
||||
When a new minor release ships, repeat the steps above for the new tag and
|
||||
add a row to the table at the top of this file. Keep the historical fixtures -
|
||||
the test gets stronger with each schema generation it covers.
|
||||
|
||||
## Inspecting a fixture by hand
|
||||
|
||||
The H2 driver bundled with the build ships an interactive shell:
|
||||
|
||||
```bash
|
||||
h2_jar=$(find ~/.gradle/caches/modules-2 -name 'h2-2.3.232.jar' | head -1)
|
||||
cd app/proprietary/src/test/resources/db-migration-fixtures
|
||||
java -cp "$h2_jar" org.h2.tools.Shell \
|
||||
-url 'jdbc:h2:file:./stirling-pdf-v2.10.0;ACCESS_MODE_DATA=r;MODE=PostgreSQL' \
|
||||
-user sa
|
||||
```
|
||||
|
||||
`ACCESS_MODE_DATA=r` keeps the inspection read-only so you can't accidentally
|
||||
mutate a committed fixture.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,235 @@
|
||||
# AWS deployment - Stirling-PDF clustered
|
||||
|
||||
Three paths, ordered by ease-of-use:
|
||||
|
||||
| Path | Best for | Time-to-live | Monthly cost (us-east-1) | Scales? |
|
||||
|---|---|---|---|---|
|
||||
| **1. CloudFormation one-click** | Enterprise self-host, no Kubernetes | ~15 min | ~$120-160 | Yes (ECS autoscaling) |
|
||||
| **2. Terraform module** | Your SaaS, env-promotable, GitOps | ~20 min first time | ~$120-160 | Yes (ECS autoscaling) |
|
||||
| **3. EC2 + Docker Compose** | ≤25 concurrent users, single VM, dead simple | ~5 min | ~$25-40 | No (one VM) |
|
||||
|
||||
Already have a Kubernetes cluster (EKS)? Use the existing
|
||||
[`deploy/helm/stirling-pdf/`](../helm/stirling-pdf/) chart instead of any of
|
||||
these.
|
||||
|
||||
## What each deploys
|
||||
|
||||
All three deliver the same logical topology: **N Stirling app instances behind
|
||||
a load balancer, sharing a Valkey backplane and a Postgres database**, with
|
||||
`/internal/*` blocked at the LB.
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ ALB / nginx │ ←── blocks /internal/*
|
||||
└────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐
|
||||
│ app-1 app-2 │ ←── CLUSTER_ENABLED=true
|
||||
└────────────────┘ CLUSTER_BACKPLANE=valkey
|
||||
│ │
|
||||
▼ ▼
|
||||
Valkey Postgres
|
||||
(ElastiCache) (RDS)
|
||||
```
|
||||
|
||||
### Sticky sessions are required
|
||||
|
||||
Every LB config in this repo (`ip_hash` in nginx, `lb_cookie` on the ALB,
|
||||
`affinity: cookie` on the k8s ingress) is pinning sessions deliberately, not as
|
||||
an optimisation. Result PDFs are written to the local disk of whichever node
|
||||
ran the job; without affinity a download has a ~50% chance of landing on a
|
||||
non-owner node and getting a 410 Gone. Cookie / IP affinity pins a returning
|
||||
client back to the owner pod. If you fork an LB config, keep the stickiness.
|
||||
|
||||
**Heads-up on `ip_hash` (nginx Docker Compose path only):** `ip_hash` collapses
|
||||
every client sharing a source IP onto the same backend. Behind a corporate VPN,
|
||||
CGNAT, or a single egress NAT this means all of those users hammer one app
|
||||
container while the others sit idle. The Compose path is fine for small
|
||||
deployments (the doc above caps it at 25 concurrent users on one VM), but for
|
||||
diverse client populations move to cookie-based affinity (nginx-plus or the
|
||||
openresty sticky-cookie module) or use one of the managed LB paths above
|
||||
- the ALB / k8s Ingress configs in this directory already use cookie stickiness
|
||||
for exactly this reason.
|
||||
|
||||
The only thing that changes between paths is **who manages Valkey and Postgres**:
|
||||
|
||||
| Path | App tasks | Valkey | Postgres | LB |
|
||||
|---|---|---|---|---|
|
||||
| CloudFormation | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
|
||||
| Terraform | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
|
||||
| EC2 Compose | Docker on one EC2 | Docker container | Docker container | nginx (Docker) |
|
||||
|
||||
## Path 1 - CloudFormation (recommended for enterprises)
|
||||
|
||||
The template no longer accepts plaintext passwords as parameters - that pattern leaked
|
||||
secrets into the rendered stack template, CloudTrail events, and the template S3 bucket
|
||||
even with `NoEcho`. Operators pre-create the password secrets and pass their ARNs:
|
||||
|
||||
```bash
|
||||
DB_SECRET_ARN=$(aws secretsmanager create-secret \
|
||||
--name stirling/db-password \
|
||||
--secret-string "$(openssl rand -base64 24)" \
|
||||
--query ARN --output text)
|
||||
|
||||
VALKEY_SECRET_ARN=$(aws secretsmanager create-secret \
|
||||
--name stirling/valkey-auth \
|
||||
--secret-string "$(openssl rand -hex 32)" \
|
||||
--query ARN --output text)
|
||||
|
||||
aws cloudformation deploy \
|
||||
--stack-name stirling-pdf \
|
||||
--template-file cloudformation/stirling-pdf-aws.yaml \
|
||||
--capabilities CAPABILITY_NAMED_IAM \
|
||||
--parameter-overrides \
|
||||
DbSecretArn=$DB_SECRET_ARN \
|
||||
ValkeyAuthSecretArn=$VALKEY_SECRET_ARN
|
||||
```
|
||||
|
||||
The X-Engine-Auth token is generated *inside* Secrets Manager by CloudFormation - the
|
||||
operator never sees or supplies it. After deploy it can be retrieved with
|
||||
`aws secretsmanager get-secret-value --secret-id <stack-name>-engine-secret`.
|
||||
|
||||
Or click-deploy via the AWS console: **Console → CloudFormation → Create stack
|
||||
→ Upload `cloudformation/stirling-pdf-aws.yaml` → fill the 2 ARN params**.
|
||||
|
||||
The stack output `AppUrl` gives you the URL.
|
||||
|
||||
**Knobs you can change in the parameters:**
|
||||
|
||||
| Param | Default | Notes |
|
||||
|---|---|---|
|
||||
| `AppCount` | 2 | Initial Fargate task count; autoscaling can grow to 10 |
|
||||
| `AppCpu` | 1024 (1 vCPU) | Per task |
|
||||
| `AppMemory` | 4096 MiB | Per task |
|
||||
| `EnableAiEngine` | false | Turn on if using AI features |
|
||||
| `AppImage` | `stirlingtools/stirling-pdf:2.11.0` | Pinned to release. Swap for your own ECR URI or newer tag |
|
||||
| `EngineImage` | `stirlingtools/stirling-pdf-ai-engine:2.11.0` | Same |
|
||||
| `DbSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Postgres password |
|
||||
| `ValkeyAuthSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Valkey AUTH token |
|
||||
|
||||
**Tear-down:** `aws cloudformation delete-stack --stack-name stirling-pdf`. RDS
|
||||
keeps a final snapshot for safety.
|
||||
|
||||
## Path 2 - Terraform (recommended for your SaaS)
|
||||
|
||||
```bash
|
||||
cd terraform
|
||||
cat > terraform.tfvars <<EOF
|
||||
name = "stirling-prod"
|
||||
region = "us-east-1"
|
||||
app_count = 3
|
||||
db_password = "$(openssl rand -base64 24)"
|
||||
engine_shared_secret = "$(openssl rand -hex 32)"
|
||||
valkey_auth_token = "$(openssl rand -hex 32)"
|
||||
EOF
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
The ElastiCache replication group runs with TLS in flight (`rediss://`) and AUTH enabled.
|
||||
`valkey_auth_token` is required - the application connects with `REDIS_PASSWORD` populated
|
||||
from Secrets Manager so a compromised pod cannot sweep the keyspace.
|
||||
|
||||
Tearing down: `terraform destroy`.
|
||||
|
||||
The Terraform module is intentionally a **single file** to keep it easy to
|
||||
fork. For real production you'd split into `modules/{vpc,ecs,rds,elasticache,alb}`
|
||||
and have a separate `envs/{dev,staging,prod}/main.tf` referencing them. Both
|
||||
shapes work; the single file is the starting point.
|
||||
|
||||
## Path 3 - EC2 + Docker Compose
|
||||
|
||||
See [`quickstart/ec2-compose.md`](quickstart/ec2-compose.md).
|
||||
|
||||
## EKS + Helm (for k8s shops)
|
||||
|
||||
The Phase 1 implementation already ships a Helm chart:
|
||||
|
||||
```bash
|
||||
# Assumes you already have an EKS cluster
|
||||
helm install stirling deploy/helm/stirling-pdf/ \
|
||||
--set cluster.engineSharedSecret=$(openssl rand -hex 32) \
|
||||
--set image.repository=<your-ecr-uri>/stirling-pdf
|
||||
```
|
||||
|
||||
The chart includes a Valkey StatefulSet by default. To use ElastiCache instead:
|
||||
|
||||
```bash
|
||||
helm install stirling deploy/helm/stirling-pdf/ \
|
||||
--set cluster.valkey.bundled=false \
|
||||
--set cluster.valkey.externalUrl=redis://your-elasticache:6379 \
|
||||
...
|
||||
```
|
||||
|
||||
## Required AWS permissions (for whoever runs the deploy)
|
||||
|
||||
Minimum set to apply the CloudFormation/Terraform:
|
||||
|
||||
- `ec2:*`, `elasticloadbalancing:*` (VPC + ALB)
|
||||
- `ecs:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `iam:PutRolePolicy`,
|
||||
`iam:PassRole`
|
||||
- `elasticache:*`
|
||||
- `rds:*`
|
||||
- `secretsmanager:*`
|
||||
- `logs:CreateLogGroup`, `logs:PutRetentionPolicy`
|
||||
- `application-autoscaling:*`
|
||||
- `cloudformation:*` (CFN only)
|
||||
|
||||
Easiest: run as a user with `PowerUserAccess` for the initial bootstrap, then
|
||||
narrow down with IAM Access Analyzer after the stack is up.
|
||||
|
||||
## Sizing rules of thumb
|
||||
|
||||
| Concurrent users | App tasks | App size | Valkey | Postgres |
|
||||
|---|---|---|---|---|
|
||||
| ≤25 | 2 | 1 vCPU / 4 GB | cache.t4g.small | db.t4g.micro |
|
||||
| 25-100 | 3 | 2 vCPU / 4 GB | cache.t4g.small | db.t4g.small |
|
||||
| 100-500 | 4-6 | 2 vCPU / 8 GB | cache.t4g.medium | db.t4g.medium |
|
||||
| 500+ | 6+ (autoscale) | 2 vCPU / 8 GB | cache.r7g.large + replica | db.t4g.large + read replica |
|
||||
|
||||
The defaults in the templates suit ≤25 users; bump `AppCount` /
|
||||
`InstanceClass` to grow.
|
||||
|
||||
## What you still need to set up yourself
|
||||
|
||||
These are not in the templates because they're customer-specific:
|
||||
|
||||
- **Custom domain + HTTPS.** ALB listener on port 443 with an ACM cert (5-min
|
||||
console wizard) + a Route 53 A-record alias to the ALB.
|
||||
- **Email (SES) for password reset / invitations.**
|
||||
- **OAuth/SAML identity provider.** Stirling supports Keycloak, Okta, Azure
|
||||
AD, Google - config goes in `settings.yml`.
|
||||
- **Backups for Valkey.** Optional - ElastiCache supports daily snapshots; turn
|
||||
on `SnapshotRetentionLimit` in the template if you want. Phase 1 state in
|
||||
Valkey is short-TTL so most operators skip it.
|
||||
- **Frontend CDN.** Not required; the app serves static assets fine. If you
|
||||
want CloudFront, point it at the ALB and cache `/static/*`.
|
||||
|
||||
## Common questions
|
||||
|
||||
**Why ECS Fargate, not EKS?** Fargate has zero cluster management - no
|
||||
control plane to maintain. EKS gets cheaper at scale but adds k8s ops. For
|
||||
most enterprises Fargate is the right default.
|
||||
|
||||
**Why ElastiCache for Valkey and not just Redis?** Valkey is the Linux
|
||||
Foundation's BSD-licensed Redis fork; AWS ElastiCache has supported it natively
|
||||
since 2024. Same wire protocol. Stirling's `LettuceConnectionFactory` doesn't
|
||||
care which one - pick whichever your security team is happier with.
|
||||
|
||||
**Why RDS Postgres and not Aurora?** Aurora costs ~3× more for the same TPS in
|
||||
this workload (mostly cold reads for user/team tables). Switch to Aurora if
|
||||
you need read replicas + DR; t4g.micro covers the small-tenant case.
|
||||
|
||||
**What if I want to use my company's existing Postgres / Redis?** Run only
|
||||
the ECS part of the CloudFormation by setting `SPRING_DATASOURCE_URL` and
|
||||
`CLUSTER_VALKEY_URL` in the task definition to point at your
|
||||
existing endpoints. The template doesn't currently expose those as parameters
|
||||
- easy fork.
|
||||
|
||||
**My ops team wants Pulumi / CDK / Crossplane.** All three speak the same
|
||||
underlying APIs the CloudFormation template uses. Translate from the YAML -
|
||||
the resource graph is identical.
|
||||
@@ -0,0 +1,507 @@
|
||||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Description: >
|
||||
Stirling-PDF clustered deployment on AWS. Provisions an ECS Fargate service
|
||||
(2+ app tasks) + ElastiCache for Valkey + RDS PostgreSQL + ALB. Single
|
||||
CloudFormation stack - click Launch Stack and fill in 4 fields.
|
||||
|
||||
Cost estimate (us-east-1, default sizing): ~$120-160 / month.
|
||||
Tear-down: delete the stack. Everything except the RDS final snapshot goes.
|
||||
|
||||
Parameters:
|
||||
AppImage:
|
||||
Type: String
|
||||
Default: stirlingtools/stirling-pdf:2.11.0
|
||||
Description: Container image with the Phase 1 cluster code. Use your ECR URI for private builds. Pin to an exact version - never :latest in production.
|
||||
|
||||
EngineImage:
|
||||
Type: String
|
||||
Default: stirlingtools/stirling-pdf-ai-engine:2.11.0
|
||||
Description: AI engine image. Leave default if you do not use AI features. Pin to an exact version - never :latest in production.
|
||||
|
||||
DbSecretArn:
|
||||
Type: String
|
||||
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
|
||||
Description: >
|
||||
ARN of a Secrets Manager secret holding the Postgres password as plain SecretString.
|
||||
Create: aws secretsmanager create-secret --name stirling/db-password --secret-string "$(openssl rand -base64 24)"
|
||||
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
|
||||
|
||||
ValkeyAuthSecretArn:
|
||||
Type: String
|
||||
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
|
||||
Description: >
|
||||
ARN of a Secrets Manager secret holding the ElastiCache AUTH token (16-128 chars).
|
||||
Create: aws secretsmanager create-secret --name stirling/valkey-auth --secret-string "$(openssl rand -hex 32)"
|
||||
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
|
||||
|
||||
AppCount:
|
||||
Type: Number
|
||||
Default: 2
|
||||
MinValue: 2
|
||||
MaxValue: 10
|
||||
Description: Number of Stirling-PDF app tasks. Multi-instance requires at least 2.
|
||||
|
||||
AppCpu:
|
||||
Type: Number
|
||||
Default: 1024
|
||||
AllowedValues: [512, 1024, 2048, 4096]
|
||||
Description: Fargate CPU units per app task (1024 = 1 vCPU).
|
||||
|
||||
AppMemory:
|
||||
Type: Number
|
||||
Default: 4096
|
||||
AllowedValues: [1024, 2048, 4096, 8192]
|
||||
Description: Fargate memory MiB per app task.
|
||||
|
||||
EnableAiEngine:
|
||||
Type: String
|
||||
Default: "false"
|
||||
AllowedValues: ["true", "false"]
|
||||
Description: Set true to also run the AI engine service.
|
||||
|
||||
Conditions:
|
||||
WithAiEngine: !Equals [!Ref EnableAiEngine, "true"]
|
||||
|
||||
Resources:
|
||||
|
||||
# ----- networking: use default VPC + its subnets to stay simple -----
|
||||
Vpc:
|
||||
Type: AWS::EC2::VPC
|
||||
Properties:
|
||||
CidrBlock: 10.42.0.0/16
|
||||
EnableDnsHostnames: true
|
||||
EnableDnsSupport: true
|
||||
Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-vpc"}]
|
||||
|
||||
Igw:
|
||||
Type: AWS::EC2::InternetGateway
|
||||
IgwAttach:
|
||||
Type: AWS::EC2::VPCGatewayAttachment
|
||||
Properties: {VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw}
|
||||
|
||||
SubnetA:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
AvailabilityZone: !Select [0, !GetAZs ""]
|
||||
CidrBlock: 10.42.1.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
SubnetB:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
AvailabilityZone: !Select [1, !GetAZs ""]
|
||||
CidrBlock: 10.42.2.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
|
||||
PublicRt:
|
||||
Type: AWS::EC2::RouteTable
|
||||
Properties: {VpcId: !Ref Vpc}
|
||||
PublicRoute:
|
||||
Type: AWS::EC2::Route
|
||||
DependsOn: IgwAttach
|
||||
Properties:
|
||||
RouteTableId: !Ref PublicRt
|
||||
DestinationCidrBlock: 0.0.0.0/0
|
||||
GatewayId: !Ref Igw
|
||||
RtAssocA:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties: {SubnetId: !Ref SubnetA, RouteTableId: !Ref PublicRt}
|
||||
RtAssocB:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties: {SubnetId: !Ref SubnetB, RouteTableId: !Ref PublicRt}
|
||||
|
||||
# ----- security groups -----
|
||||
AlbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Public ALB
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0}
|
||||
- {IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0}
|
||||
|
||||
AppSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Stirling app tasks
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref AlbSg}
|
||||
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !GetAtt InternalSelfSg.GroupId}
|
||||
|
||||
InternalSelfSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Allow app tasks to reach each other internally
|
||||
VpcId: !Ref Vpc
|
||||
|
||||
ValkeySg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Valkey ElastiCache
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 6379, ToPort: 6379, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
DbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: PostgreSQL
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
EngineLbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
GroupDescription: Internal ALB in front of the engine tier - only reachable from app tasks
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
EngineSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
GroupDescription: AI engine - only reachable from the internal engine LB
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref EngineLbSg}
|
||||
|
||||
# ----- managed Valkey -----
|
||||
ValkeySubnetGroup:
|
||||
Type: AWS::ElastiCache::SubnetGroup
|
||||
Properties:
|
||||
Description: Stirling Valkey subnet group
|
||||
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
|
||||
|
||||
Valkey:
|
||||
Type: AWS::ElastiCache::ReplicationGroup
|
||||
Properties:
|
||||
ReplicationGroupDescription: Stirling Valkey
|
||||
Engine: valkey
|
||||
EngineVersion: "8.0"
|
||||
CacheNodeType: cache.t4g.small
|
||||
NumCacheClusters: 1
|
||||
AutomaticFailoverEnabled: false
|
||||
CacheSubnetGroupName: !Ref ValkeySubnetGroup
|
||||
SecurityGroupIds: [!Ref ValkeySg]
|
||||
AtRestEncryptionEnabled: true
|
||||
# TLS in flight required when AuthToken is set (ElastiCache enforces this).
|
||||
TransitEncryptionEnabled: true
|
||||
AuthToken: !Sub "{{resolve:secretsmanager:${ValkeyAuthSecretArn}}}"
|
||||
|
||||
# ----- managed Postgres -----
|
||||
DbSubnetGroup:
|
||||
Type: AWS::RDS::DBSubnetGroup
|
||||
Properties:
|
||||
DBSubnetGroupDescription: Stirling Postgres subnet group
|
||||
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
|
||||
|
||||
Postgres:
|
||||
Type: AWS::RDS::DBInstance
|
||||
DeletionPolicy: Snapshot
|
||||
UpdateReplacePolicy: Snapshot
|
||||
Properties:
|
||||
DBInstanceIdentifier: !Sub "${AWS::StackName}-pg"
|
||||
AllocatedStorage: 20
|
||||
DBInstanceClass: db.t4g.micro
|
||||
Engine: postgres
|
||||
EngineVersion: "17.2"
|
||||
MasterUsername: stirling
|
||||
MasterUserPassword: !Sub "{{resolve:secretsmanager:${DbSecretArn}}}"
|
||||
DBName: stirling
|
||||
DBSubnetGroupName: !Ref DbSubnetGroup
|
||||
VPCSecurityGroups: [!Ref DbSg]
|
||||
StorageEncrypted: true
|
||||
PubliclyAccessible: false
|
||||
MultiAZ: false
|
||||
BackupRetentionPeriod: 7
|
||||
|
||||
# ----- secrets -----
|
||||
EngineSharedSecret:
|
||||
Type: AWS::SecretsManager::Secret
|
||||
Properties:
|
||||
Name: !Sub "${AWS::StackName}-engine-secret"
|
||||
Description: X-Engine-Auth shared secret between app and AI engine. Generated by CFN, never passed in.
|
||||
GenerateSecretString:
|
||||
PasswordLength: 64
|
||||
ExcludePunctuation: true
|
||||
|
||||
|
||||
# ----- ECS cluster + roles -----
|
||||
EcsCluster:
|
||||
Type: AWS::ECS::Cluster
|
||||
Properties:
|
||||
ClusterName: !Sub "${AWS::StackName}-cluster"
|
||||
ClusterSettings:
|
||||
- {Name: containerInsights, Value: enabled}
|
||||
|
||||
TaskExecutionRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
AssumeRolePolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal: {Service: ecs-tasks.amazonaws.com}
|
||||
Action: sts:AssumeRole
|
||||
ManagedPolicyArns:
|
||||
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
|
||||
Policies:
|
||||
- PolicyName: ReadSecrets
|
||||
PolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action: [secretsmanager:GetSecretValue]
|
||||
Resource:
|
||||
- !Ref EngineSharedSecret
|
||||
- !Ref DbSecretArn
|
||||
- !Ref ValkeyAuthSecretArn
|
||||
|
||||
TaskLogGroup:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
LogGroupName: !Sub "/ecs/${AWS::StackName}"
|
||||
RetentionInDays: 14
|
||||
|
||||
# ----- ALB -----
|
||||
Alb:
|
||||
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
|
||||
Properties:
|
||||
Scheme: internet-facing
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref AlbSg]
|
||||
Type: application
|
||||
|
||||
AppTg:
|
||||
Type: AWS::ElasticLoadBalancingV2::TargetGroup
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
Port: 8080
|
||||
Protocol: HTTP
|
||||
TargetType: ip
|
||||
HealthCheckPath: /api/v1/info/status
|
||||
HealthCheckIntervalSeconds: 30
|
||||
HealthCheckTimeoutSeconds: 10
|
||||
HealthyThresholdCount: 2
|
||||
UnhealthyThresholdCount: 5
|
||||
Matcher: {HttpCode: "200"}
|
||||
# Sticky sessions required - see deploy/aws/README.md.
|
||||
TargetGroupAttributes:
|
||||
- Key: stickiness.enabled
|
||||
Value: 'true'
|
||||
- Key: stickiness.type
|
||||
Value: lb_cookie
|
||||
- Key: stickiness.lb_cookie.duration_seconds
|
||||
Value: '86400'
|
||||
|
||||
AlbListener:
|
||||
Type: AWS::ElasticLoadBalancingV2::Listener
|
||||
Properties:
|
||||
LoadBalancerArn: !Ref Alb
|
||||
Port: 80
|
||||
Protocol: HTTP
|
||||
DefaultActions:
|
||||
- Type: forward
|
||||
TargetGroupArn: !Ref AppTg
|
||||
|
||||
AlbBlockInternalRule:
|
||||
Type: AWS::ElasticLoadBalancingV2::ListenerRule
|
||||
Properties:
|
||||
ListenerArn: !Ref AlbListener
|
||||
Priority: 1
|
||||
Conditions:
|
||||
- Field: path-pattern
|
||||
Values: ["/internal/*"]
|
||||
Actions:
|
||||
- Type: fixed-response
|
||||
FixedResponseConfig:
|
||||
StatusCode: "404"
|
||||
ContentType: text/plain
|
||||
MessageBody: "Not Found"
|
||||
|
||||
# ----- ECS task definition for the Stirling app -----
|
||||
AppTaskDef:
|
||||
Type: AWS::ECS::TaskDefinition
|
||||
Properties:
|
||||
Family: !Sub "${AWS::StackName}-app"
|
||||
Cpu: !Ref AppCpu
|
||||
Memory: !Ref AppMemory
|
||||
NetworkMode: awsvpc
|
||||
RequiresCompatibilities: [FARGATE]
|
||||
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
|
||||
ContainerDefinitions:
|
||||
- Name: stirling
|
||||
Image: !Ref AppImage
|
||||
Essential: true
|
||||
PortMappings:
|
||||
- ContainerPort: 8080
|
||||
Protocol: tcp
|
||||
LogConfiguration:
|
||||
LogDriver: awslogs
|
||||
Options:
|
||||
awslogs-group: !Ref TaskLogGroup
|
||||
awslogs-region: !Ref AWS::Region
|
||||
awslogs-stream-prefix: app
|
||||
Environment:
|
||||
- {Name: CLUSTER_ENABLED, Value: "true"}
|
||||
- {Name: CLUSTER_BACKPLANE, Value: valkey}
|
||||
# rediss:// = TLS. REDIS_PASSWORD injected separately via Secrets below.
|
||||
- {Name: CLUSTER_VALKEY_URL, Value: !Sub "rediss://${Valkey.PrimaryEndPoint.Address}:6379"}
|
||||
- {Name: SPRING_DATASOURCE_URL, Value: !Sub "jdbc:postgresql://${Postgres.Endpoint.Address}:5432/stirling"}
|
||||
- {Name: SPRING_DATASOURCE_USERNAME, Value: stirling}
|
||||
- {Name: DOCKER_ENABLE_SECURITY, Value: "true"}
|
||||
- {Name: SYSTEM_DEFAULTLOCALE, Value: en-US}
|
||||
- !If
|
||||
- WithAiEngine
|
||||
- {Name: AIENGINE_URL, Value: !Sub "http://${EngineAlb.DNSName}:5001"}
|
||||
- !Ref AWS::NoValue
|
||||
Secrets:
|
||||
- {Name: CLUSTER_ENGINE_SHAREDSECRET, ValueFrom: !Ref EngineSharedSecret}
|
||||
- {Name: SPRING_DATASOURCE_PASSWORD, ValueFrom: !Ref DbSecretArn}
|
||||
- {Name: REDIS_PASSWORD, ValueFrom: !Ref ValkeyAuthSecretArn}
|
||||
|
||||
AppService:
|
||||
Type: AWS::ECS::Service
|
||||
DependsOn: AlbListener
|
||||
Properties:
|
||||
ServiceName: !Sub "${AWS::StackName}-app"
|
||||
Cluster: !Ref EcsCluster
|
||||
DesiredCount: !Ref AppCount
|
||||
LaunchType: FARGATE
|
||||
TaskDefinition: !Ref AppTaskDef
|
||||
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
|
||||
HealthCheckGracePeriodSeconds: 120
|
||||
DeploymentConfiguration:
|
||||
MinimumHealthyPercent: 50
|
||||
MaximumPercent: 200
|
||||
NetworkConfiguration:
|
||||
AwsvpcConfiguration:
|
||||
AssignPublicIp: ENABLED
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref AppSg, !Ref InternalSelfSg]
|
||||
LoadBalancers:
|
||||
- ContainerName: stirling
|
||||
ContainerPort: 8080
|
||||
TargetGroupArn: !Ref AppTg
|
||||
|
||||
# ----- optional AI engine service -----
|
||||
EngineAlb:
|
||||
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
Scheme: internal
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref EngineLbSg]
|
||||
Type: application
|
||||
|
||||
EngineTg:
|
||||
Type: AWS::ElasticLoadBalancingV2::TargetGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
Port: 5001
|
||||
Protocol: HTTP
|
||||
TargetType: ip
|
||||
HealthCheckPath: /health
|
||||
HealthCheckIntervalSeconds: 30
|
||||
HealthCheckTimeoutSeconds: 10
|
||||
HealthyThresholdCount: 2
|
||||
UnhealthyThresholdCount: 5
|
||||
Matcher: {HttpCode: "200"}
|
||||
|
||||
EngineAlbListener:
|
||||
Type: AWS::ElasticLoadBalancingV2::Listener
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
LoadBalancerArn: !Ref EngineAlb
|
||||
Port: 5001
|
||||
Protocol: HTTP
|
||||
DefaultActions:
|
||||
- Type: forward
|
||||
TargetGroupArn: !Ref EngineTg
|
||||
|
||||
EngineTaskDef:
|
||||
Type: AWS::ECS::TaskDefinition
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
Family: !Sub "${AWS::StackName}-engine"
|
||||
Cpu: 1024
|
||||
Memory: 2048
|
||||
NetworkMode: awsvpc
|
||||
RequiresCompatibilities: [FARGATE]
|
||||
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
|
||||
ContainerDefinitions:
|
||||
- Name: engine
|
||||
Image: !Ref EngineImage
|
||||
PortMappings: [{ContainerPort: 5001, Protocol: tcp}]
|
||||
LogConfiguration:
|
||||
LogDriver: awslogs
|
||||
Options:
|
||||
awslogs-group: !Ref TaskLogGroup
|
||||
awslogs-region: !Ref AWS::Region
|
||||
awslogs-stream-prefix: engine
|
||||
Secrets:
|
||||
- {Name: STIRLING_ENGINE_SHARED_SECRET, ValueFrom: !Ref EngineSharedSecret}
|
||||
|
||||
EngineService:
|
||||
Type: AWS::ECS::Service
|
||||
Condition: WithAiEngine
|
||||
DependsOn: EngineAlbListener
|
||||
Properties:
|
||||
ServiceName: !Sub "${AWS::StackName}-engine"
|
||||
Cluster: !Ref EcsCluster
|
||||
DesiredCount: 1
|
||||
LaunchType: FARGATE
|
||||
TaskDefinition: !Ref EngineTaskDef
|
||||
# Engine model-load warm-up can take 60-90s.
|
||||
HealthCheckGracePeriodSeconds: 120
|
||||
NetworkConfiguration:
|
||||
AwsvpcConfiguration:
|
||||
AssignPublicIp: ENABLED
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref EngineSg]
|
||||
LoadBalancers:
|
||||
- ContainerName: engine
|
||||
ContainerPort: 5001
|
||||
TargetGroupArn: !Ref EngineTg
|
||||
|
||||
# ----- HPA-equivalent: target tracking on CPU -----
|
||||
AppScalingTarget:
|
||||
Type: AWS::ApplicationAutoScaling::ScalableTarget
|
||||
Properties:
|
||||
MaxCapacity: 10
|
||||
MinCapacity: !Ref AppCount
|
||||
ResourceId: !Sub "service/${EcsCluster}/${AppService.Name}"
|
||||
ScalableDimension: ecs:service:DesiredCount
|
||||
ServiceNamespace: ecs
|
||||
RoleARN: !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService"
|
||||
|
||||
AppScalingPolicy:
|
||||
Type: AWS::ApplicationAutoScaling::ScalingPolicy
|
||||
Properties:
|
||||
PolicyName: !Sub "${AWS::StackName}-cpu-target"
|
||||
PolicyType: TargetTrackingScaling
|
||||
ScalingTargetId: !Ref AppScalingTarget
|
||||
TargetTrackingScalingPolicyConfiguration:
|
||||
TargetValue: 70
|
||||
PredefinedMetricSpecification:
|
||||
PredefinedMetricType: ECSServiceAverageCPUUtilization
|
||||
ScaleInCooldown: 60
|
||||
ScaleOutCooldown: 60
|
||||
|
||||
Outputs:
|
||||
AppUrl:
|
||||
Description: Open this URL - Stirling-PDF cluster front door
|
||||
Value: !Sub "http://${Alb.DNSName}/"
|
||||
ClusterName:
|
||||
Value: !Ref EcsCluster
|
||||
ValkeyEndpoint:
|
||||
Value: !GetAtt Valkey.PrimaryEndPoint.Address
|
||||
PostgresEndpoint:
|
||||
Value: !GetAtt Postgres.Endpoint.Address
|
||||
StackTeardown:
|
||||
Description: To delete everything (RDS keeps a final snapshot)
|
||||
Value: !Sub "aws cloudformation delete-stack --stack-name ${AWS::StackName}"
|
||||
@@ -0,0 +1,108 @@
|
||||
# Single-EC2 Quickstart - "just give me Docker on a VM"
|
||||
|
||||
The fastest possible AWS deployment when you don't want managed services. **Good
|
||||
for ≤25 concurrent users on a single beefy VM**. Past that, jump to the
|
||||
CloudFormation or Terraform option which scales horizontally.
|
||||
|
||||
## What you get
|
||||
|
||||
- One EC2 instance running 2 Stirling app containers, 1 Valkey, 1 Postgres,
|
||||
1 nginx LB - exactly the validated `validation/compose.test.yml` topology
|
||||
- ~$25-40/month for a `t3.large`
|
||||
- 5-minute deploy
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Launch an EC2 instance
|
||||
|
||||
- AMI: Amazon Linux 2023 (or Ubuntu 22.04 LTS)
|
||||
- Instance type: `t3.large` (2 vCPU, 8 GB RAM) minimum
|
||||
- Storage: 30 GB gp3
|
||||
- Security group: open `80/tcp` (and `22/tcp` for SSH)
|
||||
- IAM role: none needed
|
||||
|
||||
### 2. SSH in and install Docker
|
||||
|
||||
```bash
|
||||
sudo dnf install -y docker git
|
||||
sudo systemctl enable --now docker
|
||||
sudo usermod -aG docker ec2-user
|
||||
# new shell so the group takes effect
|
||||
exit
|
||||
```
|
||||
|
||||
(On Ubuntu: `sudo apt install -y docker.io docker-compose-plugin git`.)
|
||||
|
||||
### 3. Pull the compose stack
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
|
||||
cd Stirling-PDF
|
||||
git checkout v2.11.0
|
||||
```
|
||||
|
||||
### 4. Set secrets and start
|
||||
|
||||
```bash
|
||||
export CLUSTER_ENGINE_SHAREDSECRET=$(openssl rand -hex 16)
|
||||
export STIRLING_VALKEY_PASSWORD=$(openssl rand -hex 16)
|
||||
export POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||
docker compose -f docker/compose/docker-compose-cluster.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~2 min for the apps to come up:
|
||||
|
||||
```bash
|
||||
until curl -fsS http://localhost:8080/api/v1/info/status | grep -q UP; do sleep 5; done
|
||||
echo OK
|
||||
```
|
||||
|
||||
### 5. Open it
|
||||
|
||||
`http://<EC2 public IP>/`
|
||||
|
||||
### 6. (Optional) HTTPS
|
||||
|
||||
Slap Caddy or Traefik in front, or use AWS ALB pointing at the EC2 instance.
|
||||
Easiest is Caddy:
|
||||
|
||||
```bash
|
||||
docker run -d --name caddy --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v caddy_data:/data \
|
||||
-v $PWD/Caddyfile:/etc/caddy/Caddyfile \
|
||||
caddy
|
||||
```
|
||||
|
||||
with a tiny `Caddyfile`:
|
||||
|
||||
```
|
||||
stirling.yourdomain.com {
|
||||
reverse_proxy host.docker.internal:8080
|
||||
}
|
||||
```
|
||||
|
||||
Caddy fetches a Let's Encrypt cert automatically.
|
||||
|
||||
## Backups
|
||||
|
||||
- **Postgres**: `docker exec stirling-postgres pg_dump -U stirling stirling > backup-$(date +%F).sql`
|
||||
Cron this and ship to S3 with `aws s3 cp`.
|
||||
- **Valkey**: state is short-TTL (job status, rate-limit counters); no backup
|
||||
needed - losing it on restart just means in-flight async jobs need re-running.
|
||||
|
||||
## Tear-down
|
||||
|
||||
`docker compose -f docker/compose/docker-compose-cluster.yml down -v` then
|
||||
terminate the EC2 instance.
|
||||
|
||||
## Limits of this path
|
||||
|
||||
- Single point of failure (one VM)
|
||||
- Manual scaling: edit `app-3`/`app-4` services into the compose file, restart
|
||||
- No autoscaling
|
||||
- No managed-service backups for Valkey
|
||||
- nginx LB runs on the same VM as the apps
|
||||
|
||||
If any of these matter, use the CloudFormation or Terraform option in this
|
||||
directory instead.
|
||||
Generated
+25
@@ -0,0 +1,25 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.100.0"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
Copyright (c) 2017 HashiCorp, Inc.
|
||||
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1,642 @@
|
||||
# Stirling-PDF on AWS - Terraform module (single-module starting point).
|
||||
# Production users typically split into modules/{vpc,ecs,rds,elasticache,alb}; this shape
|
||||
# is meant as a copy-and-fill-in template.
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# ----- inputs -----
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
default = "stirling"
|
||||
}
|
||||
|
||||
variable "app_image" {
|
||||
type = string
|
||||
# Never use :latest in production - breaks reproducible deploys and rollback.
|
||||
default = "stirlingtools/stirling-pdf:2.11.0"
|
||||
}
|
||||
|
||||
variable "engine_image" {
|
||||
type = string
|
||||
default = "stirlingtools/stirling-pdf-ai-engine:2.11.0"
|
||||
}
|
||||
|
||||
variable "app_count" {
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "app_cpu" {
|
||||
type = number
|
||||
default = 1024
|
||||
}
|
||||
|
||||
variable "app_memory" {
|
||||
type = number
|
||||
default = 4096
|
||||
}
|
||||
|
||||
variable "engine_count" {
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "enable_ai_engine" {
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "db_password" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "engine_shared_secret" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "valkey_auth_token" {
|
||||
# ElastiCache AUTH token (16-128 chars). Generate: openssl rand -hex 32
|
||||
type = string
|
||||
sensitive = true
|
||||
validation {
|
||||
condition = length(var.valkey_auth_token) >= 16 && length(var.valkey_auth_token) <= 128
|
||||
error_message = "valkey_auth_token must be between 16 and 128 characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "vpc_cidr" {
|
||||
type = string
|
||||
default = "10.42.0.0/16"
|
||||
}
|
||||
|
||||
# ----- networking (slim VPC, two AZs) -----
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = var.vpc_cidr
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
tags = {
|
||||
Name = "${var.name}-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
resource "aws_subnet" "a" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, 1)
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
}
|
||||
|
||||
resource "aws_subnet" "b" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, 2)
|
||||
availability_zone = data.aws_availability_zones.available.names[1]
|
||||
map_public_ip_on_launch = true
|
||||
}
|
||||
|
||||
resource "aws_internet_gateway" "igw" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
}
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.igw.id
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "a" {
|
||||
subnet_id = aws_subnet.a.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "b" {
|
||||
subnet_id = aws_subnet.b.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
# ----- security groups -----
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${var.name}-alb"
|
||||
description = "Public ALB"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "app" {
|
||||
name = "${var.name}-app"
|
||||
description = "Stirling app tasks"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "alb_to_app" {
|
||||
type = "ingress"
|
||||
from_port = 8080
|
||||
to_port = 8080
|
||||
protocol = "tcp"
|
||||
security_group_id = aws_security_group.app.id
|
||||
source_security_group_id = aws_security_group.alb.id
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "app_to_app" {
|
||||
type = "ingress"
|
||||
from_port = 8080
|
||||
to_port = 8080
|
||||
protocol = "tcp"
|
||||
security_group_id = aws_security_group.app.id
|
||||
source_security_group_id = aws_security_group.app.id
|
||||
}
|
||||
|
||||
resource "aws_security_group" "valkey" {
|
||||
name = "${var.name}-valkey"
|
||||
description = "Valkey ElastiCache"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 6379
|
||||
to_port = 6379
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "db" {
|
||||
name = "${var.name}-db"
|
||||
description = "PostgreSQL"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5432
|
||||
to_port = 5432
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "engine_lb" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-lb"
|
||||
description = "Internal ALB in front of engine tier - app tasks only"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5001
|
||||
to_port = 5001
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine"
|
||||
description = "AI engine tasks - only reachable from the internal engine LB"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5001
|
||||
to_port = 5001
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.engine_lb[0].id]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
# ----- managed Valkey (ElastiCache for Valkey, GA since 2024) -----
|
||||
resource "aws_elasticache_subnet_group" "valkey" {
|
||||
name = "${var.name}-valkey"
|
||||
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
}
|
||||
|
||||
resource "aws_elasticache_replication_group" "valkey" {
|
||||
replication_group_id = "${var.name}-valkey"
|
||||
description = "Stirling Valkey"
|
||||
engine = "valkey"
|
||||
engine_version = "8.0"
|
||||
node_type = "cache.t4g.small"
|
||||
num_cache_clusters = 1
|
||||
automatic_failover_enabled = false
|
||||
subnet_group_name = aws_elasticache_subnet_group.valkey.name
|
||||
security_group_ids = [aws_security_group.valkey.id]
|
||||
at_rest_encryption_enabled = true
|
||||
# TLS in flight required when auth_token is set (ElastiCache enforces this).
|
||||
transit_encryption_enabled = true
|
||||
auth_token = var.valkey_auth_token
|
||||
}
|
||||
|
||||
# ----- managed Postgres -----
|
||||
resource "aws_db_subnet_group" "pg" {
|
||||
name = "${var.name}-pg"
|
||||
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
}
|
||||
|
||||
resource "aws_db_instance" "pg" {
|
||||
identifier = "${var.name}-pg"
|
||||
engine = "postgres"
|
||||
engine_version = "17.2"
|
||||
instance_class = "db.t4g.micro"
|
||||
allocated_storage = 20
|
||||
username = "stirling"
|
||||
password = var.db_password
|
||||
db_name = "stirling"
|
||||
db_subnet_group_name = aws_db_subnet_group.pg.name
|
||||
vpc_security_group_ids = [aws_security_group.db.id]
|
||||
storage_encrypted = true
|
||||
skip_final_snapshot = false
|
||||
final_snapshot_identifier = "${var.name}-pg-final"
|
||||
backup_retention_period = 7
|
||||
}
|
||||
|
||||
# ----- secrets -----
|
||||
resource "aws_secretsmanager_secret" "bundle" {
|
||||
name = "${var.name}-secrets"
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "bundle" {
|
||||
secret_id = aws_secretsmanager_secret.bundle.id
|
||||
secret_string = jsonencode({
|
||||
engineSharedSecret = var.engine_shared_secret
|
||||
dbPassword = var.db_password
|
||||
valkeyAuthToken = var.valkey_auth_token
|
||||
})
|
||||
}
|
||||
|
||||
# ----- ECS cluster + IAM -----
|
||||
resource "aws_ecs_cluster" "main" {
|
||||
name = "${var.name}-cluster"
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "exec" {
|
||||
name = "${var.name}-exec"
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
Action = "sts:AssumeRole"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "exec_managed" {
|
||||
role = aws_iam_role.exec.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "exec_read_secrets" {
|
||||
role = aws_iam_role.exec.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue"]
|
||||
Resource = aws_secretsmanager_secret.bundle.arn
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "ecs" {
|
||||
name = "/ecs/${var.name}"
|
||||
retention_in_days = 14
|
||||
}
|
||||
|
||||
# ----- ALB + target group + listener (blocks /internal/*) -----
|
||||
resource "aws_lb" "alb" {
|
||||
name = "${var.name}-alb"
|
||||
load_balancer_type = "application"
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "app" {
|
||||
name = "${var.name}-app-tg"
|
||||
vpc_id = aws_vpc.main.id
|
||||
port = 8080
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
path = "/api/v1/info/status"
|
||||
matcher = "200"
|
||||
interval = 30
|
||||
timeout = 10
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 5
|
||||
}
|
||||
|
||||
# Sticky sessions required - see deploy/aws/README.md.
|
||||
stickiness {
|
||||
type = "lb_cookie"
|
||||
cookie_duration = 86400
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "http" {
|
||||
load_balancer_arn = aws_lb.alb.arn
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener_rule" "block_internal" {
|
||||
listener_arn = aws_lb_listener.http.arn
|
||||
priority = 1
|
||||
|
||||
condition {
|
||||
path_pattern {
|
||||
values = ["/internal/*"]
|
||||
}
|
||||
}
|
||||
|
||||
action {
|
||||
type = "fixed-response"
|
||||
fixed_response {
|
||||
status_code = "404"
|
||||
content_type = "text/plain"
|
||||
message_body = "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----- app task definition + service -----
|
||||
resource "aws_ecs_task_definition" "app" {
|
||||
family = "${var.name}-app"
|
||||
cpu = var.app_cpu
|
||||
memory = var.app_memory
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
execution_role_arn = aws_iam_role.exec.arn
|
||||
|
||||
container_definitions = jsonencode([{
|
||||
name = "stirling"
|
||||
image = var.app_image
|
||||
essential = true
|
||||
portMappings = [{
|
||||
containerPort = 8080
|
||||
protocol = "tcp"
|
||||
}]
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.ecs.name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "app"
|
||||
}
|
||||
}
|
||||
environment = concat([
|
||||
{ name = "CLUSTER_ENABLED", value = "true" },
|
||||
{ name = "CLUSTER_BACKPLANE", value = "valkey" },
|
||||
# rediss:// = TLS. REDIS_PASSWORD injected separately via secrets below.
|
||||
{ name = "CLUSTER_VALKEY_URL", value = "rediss://${aws_elasticache_replication_group.valkey.primary_endpoint_address}:6379" },
|
||||
{ name = "SPRING_DATASOURCE_URL", value = "jdbc:postgresql://${aws_db_instance.pg.endpoint}/stirling" },
|
||||
{ name = "SPRING_DATASOURCE_USERNAME", value = "stirling" },
|
||||
{ name = "DOCKER_ENABLE_SECURITY", value = "true" },
|
||||
],
|
||||
var.enable_ai_engine ? [
|
||||
{ name = "AIENGINE_URL", value = "http://${aws_lb.engine[0].dns_name}:5001" },
|
||||
] : []
|
||||
)
|
||||
secrets = [
|
||||
{ name = "CLUSTER_ENGINE_SHAREDSECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
|
||||
{ name = "SPRING_DATASOURCE_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:dbPassword::" },
|
||||
{ name = "REDIS_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:valkeyAuthToken::" },
|
||||
]
|
||||
}])
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "app" {
|
||||
name = "${var.name}-app"
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.app.arn
|
||||
desired_count = var.app_count
|
||||
launch_type = "FARGATE"
|
||||
deployment_minimum_healthy_percent = 50
|
||||
deployment_maximum_percent = 200
|
||||
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
|
||||
health_check_grace_period_seconds = 120
|
||||
|
||||
network_configuration {
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.app.id]
|
||||
assign_public_ip = true
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
container_name = "stirling"
|
||||
container_port = 8080
|
||||
}
|
||||
|
||||
depends_on = [aws_lb_listener.http]
|
||||
}
|
||||
|
||||
# ----- autoscaling -----
|
||||
resource "aws_appautoscaling_target" "app" {
|
||||
max_capacity = 10
|
||||
min_capacity = var.app_count
|
||||
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
|
||||
scalable_dimension = "ecs:service:DesiredCount"
|
||||
service_namespace = "ecs"
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "cpu" {
|
||||
name = "${var.name}-cpu-target"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.app.resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.app.scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.app.service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
target_value = 70
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "ECSServiceAverageCPUUtilization"
|
||||
}
|
||||
scale_in_cooldown = 60
|
||||
scale_out_cooldown = 60
|
||||
}
|
||||
}
|
||||
|
||||
# ----- AI engine tier (internal LB + task def + service) -----
|
||||
resource "aws_lb" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-alb"
|
||||
internal = true
|
||||
load_balancer_type = "application"
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.engine_lb[0].id]
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-tg"
|
||||
vpc_id = aws_vpc.main.id
|
||||
port = 5001
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
path = "/health"
|
||||
matcher = "200"
|
||||
interval = 30
|
||||
timeout = 10
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 5
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
load_balancer_arn = aws_lb.engine[0].arn
|
||||
port = 5001
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.engine[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ecs_task_definition" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
family = "${var.name}-engine"
|
||||
cpu = 1024
|
||||
memory = 2048
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
execution_role_arn = aws_iam_role.exec.arn
|
||||
|
||||
container_definitions = jsonencode([{
|
||||
name = "engine"
|
||||
image = var.engine_image
|
||||
essential = true
|
||||
portMappings = [{
|
||||
containerPort = 5001
|
||||
protocol = "tcp"
|
||||
}]
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.ecs.name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "engine"
|
||||
}
|
||||
}
|
||||
secrets = [
|
||||
{ name = "STIRLING_ENGINE_SHARED_SECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
|
||||
]
|
||||
}])
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine"
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.engine[0].arn
|
||||
desired_count = var.engine_count
|
||||
launch_type = "FARGATE"
|
||||
# Engine model-load warm-up can take 60-90s.
|
||||
health_check_grace_period_seconds = 120
|
||||
|
||||
network_configuration {
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.engine[0].id]
|
||||
assign_public_ip = true
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = aws_lb_target_group.engine[0].arn
|
||||
container_name = "engine"
|
||||
container_port = 5001
|
||||
}
|
||||
|
||||
depends_on = [aws_lb_listener.engine]
|
||||
}
|
||||
|
||||
# ----- outputs -----
|
||||
output "app_url" {
|
||||
value = "http://${aws_lb.alb.dns_name}/"
|
||||
}
|
||||
|
||||
output "valkey_endpoint" {
|
||||
value = aws_elasticache_replication_group.valkey.primary_endpoint_address
|
||||
}
|
||||
|
||||
output "postgres_endpoint" {
|
||||
value = aws_db_instance.pg.endpoint
|
||||
}
|
||||
|
||||
output "cluster_name" {
|
||||
value = aws_ecs_cluster.main.name
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: stirling-pdf
|
||||
description: Stirling-PDF clustered deployment (web + worker + AI engine + Valkey).
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "2.11.0"
|
||||
@@ -0,0 +1,44 @@
|
||||
Stirling-PDF release "{{ .Release.Name }}" has been deployed.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
Front door: https://{{ .Values.ingress.host }}/ (via Ingress class "{{ .Values.ingress.className }}")
|
||||
{{- if not .Values.ingress.tls.enabled }}
|
||||
|
||||
WARNING: TLS is disabled on the Ingress (ingress.tls.enabled=false).
|
||||
Cookies, JWTs, and API keys will cross the wire in plaintext. Acceptable
|
||||
for dev / loopback only. Re-enable TLS and provide a secret named
|
||||
"{{ .Values.ingress.tls.secretName }}" (cert-manager or kubectl create secret tls).
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
|
||||
WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
|
||||
Production deployments MUST use an external HA Valkey/Redis: set
|
||||
cluster.valkey.bundled=false
|
||||
cluster.valkey.externalUrl=rediss://<managed-endpoint>:6379
|
||||
Managed options: AWS ElastiCache for Valkey, GCP Memorystore, the Valkey
|
||||
Operator with multi-replica + Sentinel, or any other HA Redis-protocol service.
|
||||
See deploy/aws/README.md for the recommended managed paths.
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (not .Values.cluster.engineSharedSecret) }}
|
||||
|
||||
INFO: cluster.engineSharedSecret was auto-generated and stored in the Secret
|
||||
"{{ .Release.Name }}-cluster-secrets". It is stable across `helm upgrade`
|
||||
(the chart reads the existing Secret on each render) but will regenerate on
|
||||
`helm uninstall` + reinstall. To pin it, retrieve with:
|
||||
kubectl get secret {{ .Release.Name }}-cluster-secrets \
|
||||
-o jsonpath='{.data.engineSharedSecret}' | base64 -d
|
||||
and store it in your secret manager / values file for future installs.
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled (not .Values.cluster.valkey.password) }}
|
||||
|
||||
INFO: cluster.valkey.password was auto-generated for the bundled Valkey and
|
||||
stored in "{{ .Release.Name }}-cluster-secrets". Stable across upgrades but
|
||||
regenerates on uninstall + reinstall (which would orphan the persisted
|
||||
Valkey PVC). Pin it explicitly before going beyond eval/dev:
|
||||
kubectl get secret {{ .Release.Name }}-cluster-secrets \
|
||||
-o jsonpath='{.data.valkeyPassword}' | base64 -d
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{/* Resolve the Valkey URL - bundled vs. external bring-your-own.
|
||||
Bundled path uses ${REDIS_PASSWORD} (shell expansion at Spring Boot startup) so the
|
||||
password is never written into the rendered manifest or K8s events.
|
||||
*/}}
|
||||
{{- define "stirling-pdf.valkeyUrl" -}}
|
||||
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled -}}
|
||||
redis://:${REDIS_PASSWORD}@{{ .Release.Name }}-valkey:6379
|
||||
{{- else if .Values.cluster.valkey.externalUrl -}}
|
||||
{{ .Values.cluster.valkey.externalUrl }}
|
||||
{{- else if .Values.cluster.enabled -}}
|
||||
{{ fail "cluster.valkey.bundled=false requires cluster.valkey.externalUrl to be set (e.g. rediss://user:pw@host:6379)" }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,62 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-engine
|
||||
spec:
|
||||
replicas: {{ .Values.engine.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
spec:
|
||||
containers:
|
||||
- name: engine
|
||||
image: {{ .Values.engine.image.repository }}:{{ .Values.engine.image.tag | default .Chart.AppVersion }}
|
||||
env:
|
||||
- name: STIRLING_ENGINE_SHARED_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 5001
|
||||
# Startup probe: 30 x 5s = 150s grace for model-load warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.engine.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-engine
|
||||
spec:
|
||||
selector:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
ports:
|
||||
- port: 5001
|
||||
targetPort: 5001
|
||||
@@ -0,0 +1,41 @@
|
||||
{{- if .Values.web.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ .Release.Name }}-web
|
||||
minReplicas: {{ .Values.web.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.web.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.web.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if .Values.worker.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-worker
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ .Release.Name }}-worker
|
||||
minReplicas: {{ .Values.worker.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
annotations:
|
||||
# Cookie affinity is required - see deploy/aws/README.md "Sticky sessions are required".
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
|
||||
nginx.ingress.kubernetes.io/session-cookie-name: "STIRLING_NODE"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "86400"
|
||||
# Match the JVM-side 2000MB multipart limit (nginx-ingress default 1MB would 413).
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "2000m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
# Defense-in-depth: /internal/* is never a public route.
|
||||
nginx.ingress.kubernetes.io/server-snippet: |
|
||||
location ^~ /internal/ { return 404; }
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- if .Values.ingress.tls.enabled }}
|
||||
{{- if not .Values.ingress.tls.secretName }}
|
||||
{{- fail "ingress.tls.enabled=true requires ingress.tls.secretName to be set" }}
|
||||
{{- end }}
|
||||
tls:
|
||||
- hosts:
|
||||
{{- if .Values.ingress.tls.hosts }}
|
||||
{{- toYaml .Values.ingress.tls.hosts | nindent 8 }}
|
||||
{{- else }}
|
||||
- {{ .Values.ingress.host }}
|
||||
{{- end }}
|
||||
secretName: {{ .Values.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-web
|
||||
port:
|
||||
number: 8080
|
||||
{{- end }}
|
||||
@@ -0,0 +1,59 @@
|
||||
{{/*
|
||||
Stable-secret pattern. Resolution order for each field:
|
||||
1. Operator-provided value (.Values.*) wins.
|
||||
2. Otherwise reuse what is already in the cluster Secret (lookup), so
|
||||
`helm upgrade` does NOT churn the value on every run.
|
||||
3. Otherwise generate a fresh 64-char random value (first install).
|
||||
|
||||
Caveats:
|
||||
- `lookup` returns empty during `helm template` / `--dry-run=client`. That
|
||||
is acceptable here: those modes are for inspection, not source of truth.
|
||||
Real `helm install` / `helm upgrade` against a live API server see the
|
||||
existing Secret.
|
||||
- `helm uninstall` removes this Secret. A subsequent `helm install` with no
|
||||
operator override will generate fresh values, and any persisted data tied
|
||||
to the old Valkey password (PVC contents) will be unreadable.
|
||||
*/}}
|
||||
{{- $secretName := printf "%s-cluster-secrets" .Release.Name }}
|
||||
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
|
||||
{{- $existingEngine := "" }}
|
||||
{{- $existingValkey := "" }}
|
||||
{{- if $existing }}
|
||||
{{- if hasKey $existing.data "engineSharedSecret" }}
|
||||
{{- $existingEngine = index $existing.data "engineSharedSecret" | b64dec }}
|
||||
{{- end }}
|
||||
{{- if hasKey $existing.data "valkeyPassword" }}
|
||||
{{- $existingValkey = index $existing.data "valkeyPassword" | b64dec }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- $engineSecret := "" }}
|
||||
{{- if .Values.cluster.engineSharedSecret }}
|
||||
{{- $engineSecret = .Values.cluster.engineSharedSecret }}
|
||||
{{- else if $existingEngine }}
|
||||
{{- $engineSecret = $existingEngine }}
|
||||
{{- else }}
|
||||
{{- $engineSecret = randAlphaNum 64 }}
|
||||
{{- end }}
|
||||
|
||||
{{- $needsBundledValkey := and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
{{- $valkeyPassword := "" }}
|
||||
{{- if $needsBundledValkey }}
|
||||
{{- if .Values.cluster.valkey.password }}
|
||||
{{- $valkeyPassword = .Values.cluster.valkey.password }}
|
||||
{{- else if $existingValkey }}
|
||||
{{- $valkeyPassword = $existingValkey }}
|
||||
{{- else }}
|
||||
{{- $valkeyPassword = randAlphaNum 64 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
engineSharedSecret: {{ $engineSecret | quote }}
|
||||
{{- if $needsBundledValkey }}
|
||||
valkeyPassword: {{ $valkeyPassword | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-valkey
|
||||
spec:
|
||||
serviceName: {{ .Release.Name }}-valkey
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-valkey
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-valkey
|
||||
spec:
|
||||
containers:
|
||||
- name: valkey
|
||||
image: valkey/valkey:8.0-alpine
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "exec valkey-server --requirepass \"$REDIS_PASSWORD\" --maxmemory 256mb --maxmemory-policy allkeys-lru"
|
||||
env:
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
{{- toYaml .Values.cluster.valkey.resources | nindent 12 }}
|
||||
{{- if .Values.cluster.valkey.persistence.enabled }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
{{- end }}
|
||||
{{- if .Values.cluster.valkey.persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
{{- if .Values.cluster.valkey.persistence.storageClassName }}
|
||||
storageClassName: {{ .Values.cluster.valkey.persistence.storageClassName }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.cluster.valkey.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-valkey
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: stirling-valkey
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
{{- end }}
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
spec:
|
||||
replicas: {{ .Values.web.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: MODE
|
||||
value: FRONTEND
|
||||
- name: CLUSTER_ENABLED
|
||||
value: {{ .Values.cluster.enabled | quote }}
|
||||
- name: CLUSTER_BACKPLANE
|
||||
value: {{ .Values.cluster.backplane }}
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
{{- end }}
|
||||
- name: CLUSTER_VALKEY_URL
|
||||
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
|
||||
- name: CLUSTER_NODE_ROLE
|
||||
value: web
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: CLUSTER_ENGINE_SHAREDSECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.web.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
spec:
|
||||
selector:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-worker
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
spec:
|
||||
replicas: {{ .Values.worker.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: MODE
|
||||
value: BACKEND
|
||||
- name: CLUSTER_ENABLED
|
||||
value: {{ .Values.cluster.enabled | quote }}
|
||||
- name: CLUSTER_BACKPLANE
|
||||
value: {{ .Values.cluster.backplane }}
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
{{- end }}
|
||||
- name: CLUSTER_VALKEY_URL
|
||||
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
|
||||
- name: CLUSTER_NODE_ROLE
|
||||
value: worker
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: CLUSTER_ENGINE_SHAREDSECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.worker.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Stirling-PDF Helm chart - clustered install (web + worker + AI engine + Valkey behind Ingress).
|
||||
# Single-instance deploys can run the standard container directly without this chart.
|
||||
|
||||
image:
|
||||
repository: stirlingtools/stirling-pdf
|
||||
# Leave blank to default to .Chart.AppVersion. Override per release.
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
engine:
|
||||
image:
|
||||
repository: stirlingtools/stirling-pdf-ai-engine
|
||||
tag: ""
|
||||
replicas: 2
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
|
||||
web:
|
||||
replicas: 2
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
|
||||
worker:
|
||||
replicas: 2
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
|
||||
cluster:
|
||||
enabled: true
|
||||
backplane: valkey
|
||||
valkey:
|
||||
# WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
|
||||
# Production: set bundled=false and point externalUrl at HA Valkey/Redis.
|
||||
# See deploy/aws/README.md for managed options (ElastiCache, Memorystore, etc.).
|
||||
bundled: true
|
||||
# Required when bundled=false. Use rediss:// for TLS.
|
||||
externalUrl: ""
|
||||
# Auto-generated on first install if empty (stable across upgrades via Secret lookup).
|
||||
# Set explicitly via --set or values file when using your own secret manager.
|
||||
password: ""
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 384Mi
|
||||
# Java <-> AI engine auth secret. Auto-generated if empty (see NOTES.txt for retrieval).
|
||||
engineSharedSecret: ""
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: stirling.example.com
|
||||
className: nginx
|
||||
tls:
|
||||
# WARNING: TLS MUST be on in production - cookies, JWTs, and API keys cross the wire.
|
||||
# Provision the secret below (cert-manager, kubectl create secret tls, etc.) before helm install.
|
||||
# tls.enabled=false is HTTP-only - dev/loopback ONLY.
|
||||
enabled: true
|
||||
secretName: stirling-pdf-tls
|
||||
# Defaults to [ingress.host] when empty.
|
||||
hosts: []
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Starter dashboard for Stirling-PDF cluster mode (plan §7). Add to your Grafana instance pointed at a Prometheus that is scraping /actuator/prometheus on each node. NOTE: requires PR3 (Valkey backplane impls + ClusterMetrics) - panels render 'No data' until that lands.",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"id": 1,
|
||||
"options": {"legend": {"displayMode": "table", "showLegend": true}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "stirling_cluster_jobs_inflight",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Jobs in flight (per node)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"id": 2,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "stirling_cluster_queue_depth",
|
||||
"legendFormat": "{{lane}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Queue depth (per lane)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"id": 3,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.99, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Job wait time (p50/p95/p99)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"id": 4,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Backplane round-trip latency (Valkey p50/p95)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||
"id": 5,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "sum(rate(stirling_cluster_sticky_miss_total[5m]))",
|
||||
"legendFormat": "sticky-session misses / sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Sticky-session miss rate (high = LB affinity broken)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||
"id": 6,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "sum(rate(stirling_cluster_ratelimit_rejected_total[5m]))",
|
||||
"legendFormat": "rejections / sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Rate-limit rejections (cluster-wide)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["stirling-pdf", "cluster"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Datasource",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"queryValue": "",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {"from": "now-1h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Stirling-PDF Cluster",
|
||||
"uid": "stirling-cluster-phase1",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Stirling-PDF unified container. MODE env var selects role: FRONTEND, BACKEND, or BOTH.
|
||||
|
||||
ARG BASE_VERSION=1.0.2
|
||||
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
|
||||
|
||||
# Stage 1: Build the Java application
|
||||
FROM gradle:9.3.1-jdk25 AS app-build
|
||||
|
||||
ARG TASK_VERSION=3.49.1
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& update-ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
|
||||
&& dpkg -i /tmp/task.deb \
|
||||
&& rm /tmp/task.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY build.gradle settings.gradle gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY app/core/build.gradle app/core/
|
||||
COPY app/common/build.gradle app/common/
|
||||
COPY app/proprietary/build.gradle app/proprietary/
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
|
||||
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
# Stage 2: Extract Spring Boot layers
|
||||
FROM eclipse-temurin:25-jre-noble AS jar-extract
|
||||
WORKDIR /tmp
|
||||
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
|
||||
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
ENV MODE=BOTH \
|
||||
BACKEND_INTERNAL_PORT=8081 \
|
||||
STIRLING_AOT_ENABLE="false" \
|
||||
STIRLING_JVM_PROFILE="balanced" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf
|
||||
|
||||
RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
|
||||
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Unified" \
|
||||
org.opencontainers.image.description="Unified container - selectable role via MODE env var." \
|
||||
org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.vendor="Stirling-Tools" \
|
||||
org.opencontainers.image.version="${VERSION_TAG}"
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
|
||||
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
|
||||
|
||||
# Map MODE to cluster node role at container start.
|
||||
ENTRYPOINT ["sh", "-c", "case \"$MODE\" in BACKEND) export CLUSTER_NODE_ROLE=worker;; FRONTEND) export CLUSTER_NODE_ROLE=web;; *) export CLUSTER_NODE_ROLE=both;; esac; exec tini -- /scripts/init.sh"]
|
||||
CMD []
|
||||
@@ -630,10 +630,9 @@ RUN set -eux; \
|
||||
RUN set -eux; \
|
||||
mkdir -p /configs /configs/cache /configs/heap_dumps /logs /customFiles \
|
||||
/pipeline/watchedFolders /pipeline/finishedFolders \
|
||||
/storage \
|
||||
/tmp/stirling-pdf/heap_dumps; \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup \
|
||||
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /storage \
|
||||
/home/stirlingpdfuser /configs /logs /customFiles /pipeline \
|
||||
/tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Reference clustered deployment: load balancer + N app + N engine + Valkey + Postgres.
|
||||
# Non-k8s enterprise self-host blueprint. /internal/* stays off the LB.
|
||||
#
|
||||
# Required env vars (set in a .env beside this file or via your secret store):
|
||||
# CLUSTER_ENGINE_SHAREDSECRET - generate: openssl rand -hex 32
|
||||
# STIRLING_VALKEY_PASSWORD - generate: openssl rand -hex 32
|
||||
# POSTGRES_PASSWORD - required
|
||||
# STIRLING_PREMIUM_KEY - SERVER/ENTERPRISE license key (cluster mode is a paid feature)
|
||||
|
||||
services:
|
||||
valkey:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
container_name: stirling-valkey
|
||||
restart: unless-stopped
|
||||
command:
|
||||
[
|
||||
"valkey-server",
|
||||
"--requirepass",
|
||||
"${STIRLING_VALKEY_PASSWORD:?STIRLING_VALKEY_PASSWORD must be set in production}",
|
||||
"--maxmemory",
|
||||
"256mb",
|
||||
"--maxmemory-policy",
|
||||
"allkeys-lru"
|
||||
]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"valkey-cli",
|
||||
"-a",
|
||||
"${STIRLING_VALKEY_PASSWORD:?required}",
|
||||
"--no-auth-warning",
|
||||
"ping"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
# Do not publish 6379 in prod. Uncomment only for local valkey-cli introspection.
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384m
|
||||
reservations:
|
||||
memory: 128m
|
||||
volumes:
|
||||
- valkey-data:/data
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: stirling-postgres
|
||||
environment:
|
||||
POSTGRES_USER: stirling
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
POSTGRES_DB: stirling
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
reservations:
|
||||
memory: 256m
|
||||
|
||||
app-1:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
container_name: stirling-app-1
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: BOTH
|
||||
CLUSTER_ENABLED: "true"
|
||||
CLUSTER_BACKPLANE: valkey
|
||||
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
|
||||
CLUSTER_NODE_ID: app-1
|
||||
CLUSTER_NODE_INTERNALADDRESS: app-1:8080
|
||||
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
AIENGINE_URL: http://engine-lb:5001
|
||||
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
|
||||
SPRING_DATASOURCE_USERNAME: stirling
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_started
|
||||
engine-lb:
|
||||
condition: service_started
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
reservations:
|
||||
memory: 512m
|
||||
|
||||
app-2:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
container_name: stirling-app-2
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: BOTH
|
||||
CLUSTER_ENABLED: "true"
|
||||
CLUSTER_BACKPLANE: valkey
|
||||
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
|
||||
CLUSTER_NODE_ID: app-2
|
||||
CLUSTER_NODE_INTERNALADDRESS: app-2:8080
|
||||
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
AIENGINE_URL: http://engine-lb:5001
|
||||
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
|
||||
SPRING_DATASOURCE_USERNAME: stirling
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_started
|
||||
engine-lb:
|
||||
condition: service_started
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
reservations:
|
||||
memory: 512m
|
||||
|
||||
engine-1:
|
||||
build:
|
||||
context: ../../engine
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: stirling-engine-1
|
||||
env_file: ../../engine/.env
|
||||
environment:
|
||||
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
|
||||
engine-2:
|
||||
build:
|
||||
context: ../../engine
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: stirling-engine-2
|
||||
env_file: ../../engine/.env
|
||||
environment:
|
||||
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
|
||||
# Internal round-robin LB for the engine tier - not exposed to the host.
|
||||
engine-lb:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: stirling-engine-lb
|
||||
volumes:
|
||||
- ./engine-nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- engine-1
|
||||
- engine-2
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64m
|
||||
|
||||
lb:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: stirling-lb
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- app-1
|
||||
- app-2
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
|
||||
volumes:
|
||||
valkey-data:
|
||||
postgres-data:
|
||||
@@ -0,0 +1,27 @@
|
||||
# nginx LB for the AI engine tier of docker-compose-cluster.yml.
|
||||
# Round-robin is safe (engine is stateless). The separate LB hop exists because the JVM
|
||||
# caches DNS lookups for the process lifetime, so compose round-robin DNS would pin each
|
||||
# JVM to one engine and defeat scaling.
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
upstream stirling_engine {
|
||||
server engine-1:5001;
|
||||
server engine-2:5001;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 5001;
|
||||
|
||||
# Matches JVM multipart cap; nginx default 1MB would 413.
|
||||
client_max_body_size 2000m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://stirling_engine;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# nginx LB for docker-compose-cluster.yml.
|
||||
# Sends all traffic to the Java app (including /api/v1/ai/*, which the app proxies to
|
||||
# the engine with X-Engine-Auth attestation). /internal/* is 404'd. The engine tier
|
||||
# is never exposed directly to clients.
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
upstream stirling_app {
|
||||
# Sticky sessions required - see deploy/aws/README.md for ip_hash caveat + alternatives.
|
||||
ip_hash;
|
||||
server app-1:8080;
|
||||
server app-2:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
# Matches JVM multipart.max-file-size; nginx default 1MB would 413.
|
||||
client_max_body_size 2000m;
|
||||
|
||||
location /internal/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://stirling_app;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,7 @@ RUN set -eux; \
|
||||
ln -s /configs /app/configs; \
|
||||
ln -s /customFiles /app/customFiles; \
|
||||
ln -s /pipeline /app/pipeline; \
|
||||
ln -s /storage /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app; \
|
||||
chmod 750 /tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps; \
|
||||
|
||||
@@ -80,8 +80,7 @@ RUN set -eux; \
|
||||
ln -s /configs /app/configs; \
|
||||
ln -s /customFiles /app/customFiles; \
|
||||
ln -s /pipeline /app/pipeline; \
|
||||
ln -s /storage /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app; \
|
||||
chmod 750 /tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps; \
|
||||
|
||||
@@ -99,11 +99,11 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
curl \
|
||||
shadow \
|
||||
util-linux && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
|
||||
mkdir -p /usr/share/fonts/opentype/noto && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /storage /tmp/stirling-pdf
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /tmp/stirling-pdf
|
||||
|
||||
# Copy scripts and built artifacts after OS package layer to maximize cache reuse.
|
||||
COPY --chown=1000:1000 scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
|
||||
@@ -20,7 +20,6 @@ services:
|
||||
- ../../../stirling/latest/data:/usr/share/tessdata:rw
|
||||
- ../../../stirling/latest/config:/configs:rw
|
||||
- ../../../stirling/latest/logs:/logs:rw
|
||||
- ../../../stirling/latest/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
@@ -37,4 +36,5 @@ services:
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SHOW_SURVEY: "true"
|
||||
STORAGE_LOCAL_BASEPATH: /configs/storage
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -16,7 +16,6 @@ services:
|
||||
- ../../../stirling/latest/data:/usr/share/tessdata:rw
|
||||
- ../../../stirling/latest/config:/configs:rw
|
||||
- ../../../stirling/latest/logs:/logs:rw
|
||||
- ../../../stirling/latest/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
@@ -32,9 +31,4 @@ services:
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SYSTEM_ENABLEMOBILESCANNER: "true"
|
||||
SECURITY_CUSTOMGLOBALAPIKEY: "123456789"
|
||||
# Folder management + file-storage features the cucumber
|
||||
# `folders_and_files.feature` suite needs to upload PDFs against.
|
||||
# The folder endpoints and the storage upload endpoint short-circuit
|
||||
# to 403 "Storage is disabled" when this is left at its default false.
|
||||
STORAGE_ENABLED: "true"
|
||||
restart: on-failure:5
|
||||
|
||||
@@ -4,6 +4,7 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY src/ ./src/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen
|
||||
|
||||
|
||||
@@ -1094,15 +1094,6 @@ class SanitizePdfParams(ApiModel):
|
||||
remove_xmp_metadata: bool = Field(False, description="Remove XMP metadata from the PDF")
|
||||
|
||||
|
||||
class Orientation1(StrEnum):
|
||||
"""
|
||||
Orientation to apply to the target page size. Ignored when pageSize is KEEP.
|
||||
"""
|
||||
|
||||
portrait = "PORTRAIT"
|
||||
landscape = "LANDSCAPE"
|
||||
|
||||
|
||||
class PageSize(StrEnum):
|
||||
"""
|
||||
The scale of pages in the output PDF. Acceptable values are A0-A6, LETTER, LEGAL, KEEP.
|
||||
@@ -1121,10 +1112,6 @@ class PageSize(StrEnum):
|
||||
|
||||
|
||||
class ScalePagesParams(ApiModel):
|
||||
orientation: Orientation1 = Field(
|
||||
Orientation1.portrait,
|
||||
description="Orientation to apply to the target page size. Ignored when pageSize is KEEP.",
|
||||
)
|
||||
page_size: PageSize = Field(
|
||||
..., description="The scale of pages in the output PDF. Acceptable values are A0-A6, LETTER, LEGAL, KEEP."
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* Stirling-PDF E2E Test Configuration
|
||||
*
|
||||
* The suite is split into two projects:
|
||||
* - `stubbed` - backend-free specs that mock `/api/v1/*` via `page.route()`.
|
||||
* - `stubbed` — backend-free specs that mock `/api/v1/*` via `page.route()`.
|
||||
* Safe to run in CI without the Spring Boot server. Lives in
|
||||
* `src/core/tests/stubbed/**`.
|
||||
* - `live` - specs that require a real backend on `localhost:8080`
|
||||
* - `live` — specs that require a real backend on `localhost:8080`
|
||||
* (auth, admin mutation, real tool round-trips). Lives in
|
||||
* `src/core/tests/live/**`.
|
||||
*
|
||||
@@ -35,7 +35,7 @@ export default defineConfig({
|
||||
expect: { timeout: 10_000 },
|
||||
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173",
|
||||
baseURL: "http://localhost:5173",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "on-first-retry",
|
||||
@@ -44,14 +44,14 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Stubbed - no backend required, chromium-only for CI speed
|
||||
// Stubbed — no backend required, chromium-only for CI speed
|
||||
{
|
||||
name: "stubbed",
|
||||
testDir: "./src/core/tests/stubbed",
|
||||
use: chromiumViewport,
|
||||
},
|
||||
|
||||
// Live setup - runs once before the live suite to perform the real
|
||||
// Live setup — runs once before the live suite to perform the real
|
||||
// forced-password-change first-login flow against a freshly-booted
|
||||
// backend. The live project depends on it.
|
||||
{
|
||||
@@ -61,7 +61,7 @@ export default defineConfig({
|
||||
use: chromiumViewport,
|
||||
},
|
||||
|
||||
// Live backend - auth + admin-mutation + real-tool smoke
|
||||
// Live backend — auth + admin-mutation + real-tool smoke
|
||||
{
|
||||
name: "live",
|
||||
testDir: "./src/core/tests/live",
|
||||
@@ -69,7 +69,7 @@ export default defineConfig({
|
||||
dependencies: ["live-setup"],
|
||||
},
|
||||
|
||||
// Enterprise - license-gated SSO/SAML/audit/teams against keycloak compose
|
||||
// Enterprise — license-gated SSO/SAML/audit/teams against keycloak compose
|
||||
// Uses port 8080 directly (the docker compose stack publishes the
|
||||
// backend's built-in frontend there); the Vite dev server is bypassed
|
||||
// because the OAuth/SAML callback URLs are registered against 8080.
|
||||
@@ -98,7 +98,7 @@ export default defineConfig({
|
||||
webServer: {
|
||||
// In CI, serve a pre-built `dist/` via `vite preview` so the heavy tool
|
||||
// pages don't pay vite's on-demand transform cost on first hit (which
|
||||
// blew the 30s navigationTimeout under --workers=3 - see
|
||||
// blew the 30s navigationTimeout under --workers=3 — see
|
||||
// all-tool-pages-load.spec.ts). Locally, keep `vite` dev for HMR.
|
||||
command: process.env.CI
|
||||
? "npx vite preview --port 5173 --strictPort"
|
||||
|
||||
@@ -494,11 +494,6 @@ title = "Adjust Page Scale"
|
||||
[adjustPageScale.error]
|
||||
failed = "An error occurred while adjusting the page scale."
|
||||
|
||||
[adjustPageScale.orientation]
|
||||
label = "Page orientation"
|
||||
landscape = "Landscape"
|
||||
portrait = "Portrait"
|
||||
|
||||
[adjustPageScale.pageSize]
|
||||
keep = "Keep Original Size"
|
||||
label = "Target Page Size"
|
||||
@@ -1472,7 +1467,7 @@ settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings
|
||||
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
|
||||
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
|
||||
welcome = "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."
|
||||
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime - just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
|
||||
[adminUserSettings]
|
||||
actions = "Actions"
|
||||
@@ -1530,7 +1525,7 @@ comment = "Comment"
|
||||
comments = "Comments"
|
||||
contents = "Text"
|
||||
delete = "Delete"
|
||||
desc = "Use highlight, pen, text, and notes. Changes stay live-no flattening required."
|
||||
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
|
||||
drawing = "Drawing"
|
||||
duplicate = "Duplicate"
|
||||
editCircle = "Edit Circle"
|
||||
@@ -1856,13 +1851,6 @@ label = "Open menu for {{title}}"
|
||||
[automate.files]
|
||||
placeholder = "Select files to process with this automation"
|
||||
|
||||
[automate.folderScanWarning]
|
||||
advice = "You can still download the file (e.g. to inspect or hand-edit it), but the unsupported steps will need to be removed before the backend can run it."
|
||||
cancel = "Cancel"
|
||||
confirm = "Export anyway"
|
||||
intro = "Folder scanning runs on the backend, so it can only execute tools that have a backend endpoint. The following step(s) in this automation do not, and will fail when the pipeline runs:"
|
||||
title = "Some steps cannot run in folder scanning"
|
||||
|
||||
[automate.importModal]
|
||||
cancel = "Cancel"
|
||||
confirm = "Import"
|
||||
@@ -2094,9 +2082,9 @@ placeholder = "Number of pages"
|
||||
title = "Last N Pages"
|
||||
|
||||
[bulkSelection.operators]
|
||||
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
|
||||
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
|
||||
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
|
||||
and = "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)"
|
||||
comma = "Comma: , or | — combine selections (e.g., 1-10, 20)"
|
||||
not = "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
|
||||
text = "AND has higher precedence than comma. NOT applies within the document range."
|
||||
title = "Operators"
|
||||
|
||||
@@ -2792,14 +2780,13 @@ title = "These PDFs look highly different"
|
||||
|
||||
[compare.edited]
|
||||
label = "Edited PDF"
|
||||
placeholder = "Select the edited PDF"
|
||||
selectBaseFirst = "Select original PDF first"
|
||||
placeholder = "Select the edited PDF"
|
||||
|
||||
[compare.error]
|
||||
filesMissing = "Unable to locate the selected files. Please re-select them."
|
||||
generic = "Unable to compare these files."
|
||||
selectRequired = "Select a original and edited document."
|
||||
title = "Comparison failed"
|
||||
|
||||
[compare.large.file]
|
||||
message = "One or Both of the provided documents are too large to process"
|
||||
@@ -2865,7 +2852,6 @@ title = "Still working…"
|
||||
|
||||
[compare.status]
|
||||
complete = "Comparison ready"
|
||||
error = "Comparison failed"
|
||||
extracting = "Extracting text..."
|
||||
processing = "Analysing differences..."
|
||||
|
||||
@@ -2876,9 +2862,9 @@ pageLabel = "Page"
|
||||
|
||||
[compare.swap]
|
||||
confirm = "Swap and Re-run"
|
||||
label = "Swap"
|
||||
confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?"
|
||||
confirmTitle = "Re-run comparison?"
|
||||
label = "Swap"
|
||||
|
||||
[compare.toasts]
|
||||
unlinkedBody = "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
|
||||
@@ -3198,7 +3184,7 @@ showPreferencesBtn = "Manage preferences"
|
||||
title = "How we use Cookies"
|
||||
|
||||
[cookieBanner.popUp.description]
|
||||
1 = "We use cookies and other technologies to make Stirling PDF work better for you-helping us improve our tools and keep building features you'll love."
|
||||
1 = "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love."
|
||||
2 = "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
|
||||
[cookieBanner.preferencesModal]
|
||||
@@ -3211,16 +3197,16 @@ subtitle = "Cookie Usage"
|
||||
title = "Consent Preferences Center"
|
||||
|
||||
[cookieBanner.preferencesModal.analytics]
|
||||
description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured-Stirling PDF cannot and will never track the content of the documents you work with."
|
||||
description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with."
|
||||
title = "Analytics"
|
||||
|
||||
[cookieBanner.preferencesModal.description]
|
||||
1 = "Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users."
|
||||
2 = "Stirling PDF cannot-and will never-track or access the content of the documents you use."
|
||||
2 = "Stirling PDF cannot—and will never—track or access the content of the documents you use."
|
||||
3 = "Your privacy and trust are at the core of what we do."
|
||||
|
||||
[cookieBanner.preferencesModal.necessary]
|
||||
description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms-which is why they can't be turned off."
|
||||
description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
|
||||
|
||||
[cookieBanner.preferencesModal.necessary.title]
|
||||
1 = "Strictly Necessary Cookies"
|
||||
@@ -3773,206 +3759,13 @@ expand = "Expand sidebar"
|
||||
files = "Files"
|
||||
googleDrive = "Google Drive"
|
||||
googleDriveDisabled = "Google Drive is not configured"
|
||||
leaveMyFiles = "Leave My Files"
|
||||
myFiles = "My Files"
|
||||
noFiles = "No files yet"
|
||||
openFileManager = "Browse all files & folders"
|
||||
openFileManager = "Open file manager"
|
||||
openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
|
||||
[filesPage]
|
||||
addToWorkspace = "Add to workspace"
|
||||
addToWorkspaceCount = "Add {{count}} to workspace"
|
||||
allFiles = "All files"
|
||||
back = "Back"
|
||||
backToFolder = "Back to {{folder}}"
|
||||
backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
create = "Create"
|
||||
cycleBlocked = "Can't move a folder into one of its own subfolders."
|
||||
delete = "Delete"
|
||||
deleteFolder = "Delete folder"
|
||||
deleteFolderBody = "Delete folder \"{{name}}\"?"
|
||||
deleteFolderConfirm = "Delete folder \"{{name}}\"? Files inside will be moved to All files. {{count}} file(s) affected."
|
||||
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
|
||||
deleteFolderContentsWarning = "Files will be permanently removed and cannot be recovered."
|
||||
deleteFolderError = "Could not delete the folder. Try again."
|
||||
deleteFolderKeepHint = "Files inside will be moved to All files."
|
||||
deleteFolderTitle = "Delete folder?"
|
||||
deselectAll = "Clear selection"
|
||||
details = "Details"
|
||||
detailsCount = "{{count}} files selected"
|
||||
dismissError = "Dismiss"
|
||||
download = "Download"
|
||||
downloadAll = "Download all"
|
||||
downloadVersion = "Download this version"
|
||||
dropOverlay = "Drop files to upload"
|
||||
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder."
|
||||
file = "File"
|
||||
fileMenu = "File actions"
|
||||
folder = "Folder"
|
||||
folderItems = "{{count}} items"
|
||||
folderMenu = "Folder actions"
|
||||
inPath = "in {{path}}"
|
||||
inWorkspace = "Open"
|
||||
inWorkspaceAria = "Already in workspace"
|
||||
loading = "Loading…"
|
||||
localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organise it."
|
||||
moreActions = "More folder actions"
|
||||
moveLocalToCloudBlocked = "Local-only files can't be moved into cloud folders. Save them to the cloud first."
|
||||
moveSkippedRemote = "{{count}} file(s) couldn't be moved on the server (no permission or already deleted)."
|
||||
moveTo = "Move to…"
|
||||
myFiles = "My Files"
|
||||
newFolder = "New folder"
|
||||
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
|
||||
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
|
||||
newRootFolder = "New folder at root"
|
||||
offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration."
|
||||
open = "Open"
|
||||
openInWorkbench = "Open in workbench"
|
||||
openVersionInWorkspace = "Open in workspace"
|
||||
originFilter = "Filter by source"
|
||||
quickView = "Quick view"
|
||||
refresh = "Refresh from server"
|
||||
remove = "Delete"
|
||||
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
|
||||
removeVersion = "Remove this version"
|
||||
rename = "Rename"
|
||||
renameFolder = "Rename folder"
|
||||
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
|
||||
selectedCount = "{{count}} selected"
|
||||
selectFile = "Select file {{name}}"
|
||||
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
|
||||
shareManage = "Manage sharing"
|
||||
showDetails = "Show details"
|
||||
summary = "{{count}} items"
|
||||
syncFailed = "Folder sync failed: {{message}}"
|
||||
syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be merged."
|
||||
tree = "Folders"
|
||||
upload = "Upload"
|
||||
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
|
||||
uploadFromMobile = "Upload from Mobile"
|
||||
versionActions = "Version actions"
|
||||
versionCollapse = "Collapse middle versions"
|
||||
versionOrigin = "Original upload"
|
||||
versionsCount = "{{count}} versions"
|
||||
versionShowHidden = "Show {{count}} earlier versions"
|
||||
viewVersion = "View this version"
|
||||
appearance.colour = "Colour"
|
||||
appearance.icon = "Icon"
|
||||
appearance.title = "Appearance"
|
||||
appearance.useColour = "Use colour {{c}}"
|
||||
column.modified = "Modified"
|
||||
column.name = "Name"
|
||||
column.size = "Size"
|
||||
column.type = "Type"
|
||||
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files."
|
||||
empty.newFolderCta = "Create folder"
|
||||
empty.title = "This folder is empty"
|
||||
empty.uploadCta = "Upload files"
|
||||
empty.cloud.hint = "Upload a file to start, or create a folder to organise."
|
||||
empty.cloud.offlineHint = "Reconnect to load your cloud library."
|
||||
empty.cloud.offlineTitle = "No cached cloud files"
|
||||
empty.cloud.title = "No cloud files yet"
|
||||
empty.local.hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||
empty.local.title = "No local-only files"
|
||||
empty.recent.hint = "Files you open or edit will appear here."
|
||||
empty.recent.title = "Nothing modified yet"
|
||||
empty.shared.hint = "When someone shares a file via link, it appears here."
|
||||
empty.shared.title = "Nothing shared with you"
|
||||
empty.sharedByMe.hint = "Create a share link or invite a teammate from any of your files to see it here."
|
||||
empty.sharedByMe.title = "You haven't shared any files yet"
|
||||
error.actionFailed = "Could not {{action}}."
|
||||
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
||||
error.deleteFolderFailed = "Could not delete folder."
|
||||
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
|
||||
error.folderAppearanceFailed = "Could not update folder appearance."
|
||||
error.folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}"
|
||||
error.moveFilesFailed = "Could not move files."
|
||||
error.moveFilesFailedDetail = "Could not move files: {{message}}"
|
||||
error.moveFolderFailed = "Could not move folder."
|
||||
error.moveFolderFailedDetail = "Could not move folder: {{message}}"
|
||||
error.removeFilesFailed = "Could not remove files."
|
||||
error.removeFilesFailedDetail = "Could not remove files: {{message}}"
|
||||
error.uploadFilesFailed = "Could not upload files."
|
||||
error.uploadFilesFailedDetail = "Could not upload files: {{message}}"
|
||||
field.added = "Added"
|
||||
field.count = "Files"
|
||||
field.folder = "Folder"
|
||||
field.modified = "Modified"
|
||||
field.name = "Name"
|
||||
field.size = "Size"
|
||||
field.toolHistory = "Tool history"
|
||||
field.toolHistoryAtVersion = "Cumulative tool chain"
|
||||
field.totalSize = "Total size"
|
||||
field.type = "Type"
|
||||
field.versionHistory = "Version journey"
|
||||
folderName.cancel = "Cancel"
|
||||
folderName.error = "Could not save folder. Try again."
|
||||
folderName.label = "Folder name"
|
||||
folderName.placeholder = "Folder name"
|
||||
moveDialog.cancel = "Cancel"
|
||||
moveDialog.confirm = "Move here"
|
||||
moveDialog.error = "Could not move. Try again."
|
||||
moveDialog.hint = "Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left."
|
||||
moveDialog.newFolderCancel = "Discard"
|
||||
moveDialog.newFolderCreate = "Create"
|
||||
moveDialog.newFolderError = "Could not create folder. Try again."
|
||||
moveDialog.newFolderLabel = "New folder name"
|
||||
moveDialog.newFolderPlaceholder = "Folder name"
|
||||
moveDialog.newFolderToggle = "Create new folder…"
|
||||
moveDialog.title = "Move to folder"
|
||||
origin.all = "All sources"
|
||||
origin.cloud = "Cloud"
|
||||
origin.cloudHint = "Stored on the Stirling server"
|
||||
origin.local = "Local"
|
||||
origin.localHint = "Only stored in this browser"
|
||||
origin.shared = "Shared"
|
||||
origin.sharedHint = "Shared with you via link"
|
||||
sort.modifiedAsc = "Oldest first"
|
||||
sort.modifiedDesc = "Recent first"
|
||||
sort.nameAsc = "Name A→Z"
|
||||
sort.nameDesc = "Name Z→A"
|
||||
sort.sizeAsc = "Smallest first"
|
||||
sort.sizeDesc = "Largest first"
|
||||
syncError.client = "Folder sync failed."
|
||||
syncError.network = "Could not reach the server."
|
||||
syncError.server = "Server error during folder sync."
|
||||
tabName.local = "Local"
|
||||
tabName.recent = "Recent"
|
||||
tabName.shared = "Shared with me"
|
||||
tabName.sharedByMe = "Shared by me"
|
||||
tabs.all = "All"
|
||||
tabs.ariaLabel = "File views"
|
||||
tabs.cloud = "Cloud"
|
||||
tabs.local = "Local"
|
||||
tabs.recent = "Recent"
|
||||
tabs.shared = "Shared with me"
|
||||
tabs.sharedByMe = "Shared by me"
|
||||
treeMenu.actions = "Folder actions for {{name}}"
|
||||
treeMenu.collapse = "Collapse folder"
|
||||
treeMenu.delete = "Delete folder"
|
||||
treeMenu.expand = "Expand folder"
|
||||
treeMenu.newSubfolder = "New subfolder"
|
||||
treeMenu.rename = "Rename"
|
||||
typeFilter.allTypes = "All types"
|
||||
typeFilter.label = "Filter by type"
|
||||
viewMode.grid = "Grid view"
|
||||
viewMode.label = "View mode"
|
||||
viewMode.list = "List view"
|
||||
|
||||
[fileToPDF]
|
||||
credit = "This service uses LibreOffice and Unoconv for file conversion."
|
||||
header = "Convert any file to PDF"
|
||||
@@ -5111,7 +4904,7 @@ toolInterface = "This is the <strong>Crop</strong> tool interface. As you can se
|
||||
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
|
||||
viewSwitcher = "Use these controls to select how you want to view your PDFs."
|
||||
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
|
||||
wrapUp = "You're all set! You can replay this tour anytime - just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
wrapUp = "You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
|
||||
[onboarding.buttons]
|
||||
back = "Back"
|
||||
@@ -5451,9 +5244,9 @@ description = "Use n in formulas for patterns."
|
||||
title = "Mathematical Functions"
|
||||
|
||||
[pageSelection.tooltip.operators]
|
||||
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
|
||||
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
|
||||
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
|
||||
and = "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)"
|
||||
comma = "Comma: , or | — combine selections (e.g., 1-10, 20)"
|
||||
not = "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
|
||||
text = "AND has higher precedence than comma. NOT applies within the document range."
|
||||
title = "Operators"
|
||||
|
||||
@@ -5641,7 +5434,7 @@ modified = "Edited"
|
||||
unsaved = "Edited"
|
||||
|
||||
[pdfTextEditor.disclaimer]
|
||||
alpha = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
|
||||
alpha = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
|
||||
heading = "Preview Limitations"
|
||||
previewVariance = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible."
|
||||
textFocus = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here."
|
||||
@@ -5719,7 +5512,7 @@ paragraph = "Paragraph page"
|
||||
sparse = "Sparse text"
|
||||
|
||||
[pdfTextEditor.tooltip.alpha]
|
||||
text = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
|
||||
text = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
|
||||
title = "Alpha Viewer"
|
||||
|
||||
[pdfTextEditor.tooltip.header]
|
||||
@@ -6776,6 +6569,51 @@ title = "High Contrast"
|
||||
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
||||
title = "Invert All Colours"
|
||||
|
||||
[workbenchBar]
|
||||
annotations = "Annotations"
|
||||
applyRedactionsFirst = "Apply redactions first"
|
||||
closeAll = "Close All Files"
|
||||
closePdf = "Close PDF"
|
||||
closeSelected = "Close Selected Files"
|
||||
deleteSelected = "Delete Selected Pages"
|
||||
deselectAll = "Deselect All"
|
||||
downloadAll = "Download All"
|
||||
downloadSelected = "Download Selected Files"
|
||||
draw = "Draw"
|
||||
exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
language = "Language"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
readAloud = "Read Aloud"
|
||||
readAloudLanguage = "Language"
|
||||
readAloudSpeed = "Speed"
|
||||
redact = "Redact"
|
||||
rotateLeft = "Rotate Left"
|
||||
rotateRight = "Rotate Right"
|
||||
ruler = "Ruler / Measure"
|
||||
save = "Save"
|
||||
saveAll = "Save All"
|
||||
saveAs = "Save As"
|
||||
saveChanges = "Save Changes"
|
||||
search = "Search PDF"
|
||||
selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
toggleComments = "Comments"
|
||||
toggleLayers = "Toggle Layers"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleTheme = "Toggle Theme"
|
||||
activeFiles = "Active Files"
|
||||
multiTool = "Multi-Tool"
|
||||
viewer = "Viewer"
|
||||
|
||||
[rotate]
|
||||
rotateLeft = "Rotate Anticlockwise"
|
||||
rotateRight = "Rotate Clockwise"
|
||||
@@ -6914,7 +6752,7 @@ useCase2 = "Split flatbed batches into separate files"
|
||||
useCase3 = "Break collages into individual photos"
|
||||
useCase4 = "Pull photos from documents"
|
||||
whatThisDoes = "What this does"
|
||||
whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image-no manual cropping."
|
||||
whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping."
|
||||
whenToUse = "When to use"
|
||||
|
||||
[search]
|
||||
@@ -7069,20 +6907,6 @@ auto = "Auto"
|
||||
fitPage = "Fit page"
|
||||
fitWidth = "Fit width"
|
||||
|
||||
[settings.help]
|
||||
label = "Tours"
|
||||
title = "Help"
|
||||
|
||||
[settings.help.adminTour]
|
||||
description = "Explore team management, system settings, and enterprise features."
|
||||
start = "Start"
|
||||
title = "Admin Tour"
|
||||
|
||||
[settings.help.toolsTour]
|
||||
description = "Walk through uploading files, picking a tool, and reviewing results."
|
||||
start = "Start"
|
||||
title = "Tools Tour"
|
||||
|
||||
[settings.hotkeys]
|
||||
capturing = "Press keys… (Esc to cancel)"
|
||||
change = "Change shortcut"
|
||||
@@ -7214,6 +7038,20 @@ title = "Team"
|
||||
enableLoginFirst = "Enable login mode first"
|
||||
requiresEnterprise = "Requires Enterprise license"
|
||||
|
||||
[settings.help]
|
||||
label = "Tours"
|
||||
title = "Help"
|
||||
|
||||
[settings.help.adminTour]
|
||||
description = "Explore team management, system settings, and enterprise features."
|
||||
start = "Start"
|
||||
title = "Admin Tour"
|
||||
|
||||
[settings.help.toolsTour]
|
||||
description = "Walk through uploading files, picking a tool, and reviewing results."
|
||||
start = "Start"
|
||||
title = "Tools Tour"
|
||||
|
||||
[settings.workspace]
|
||||
people = "People"
|
||||
teams = "Teams"
|
||||
@@ -8411,8 +8249,8 @@ csvStats = "{{rows}} rows · {{columns}} columns · {{size}}"
|
||||
emptyFile = "Empty file"
|
||||
fileTypeBadge = "{{type}} File"
|
||||
htmlPreview = "HTML preview"
|
||||
htmlPreviewWarning = "HTML preview - external resources may not load · {{size}}"
|
||||
invalidJson = "Invalid JSON - showing raw content"
|
||||
htmlPreviewWarning = "HTML preview — external resources may not load · {{size}}"
|
||||
invalidJson = "Invalid JSON — showing raw content"
|
||||
lineNumbers = "Line numbers"
|
||||
loading = "Loading..."
|
||||
renderMarkdown = "Render markdown"
|
||||
@@ -8622,50 +8460,6 @@ bullet3 = "Image will be resized to fit signature area"
|
||||
description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo."
|
||||
title = "Upload Signature Image"
|
||||
|
||||
[workbenchBar]
|
||||
activeFiles = "Active Files"
|
||||
annotations = "Annotations"
|
||||
applyRedactionsFirst = "Apply redactions first"
|
||||
closeAll = "Close All Files"
|
||||
closePdf = "Close PDF"
|
||||
closeSelected = "Close Selected Files"
|
||||
deleteSelected = "Delete Selected Pages"
|
||||
deselectAll = "Deselect All"
|
||||
downloadAll = "Download All"
|
||||
downloadSelected = "Download Selected Files"
|
||||
draw = "Draw"
|
||||
exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
language = "Language"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
readAloud = "Read Aloud"
|
||||
readAloudLanguage = "Language"
|
||||
readAloudSpeed = "Speed"
|
||||
redact = "Redact"
|
||||
rotateLeft = "Rotate Left"
|
||||
rotateRight = "Rotate Right"
|
||||
ruler = "Ruler / Measure"
|
||||
save = "Save"
|
||||
saveAll = "Save All"
|
||||
saveAs = "Save As"
|
||||
saveChanges = "Save Changes"
|
||||
search = "Search PDF"
|
||||
selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
toggleComments = "Comments"
|
||||
toggleLayers = "Toggle Layers"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleTheme = "Toggle Theme"
|
||||
viewer = "Viewer"
|
||||
|
||||
[workspace]
|
||||
title = "Workspace"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user