Compare commits

..
512 changed files with 4209 additions and 45754 deletions
+2 -3
View File
@@ -26,9 +26,8 @@ version_builds/
node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/dist-portal/
frontend/editor/playwright-report/
frontend/dist/
frontend/playwright-report/
.npm/
.yarn/
+1 -1
View File
@@ -24,7 +24,7 @@ runs:
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ inputs.app-id }}
app-id: ${{ inputs.app-id }}
private-key: ${{ inputs.private-key }}
- name: Configure Git
run: |
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.12.0
pkgver=2.11.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.12.0
pkgver=2.11.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+1 -2
View File
@@ -184,7 +184,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = Path.cwd() / "frontend" / "editor" / "public" / "locales"
base_dir = Path.cwd() / "frontend" / "public" / "locales"
for file_path in file_arr:
file_path = Path(file_path)
@@ -372,7 +372,6 @@ if __name__ == "__main__":
os.path.join(
os.getcwd(),
"frontend",
"editor",
"public",
"locales",
"*",
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Verify Tauri updater .sig files against plugins.updater.pubkey in tauri.conf.json.
Usage: verify-updater-signatures.py <dir-to-scan> [tauri.conf.json]
"""
import binascii
import sys
import json
import base64
import hashlib
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
ART_ROOT = Path(sys.argv[1])
CONF = Path(
sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json"
)
def load_pubkey():
# tauri pubkey = base64 of a minisign .pub file; last line is base64 of
# [2 algo][8 key-id][32 ed25519 public key].
raw = json.loads(CONF.read_text())["plugins"]["updater"]["pubkey"]
blob = base64.b64decode(base64.b64decode(raw).decode().splitlines()[-1])
return blob[2:10], Ed25519PublicKey.from_public_bytes(blob[10:])
def hash_file(path: Path) -> bytes:
h = hashlib.blake2b(digest_size=64)
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
return h.digest()
def verify(artifact: Path, sig_file: Path, keyid_pub, pub) -> str:
# tauri .sig = base64 of a minisign signature file (4 lines).
try:
lines = base64.b64decode(sig_file.read_text()).decode().splitlines()
sig_blob = base64.b64decode(lines[1])
except (binascii.Error, IndexError, UnicodeDecodeError) as e:
return f"FAIL malformed sig ({type(e).__name__})"
algo, keyid, sig = sig_blob[:2], sig_blob[2:10], sig_blob[10:74]
if keyid != keyid_pub:
return f"FAIL key-id mismatch (sig {keyid.hex()} vs pub {keyid_pub.hex()})"
# 'ED' = prehashed (BLAKE2b-512), 'Ed' = legacy (raw message).
msg = hash_file(artifact) if algo == b"ED" else artifact.read_bytes()
try:
pub.verify(sig, msg)
except InvalidSignature:
return f"FAIL signature invalid (algo={algo.decode()})"
# Global signature covers sig + trusted_comment.
gc = "global-sig FAIL"
try:
tc = lines[2].split("trusted comment: ", 1)[1]
pub.verify(base64.b64decode(lines[3]), sig + tc.encode())
gc = "global-sig OK"
except (InvalidSignature, IndexError, binascii.Error):
pass
return f"VALID (algo={algo.decode()}, keyid={keyid.hex()}, {gc})"
keyid_pub, pub = load_pubkey()
print(f"updater pubkey keyid={keyid_pub.hex()}\n")
sigs = sorted(ART_ROOT.rglob("*.sig"))
if not sigs:
print(f"WARN: no .sig files under {ART_ROOT} - nothing to verify")
sys.exit(0)
bad = 0
for sig_file in sigs:
artifact = sig_file.with_suffix("")
if not artifact.exists():
print(f" ? {sig_file.name}: artifact missing")
bad += 1
continue
res = verify(artifact, sig_file, keyid_pub, pub)
print(f" {artifact.name}: {res}")
if not res.startswith("VALID") or "global-sig FAIL" in res:
bad += 1
print(f"\n{'ALL SIGNATURES VALID' if bad == 0 else f'{bad} SIGNATURE(S) FAILED'}")
sys.exit(1 if bad else 0)
+7 -66
View File
@@ -1,14 +1,8 @@
name: Backend build, format check, and coverage
# Reusable workflow called from build.yml. Runs the backend build matrix
# (JDK 25 × every flavor), Spotless formatting check, JUnit, and
# (JDK 25 × spring-security on/off), Spotless formatting check, JUnit, and
# posts Jacoco coverage to PRs.
#
# Flavor axis (maps to STIRLING_FLAVOR in settings.gradle):
# core - DISABLE_ADDITIONAL_FEATURES=true, no proprietary, no saas
# proprietary - default build, no saas
# saas - proprietary + the saas subproject (build + JUnit only,
# never any runtime/integration testing)
on:
workflow_call:
@@ -31,7 +25,7 @@ jobs:
fail-fast: false
matrix:
jdk-version: [25]
flavor: [core, proprietary, saas]
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -64,10 +58,7 @@ jobs:
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Check Java formatting (Spotless)
# Runs once per matrix combination - pick the cheapest leg
# (core - no proprietary, no saas) so we don't wait for the
# heavier flavors just to fail formatting.
if: matrix.jdk-version == 25 && matrix.flavor == 'core'
if: matrix.jdk-version == 25 && matrix.spring-security == false
id: spotless-check
run: task backend:format:check
continue-on-error: true
@@ -152,24 +143,17 @@ jobs:
});
}
- name: Build with Gradle (flavor=${{ matrix.flavor }})
# STIRLING_FLAVOR is read by settings.gradle and expands into the
# right combination of DISABLE_ADDITIONAL_FEATURES + ENABLE_SAAS
# so we don't have to set them by hand. The saas flavor pulls in
# the app/saas subproject (unit tests only - no runtime tests).
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: task backend:build:ci
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
STIRLING_FLAVOR: ${{ matrix.flavor }}
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- name: Check Test Reports Exist
if: always()
run: |
# Common + core + proprietary always build (proprietary is
# excluded only at runtime, not from the gradle subproject
# graph). Saas builds add a fourth report dir.
declare -a dirs=(
"app/core/build/reports/tests/"
"app/core/build/test-results/"
@@ -178,9 +162,6 @@ jobs:
"app/proprietary/build/reports/tests/"
"app/proprietary/build/test-results/"
)
if [ "${{ matrix.flavor }}" = "saas" ]; then
dirs+=("app/saas/build/reports/tests/" "app/saas/build/test-results/")
fi
for dir in "${dirs[@]}"; do
if [ ! -d "$dir" ]; then
echo "Missing $dir"
@@ -192,7 +173,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-flavor-${{ matrix.flavor }}
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
app/**/build/reports/jacoco/test
app/**/build/reports/tests/
@@ -202,47 +183,7 @@ jobs:
retention-days: 3
if-no-files-found: warn
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml to
# silence security scanners that pattern-match on the stdlib
# xml.etree.ElementTree.parse call.
if: always() && matrix.flavor == 'saas'
run: python -m pip install --quiet defusedxml
- name: JaCoCo coverage step summary
# Only the saas leg posts the JUnit summary - it's a strict
# superset of the core + proprietary legs (same .exec files plus
# the saas subproject). Posting from all three would mean three
# near-identical tables crowding out the aggregate report.
if: always() && matrix.flavor == 'saas'
run: |
python scripts/coverage-summary.py \
--title "Backend JUnit coverage (JDK ${{ matrix.jdk-version }})" \
--jacoco "common=app/common/build/reports/jacoco/test/jacocoTestReport.xml" \
--jacoco "core=app/core/build/reports/jacoco/test/jacocoTestReport.xml" \
--jacoco "proprietary=app/proprietary/build/reports/jacoco/test/jacocoTestReport.xml" \
--jacoco "saas=app/saas/build/reports/jacoco/test/jacocoTestReport.xml" \
--github-step-summary
- name: Upload raw JUnit .exec for aggregate merge
# Same dedup rationale as the summary step: upload from the saas
# leg only (the most complete set, includes app/saas/.../test.exec)
# so the aggregate workflow merges the union rather than three
# overlapping subsets.
#
# Separate artifact from the HTML reports so the aggregate
# workflow can grab just the .exec files with a name pattern
# (`jacoco-exec-*`) instead of unpacking the whole test-reports
# tarball.
if: always() && matrix.flavor == 'saas'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-exec-junit-jdk-${{ matrix.jdk-version }}
path: app/*/build/jacoco/*.exec
retention-days: 7
if-no-files-found: warn
- name: Add coverage to PR (flavor=${{ matrix.flavor }}, JDK=${{ matrix.jdk-version }})
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
# The action only supports the pull_request event (it posts a PR comment),
# so skip it for merge_group runs and workflow_dispatch.
if: github.event_name == 'pull_request'
-18
View File
@@ -185,24 +185,6 @@ jobs:
uses: ./.github/workflows/dependency-review.yml
secrets: inherit
# Coverage aggregate: merges the JUnit + e2e:live + cucumber .exec
# artifacts produced by the jobs above into one report, plus pulls
# in vitest + Playwright frontend coverage for the per-area matrix.
# `if: always()` so a producer failing partway still gets credit
# for whatever did record. Advisory only - intentionally NOT in
# all-checks-passed, so a flaky aggregate run never blocks merging.
coverage-aggregate:
if: always()
needs:
- build
- playwright-e2e-live
- docker-compose-tests
- frontend-validation
permissions:
contents: read
uses: ./.github/workflows/coverage-aggregate.yml
secrets: inherit
# Single status check that branch protection should mark as required.
# Succeeds when every upstream job is either `success` or `skipped` (path-
# gated jobs that didn't apply this run). Any `failure` or `cancelled`
-230
View File
@@ -1,230 +0,0 @@
name: Aggregate backend coverage
# Reusable workflow called from build.yml after every backend coverage
# producer (JUnit, e2e:live, cucumber) has run. Downloads each job's raw
# .exec, merges them into one JaCoCo report, and posts a combined step
# summary alongside the per-source ones.
#
# Kept separate from the per-source jobs so:
# - the per-source jobs stay fast and independent (no cross-job waits)
# - this job can `if: always()` and still produce something useful when
# one of the producers fails partway through
# - frontend producers can be added later without touching the
# producers themselves
on:
workflow_call:
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
aggregate:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 15
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
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage scripts
# Both coverage-summary.py and coverage-matrix.py parse JaCoCo
# XML through defusedxml - see the script headers for context.
run: python -m pip install --quiet defusedxml
# Pattern matches every artifact this PR's producers might upload:
# jacoco-exec-junit-jdk-25 (uploaded only by the saas
# leg of backend-build, which
# is a strict superset of the
# core + proprietary legs)
# jacoco-exec-e2e-live
# jacoco-exec-cucumber
# Each lands as a sibling dir under coverage-execs/, with the .exec
# files preserving their original relative paths.
- name: Download all .exec artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
pattern: jacoco-exec-*
path: coverage-execs/
merge-multiple: false
continue-on-error: true
- name: Inventory .exec files
id: inventory
# Splits the downloaded artifacts into two buckets:
# * e2e-only = cucumber + Playwright live (user-flow coverage)
# * all = the above plus JUnit (everything we test)
#
# Bucketing is by artifact-name prefix: download-artifact preserves
# the artifact name as the top-level dir, so JUnit's `.exec`s live
# under coverage-execs/jacoco-exec-junit-*/... while the others
# are under coverage-execs/jacoco-exec-{e2e-live,cucumber}/...
#
# If nothing was uploaded (e.g. all producers crashed before
# writing) we exit gracefully so this advisory job never fails CI.
run: |
mapfile -t all_execs < <(find coverage-execs -name '*.exec' -type f | sort)
mapfile -t e2e_execs < <(find coverage-execs -name '*.exec' -type f -not -path '*/jacoco-exec-junit-*' | sort)
if [ "${#all_execs[@]}" -eq 0 ]; then
echo "::warning::No .exec artifacts found - skipping aggregate report"
echo "found_all=false" >> "$GITHUB_OUTPUT"
echo "found_e2e=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf 'All %d .exec files:\n' "${#all_execs[@]}"
printf ' %s\n' "${all_execs[@]}"
IFS=','; all_joined="${all_execs[*]}"
echo "files_all=$all_joined" >> "$GITHUB_OUTPUT"
echo "found_all=true" >> "$GITHUB_OUTPUT"
if [ "${#e2e_execs[@]}" -eq 0 ]; then
echo "::notice::No e2e/cucumber .exec files - e2e-only report will be skipped"
echo "found_e2e=false" >> "$GITHUB_OUTPUT"
else
printf 'E2E-only %d .exec files:\n' "${#e2e_execs[@]}"
printf ' %s\n' "${e2e_execs[@]}"
unset IFS
IFS=','; e2e_joined="${e2e_execs[*]}"
echo "files_e2e=$e2e_joined" >> "$GITHUB_OUTPUT"
echo "found_e2e=true" >> "$GITHUB_OUTPUT"
fi
- name: Compile classes for JaCoCo class lookup
# jacocoReportFromExec only needs the compiled .class files
# under each subproject's build/classes/java/main/. `classes`
# (compileJava + processResources) is enough; we skipped the
# heavier `assemble` to avoid building bootJar / fat jars that
# add 60+ seconds per run for no gain to the report.
if: steps.inventory.outputs.found_all == 'true'
run: ./gradlew classes -PnoSpotless
- name: Generate e2e-only JaCoCo report
# "Real user-flow" coverage: only counts code reached by an actual
# HTTP request from cucumber or live Playwright. Useful for
# questions like "how much of our backend does a user actually
# hit?". Skipped when neither producer uploaded a .exec.
if: steps.inventory.outputs.found_e2e == 'true'
run: |
./gradlew jacocoReportFromExec \
-PexecFile="${{ steps.inventory.outputs.files_e2e }}" \
-PreportDir=build/reports/jacoco/aggregate-e2e \
-PnoSpotless
- name: Generate combined JaCoCo report (everything)
if: steps.inventory.outputs.found_all == 'true'
run: |
./gradlew jacocoReportFromExec \
-PexecFile="${{ steps.inventory.outputs.files_all }}" \
-PreportDir=build/reports/jacoco/aggregate-all \
-PnoSpotless
- name: E2E-only step summary
# Rendered first so it gets prime real estate in the Summary
# tab - this is the number most readers actually want
# ("how much of the backend do real user flows cover?").
if: steps.inventory.outputs.found_e2e == 'true'
run: |
python scripts/coverage-summary.py \
--title "Real user-flow backend coverage (e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml" \
--github-step-summary
- name: ALL-sources step summary
# Separate call (not a multi-input one) because the helper's
# rightmost "Aggregate" column would sum the two reports - which
# is meaningless when one is a strict superset of the other.
if: steps.inventory.outputs.found_all == 'true'
run: |
python scripts/coverage-summary.py \
--title "Combined backend coverage (JUnit + e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-all/jacocoTestReport.xml" \
--github-step-summary
- name: Upload combined aggregate report
if: steps.inventory.outputs.found_all == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-aggregate-all-${{ github.run_id }}
path: build/reports/jacoco/aggregate-all/
retention-days: 14
- name: Upload e2e-only aggregate report
if: steps.inventory.outputs.found_e2e == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-aggregate-e2e-${{ github.run_id }}
path: build/reports/jacoco/aggregate-e2e/
retention-days: 14
# --------------------------------------------------------------
# Per-area matrix: rolls backend + frontend coverage into one
# table indexed by core/proprietary/saas/desktop. Pulls the
# frontend artifacts now (after the JaCoCo step has done its
# work) so the per-source backend summaries still render first
# even if the matrix step fails.
# --------------------------------------------------------------
- name: Download vitest coverage artifact
# frontend-validation uploads as `frontend-coverage`. Tolerate
# absence so a backend-only PR still produces the matrix with
# just backend rows populated.
if: always()
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: frontend-coverage
path: matrix-inputs/vitest/
continue-on-error: true
- name: Download Playwright frontend coverage artifact
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
# Same tolerance as vitest - matrix script handles missing inputs.
if: always()
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: playwright-frontend-coverage-${{ github.run_id }}
path: matrix-inputs/playwright/
continue-on-error: true
- name: Coverage matrix step summary
if: always()
# Matrix references the two aggregate JaCoCo XMLs (already
# generated above) plus whichever frontend artifacts landed.
# Every input is optional; missing ones render as "-".
run: |
python scripts/coverage-matrix.py \
${{ steps.inventory.outputs.found_all == 'true' && '--jacoco-all build/reports/jacoco/aggregate-all/jacocoTestReport.xml' || '' }} \
${{ steps.inventory.outputs.found_e2e == 'true' && '--jacoco-e2e build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml' || '' }} \
--vitest matrix-inputs/vitest/coverage-summary.json \
--playwright-frontend matrix-inputs/playwright/coverage-pw-summary/coverage-summary.json \
--title "Coverage matrix (per-area, e2e vs all)" \
--github-step-summary
@@ -87,12 +87,6 @@ jobs:
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
- name: Extract JaCoCo agent for cucumber coverage
# Stages build/jacoco/jacocoagent.jar where the coverage override
# file bind-mounts it into the cucumber container. The agent jar
# never goes into the published image - this is host-only.
run: ./gradlew copyJacocoAgent -PnoSpotless
- name: Run Docker Compose Tests
run: |
chmod +x ./testing/test_webpages.sh
@@ -104,62 +98,6 @@ jobs:
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
# Tells test.sh to layer testing/compose/docker-compose-coverage.override.yml
# over the cucumber compose so the container starts with the
# JaCoCo agent attached via JAVA_CUSTOM_OPTS.
STIRLING_PDF_TEST_COVERAGE: "1"
- name: Generate cucumber JaCoCo report
# `if: always()` so a behave failure still produces partial
# coverage from whatever endpoints did run. The exec file only
# exists when the container shut down cleanly - guard so the step
# is silent on the (rare) crash path.
if: always()
id: cucumber-coverage
run: |
if [ -s testing/cucumber-coverage/cucumber.exec ]; then
./gradlew jacocoReportFromExec \
-PexecFile=testing/cucumber-coverage/cucumber.exec \
-PreportDir=build/reports/jacoco/cucumber \
-PnoSpotless
echo "report=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::No cucumber .exec at testing/cucumber-coverage/cucumber.exec (container may have crashed before flushing)"
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml -
# see the script header for context.
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
- name: Cucumber coverage step summary
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
--title "Cucumber (docker) JaCoCo coverage" \
--jacoco "cucumber=build/reports/jacoco/cucumber/jacocoTestReport.xml" \
--github-step-summary
- name: Upload cucumber JaCoCo report
if: always() && steps.cucumber-coverage.outputs.report == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-cucumber-${{ github.run_id }}
path: build/reports/jacoco/cucumber/
retention-days: 7
- name: Upload raw cucumber .exec for aggregate merge
# Picked up by the coverage-aggregate workflow via the
# `jacoco-exec-*` artifact name pattern.
if: always() && steps.cucumber-coverage.outputs.report == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-exec-cucumber
path: testing/cucumber-coverage/cucumber.exec
retention-days: 7
if-no-files-found: warn
- name: Upload Cucumber Report
if: always()
+1 -124
View File
@@ -49,132 +49,9 @@ jobs:
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run live E2E tests (chromium) with coverage
- name: Run live E2E tests (chromium)
id: live-tests
env:
# Attaches the JaCoCo agent to the bootRun JVM (see
# .taskfiles/e2e.yml live:backend). The .exec gets flushed on
# graceful shutdown when the runner traps EXIT/INT/TERM, so the
# report step below sees a populated file.
COVERAGE: "1"
# Tells the Playwright fixture (test-base.ts) to capture per-test
# V8 JS coverage. Raw dumps land under
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
run: task e2e:live
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
# `if: always()` so even a failed test run still produces a
# report from whatever flows did exercise the backend before
# the failure. The task itself tolerates a missing .exec
# (jacoco emits an empty report rather than crashing) but we
# guard with `test -s` to keep the job log clean.
run: |
if [ -s .test-state/playwright/jacoco.exec ]; then
./gradlew jacocoReportFromExec \
-PexecFile=.test-state/playwright/jacoco.exec \
-PreportDir=build/reports/jacoco/e2e-live \
-PnoSpotless
echo "report=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::No e2e:live .exec found at .test-state/playwright/jacoco.exec; skipping report"
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python for coverage summary
if: always() && steps.live-coverage.outputs.report == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# coverage-summary.py uses defusedxml instead of stdlib xml.etree
# to dodge XXE / billion-laughs scanner findings.
if: always() && steps.live-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
- name: e2e:live coverage step summary
if: always() && steps.live-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
--title "Playwright (live backend) JaCoCo coverage" \
--jacoco "e2e-live=build/reports/jacoco/e2e-live/jacocoTestReport.xml" \
--github-step-summary
- name: Upload e2e:live JaCoCo report
if: always() && steps.live-coverage.outputs.report == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-e2e-live-${{ github.run_id }}
path: build/reports/jacoco/e2e-live/
retention-days: 7
- name: Upload raw e2e:live .exec for aggregate merge
# Picked up by the coverage-aggregate workflow via the
# `jacoco-exec-*` artifact name pattern.
if: always() && steps.live-coverage.outputs.report == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jacoco-exec-e2e-live
path: .test-state/playwright/jacoco.exec
retention-days: 7
if-no-files-found: warn
- name: Set up Python for frontend coverage summary
# Separate from the backend-coverage python step because the
# frontend path doesn't depend on a JaCoCo report - it produces
# a summary even on backend failure, as long as some Playwright
# tests ran far enough to dump V8 coverage.
if: always()
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install defusedxml for frontend coverage summary
# Idempotent re-install: the backend-coverage step may have
# installed it already, but this leg can run on its own when the
# backend report step skips (e.g. .exec missing).
if: always()
run: python -m pip install --quiet defusedxml
- name: Aggregate Playwright frontend (V8) coverage
# Rolls per-test V8 dumps from the test-base fixture into one
# vitest-shaped coverage-summary.json. Tolerates a missing dump
# dir (firefox/webkit runs, or a failure before any test got
# far enough to dump).
if: always()
id: pw-frontend-coverage
run: |
if [ -d .test-state/playwright/coverage-pw ] && \
find .test-state/playwright/coverage-pw -name '*.json' -type f | grep -q .; then
python scripts/playwright-coverage-summary.py \
.test-state/playwright/coverage-pw \
--out .test-state/playwright/coverage-pw-summary/coverage-summary.json
echo "summary=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::No Playwright frontend coverage dumps found (chromium-only feature)"
echo "summary=false" >> "$GITHUB_OUTPUT"
fi
- name: Playwright frontend coverage step summary
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
run: |
python scripts/coverage-summary.py \
--title "Playwright (live) frontend coverage" \
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
--github-step-summary
- name: Upload Playwright frontend coverage
# Bundle both the aggregated summary and the raw V8 dumps so
# someone debugging "why is this function showing as covered"
# can trace it back to the source dump.
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-frontend-coverage-${{ github.run_id }}
path: |
.test-state/playwright/coverage-pw-summary/
.test-state/playwright/coverage-pw/
retention-days: 7
- name: Print backend log on failure
if: failure() && steps.live-tests.conclusion == 'failure'
run: |
@@ -110,8 +110,8 @@ jobs:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
working-directory: frontend
run: |
mkdir -p editor/src/assets
npx --yes license-report --only=prod --output=json > editor/src/assets/3rdPartyLicenses.json
mkdir -p src/assets
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
- name: Postprocess with project script (BASE version)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
-35
View File
@@ -109,41 +109,6 @@ jobs:
comment_id: existing.id,
});
}
- name: Vitest coverage
# Separate from `frontend:check:all` so the quality-gate run stays
# uninstrumented (faster signal) and coverage stays an informational
# follow-up. Continue-on-error keeps the workflow green even when
# a handful of test files refuse to import (e.g. missing icon
# specifiers) - the summary still gets posted with whatever
# vitest managed to instrument.
id: frontend-coverage
continue-on-error: true
run: task frontend:test:coverage
- name: Set up Python for coverage summary
if: always()
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# See coverage-summary.py header - it parses XML through defusedxml
# to dodge the stdlib parser's exposure to XXE / billion-laughs.
if: always()
run: python -m pip install --quiet defusedxml
- name: Vitest coverage step summary
if: always()
run: |
python scripts/coverage-summary.py \
--title "Frontend Vitest coverage" \
--vitest frontend/editor/coverage/coverage-summary.json \
--github-step-summary
- name: Upload vitest coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-coverage
path: frontend/editor/coverage/
retention-days: 7
if-no-files-found: warn
- name: Upload frontend build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+10 -159
View File
@@ -442,6 +442,10 @@ jobs:
echo "Generated tauri.windows.conf.json (alias masked):"
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
- name: Sign JPDFium dylibs inside bootJar (macOS only)
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: bash frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
run: |
@@ -491,7 +495,6 @@ jobs:
projectPath: ./frontend/editor
tauriScript: npx tauri
args: ${{ matrix.args }}
updaterJsonKeepUniversal: true
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
@@ -588,32 +591,18 @@ jobs:
mkdir -p "$DIST"
cd ./frontend/editor/src-tauri/target
echo "=== tauri bundle artifacts ==="
find . -path "*/bundle/*" \( -name "*.msi" -o -name "*.deb" \
-o -name "*.rpm" -o -name "*.AppImage" -o -name "*.dmg" \
-o -name "*.app.tar.gz" -o -name "*.sig" \) 2>/dev/null | sort || true
echo "=============================="
# createUpdaterArtifacts:true signs the native installers in place;
# each <bundle> ships with a sibling <bundle>.sig consumed by latest.json.
# 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.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
# DMG = manual install; .app.tar.gz (+ .sig) = updater payload.
# Raw .app is intentionally not shipped (hundreds of MB of uncompressed input).
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app.tar.gz" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz" \;
find . -name "*.app.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz.sig" \;
find . -name "*.app" -exec cp -r {} "$DIST/Stirling-PDF-${{ matrix.name }}.app" \;
else
# The raw .AppImage IS its updater payload (signed -> .AppImage.sig),
# not a .tar.gz wrapper - that's only produced under v1Compatible.
find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.deb.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb.sig" \;
find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \;
find . -name "*.rpm.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm.sig" \;
find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \;
find . -name "*.AppImage.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.sig" \;
fi
- name: Upload build artifacts
@@ -624,7 +613,8 @@ jobs:
path: ./dist/*
retention-days: 1
collect-and-release:
create-release:
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
needs: [pick, determine-matrix, build, build-jars]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
@@ -635,16 +625,6 @@ jobs:
with:
egress-policy: audit
# Sparse-check out the verifier + pubkey before the artifact downloads
# so the checkout cannot clobber ./artifacts.
- name: Checkout updater verifier
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
.github/scripts/verify-updater-signatures.py
frontend/editor/src-tauri/tauri.conf.json
sparse-checkout-cone-mode: false
- name: Download all Tauri artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -672,146 +652,17 @@ jobs:
- name: Display structure of downloaded files
run: ls -R ./artifacts
# tauri-action only emits latest.json when it also publishes the release
# (tagName/releaseId set). We publish separately via action-gh-release,
# so build latest.json here from the per-platform .sig files.
- name: Generate updater latest.json
env:
VERSION: ${{ needs.determine-matrix.outputs.version }}
TAG: v${{ needs.determine-matrix.outputs.version }}
REPO: ${{ github.repository }}
run: |
python3 - << 'PYEOF'
import json, os, sys
from pathlib import Path
from datetime import datetime, timezone
VERSION = os.environ['VERSION']
TAG = os.environ['TAG']
REPO = os.environ['REPO']
ART = Path('./artifacts/tauri')
# Tauri updater looks up {os}-{arch}-{installer} (e.g. linux-x86_64-deb)
# before bare {os}-{arch}, so per-format Linux keys let deb/rpm/appimage
# each self-update from their matching file. macOS universal serves both
# arches from the one .app.tar.gz.
PLATFORM_MAP = [
{
'bundles': ['Stirling-PDF-linux-x86_64.deb'],
'targets': ['linux-x86_64-deb'],
},
{
'bundles': ['Stirling-PDF-linux-x86_64.rpm'],
'targets': ['linux-x86_64-rpm'],
},
{
'bundles': ['Stirling-PDF-linux-x86_64.AppImage'],
'targets': ['linux-x86_64-appimage'],
},
{
'bundles': ['Stirling-PDF-windows-x86_64.msi'],
'targets': ['windows-x86_64-msi', 'windows-x86_64'],
},
{
'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'],
'targets': ['darwin-x86_64', 'darwin-aarch64'],
},
]
# rglob() because download-artifact varies layout: one artifact -> flat,
# many -> nested under <artifact-name>/.
def find_signed(name):
for bundle_path in sorted(ART.rglob(name)):
sig_path = bundle_path.with_name(bundle_path.name + '.sig')
if sig_path.exists():
return bundle_path, sig_path
return None
platforms = {}
skipped = []
for entry in PLATFORM_MAP:
picked = None
for name in entry['bundles']:
picked = find_signed(name)
if picked:
break
if not picked:
skipped.append(
f"{entry['targets']} (no signed bundle among "
f"{entry['bundles']} - TAURI_SIGNING_PRIVATE_KEY unset "
f"or createUpdaterArtifacts disabled?)"
)
continue
bundle_path, sig_path = picked
signature = sig_path.read_text(encoding='utf-8').strip()
url = f"https://github.com/{REPO}/releases/download/{TAG}/{bundle_path.name}"
for target in entry['targets']:
platforms[target] = {'signature': signature, 'url': url}
print(f"Added {entry['targets']} from {bundle_path.name}")
if skipped:
print("Skipped platforms:")
for s in skipped:
print(f" - {s}")
if not platforms:
print(
"WARN: no signed updater bundles found - "
"skipping latest.json generation"
)
sys.exit(0)
manifest = {
'version': VERSION,
'notes': f"See https://github.com/{REPO}/releases/tag/{TAG}",
'pub_date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'platforms': platforms,
}
out = Path('./artifacts/latest.json')
out.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
print(f"Generated {out} with platforms: {sorted(platforms.keys())}")
PYEOF
- name: Upload merged artifacts for review
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: release-artifacts
path: ./artifacts/
retention-days: 7
# Gate publish on valid updater sigs. Runs after the review upload (so
# artifacts survive for debugging) and before action-gh-release.
- name: Verify updater signatures
run: |
python3 -m pip install --quiet 'cryptography==44.0.0'
python3 .github/scripts/verify-updater-signatures.py \
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
# workflow_dispatch path requires platform=='all' so a single-platform
# dispatch can't overwrite an existing release's full latest.json with a
# partial one (action-gh-release defaults overwrite_files:true).
# release / V2-master always build the full matrix so no extra guard needed.
# fail_on_unmatched_files makes a missing latest.json or installer fail loudly
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
fail_on_unmatched_files: true
# Installers + updater payloads + manifest. .sig contents are embedded
# in latest.json so the .sig files themselves are not uploaded.
files: |
./artifacts/**/*.jar
./artifacts/**/*.msi
./artifacts/**/*.dmg
./artifacts/**/*.app.tar.gz
./artifacts/**/*.deb
./artifacts/**/*.rpm
./artifacts/**/*.AppImage
./artifacts/latest.json
draft: false
prerelease: false
+4
View File
@@ -271,6 +271,10 @@ jobs:
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Sign JPDFium dylibs inside bootJar (macOS only)
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: bash frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15'
run: |
+3 -5
View File
@@ -57,8 +57,6 @@ app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/pdfjs/
app/core/src/main/resources/static/vendor/
app/core/src/main/resources/static/**/*.gz
app/core/src/main/resources/static/**/*.br
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle
@@ -214,7 +212,7 @@ out/
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
!frontend/editor/src/core/tests/test-fixtures/certs/**
!frontend/src/core/tests/test-fixtures/certs/**
# SSH Keys
*.pub
@@ -256,7 +254,7 @@ node_modules/
*compact*.json
test_batch.json
*.backup.*.json
frontend/editor/public/locales/*/translation.backup*.json
frontend/public/locales/*/translation.backup*.json
# Development/build artifacts
.gradle-cache/
@@ -281,4 +279,4 @@ docs/type3/signatures/
*.playwright-mcp.png
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/screenshots/
frontend/screenshots/
-5
View File
@@ -1,5 +0,0 @@
# PostHog project-level key — phc_ prefix keys are public/client-side by design
# (PostHog client-side tracking embeds them in the browser bundle). Committed
# intentionally in #6150 so engine/.env has a working default, with real
# credentials overridden via engine/.env.local.
engine/.env:generic-api-key:41
+1 -22
View File
@@ -133,29 +133,8 @@ tasks:
--no-header-files
--no-man-pages
--output runtime/jre
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
# staged copies are read-only too. On any subsequent incremental
# build the copier tries to overwrite them and fails with a bare
# `Permission denied (os error 13)` (Rust's io::Error Display drops
# the path, so the failure is opaque). Make the source writable here
# so the staged destinations are writable and can be overwritten.
#
# Trade-off: this task runs for both `task desktop:dev` and
# `task desktop:build`, so production bundles also ship mode-644
# JRE files instead of 444. Functionally harmless on POSIX (the
# `other` bit is `r--` either way, and on macOS code signing is the
# real integrity check) and on Windows the DOS read-only attribute
# isn't load-bearing for the bundled JDK. If we ever need strict
# 444 in production, split the chmod into a dev-only step and have
# `desktop:build` run `jlink:clean` first to force a fresh build.
- cmd: chmod -R u+w runtime/jre
platforms: [linux, darwin]
- cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }"
platforms: [windows]
status:
- test -f runtime/jre/release
- test -d editor/src-tauri/runtime/jre
jlink:clean:
desc: "Remove JLink runtime and bundled JARs"
+1 -11
View File
@@ -34,9 +34,6 @@ tasks:
ignore_error: true
vars:
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
# COVERAGE=1 in the calling environment attaches the JaCoCo agent to
# the bootRun JVM and writes to BASE_DIR/jacoco.exec on shutdown.
# Off by default to keep local dev runs uninstrumented; CI flips it.
env:
STIRLING_BASE_PATH: '{{.BASE_DIR}}'
# Suppress the analytics opt-in modal that fires on first admin login.
@@ -61,19 +58,12 @@ tasks:
set -e
rm -rf "{{.BASE_DIR}}"
mkdir -p "{{.BASE_DIR}}"
GRADLE_ARGS=":stirling-pdf:bootRun"
if [ -n "${COVERAGE:-}" ]; then
# copyJacocoAgent is wired as a dependency of bootRun when
# -PjacocoAgent=true, so we do not need to invoke it separately.
GRADLE_ARGS="$GRADLE_ARGS -PjacocoAgent=true -PjacocoExec={{.BASE_DIR}}/jacoco.exec"
echo "JaCoCo coverage enabled, writing to {{.BASE_DIR}}/jacoco.exec"
fi
# Background gradle and record its PID so the runner can clean up
# the exact process tree (wrapper + forked Spring Boot JVM) without
# resorting to fuzzy `pkill -f` patterns. `wait` keeps this script
# alive for the lifetime of gradle so Task'"'"'s parallel deps stay
# synchronised.
bash gradlew $GRADLE_ARGS > "{{.BASE_DIR}}/backend.log" 2>&1 &
bash gradlew :stirling-pdf:bootRun > "{{.BASE_DIR}}/backend.log" 2>&1 &
GRADLE_PID=$!
echo $GRADLE_PID > "{{.BASE_DIR}}/backend.pid"
wait $GRADLE_PID
+3 -72
View File
@@ -112,12 +112,6 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:portal:
desc: "Start developer portal dev server"
deps: [install]
cmds:
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
# ============================================================
# Build
# ============================================================
@@ -162,24 +156,6 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [install]
cmds:
- npx vite build portal
storybook:
desc: "Start Storybook dev server"
deps: [install]
cmds:
- npx storybook dev -p 6006 {{.CLI_ARGS}}
storybook:build:
desc: "Build static Storybook"
deps: [install]
cmds:
- npx storybook build {{.CLI_ARGS}}
# ============================================================
# Code quality
# ============================================================
@@ -187,23 +163,9 @@ tasks:
lint:
desc: "Run linting"
deps: [install]
cmds:
- task: lint:eslint
- task: lint:dpdm
lint:eslint:
desc: "Run ESLint linting"
deps: [install]
cmds:
- npx eslint --max-warnings=0
lint:dpdm:
desc: "Run circular import linting"
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers editor, portal, and the shared design system.
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
- npx dpdm editor/src --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -274,18 +236,6 @@ tasks:
cmds:
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- npx tsc --noEmit --project portal/tsconfig.json
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- npx tsc --noEmit --project shared/tsconfig.json
typecheck:all:
desc: "Typecheck all build variants"
cmds:
@@ -295,8 +245,6 @@ tasks:
- task: typecheck:desktop
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:shared
# ============================================================
# Quality Gate
@@ -317,9 +265,7 @@ tasks:
- task: lint
- task: format:check
- task: build
- task: build:portal
- task: test
- task: storybook:build
# ============================================================
# Test
@@ -338,25 +284,10 @@ tasks:
- npx vitest --watch --root editor
test:coverage:
desc: "Run tests with coverage (one-shot; CI-friendly)."
desc: "Run tests with coverage"
deps: [install]
cmds:
# `vitest run` makes this CI-safe (the bare `vitest` form enters watch
# mode). Explicit reporter list because v8 + json-summary is what the
# coverage-summary.py helper consumes; html/text are kept for humans.
#
# reportsDirectory is pinned to ./coverage relative to vitest's root
# (--root editor), so output lands at frontend/editor/coverage/. The
# CI upload step reads from that path. An earlier attempt with
# `./editor/coverage` double-nested into frontend/editor/editor/coverage;
# pinning future-proofs against vitest changing the default.
- >
npx vitest run --root editor --coverage
--coverage.provider=v8
--coverage.reporter=text-summary
--coverage.reporter=json-summary
--coverage.reporter=html
--coverage.reportsDirectory=./coverage
- npx vitest --coverage --root editor
# ============================================================
# Code Generation
+8 -10
View File
@@ -10,16 +10,14 @@ if that directory exists, is licensed under the license defined in "app/propriet
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
* All content that resides under the "engine/" directory of this repository,
if that directory exists, is licensed under the license defined in "engine/LICENSE".
* All content that resides under the "frontend/editor/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/proprietary/LICENSE".
* All content that resides under the "frontend/editor/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
* All content that resides under the "frontend/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
* All content that resides under the "frontend/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
* All content that resides under the "frontend/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/prototypes/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+2 -2
View File
@@ -60,7 +60,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
api 'com.stirling:jpdfium:1.0.2'
api 'com.stirling:jpdfium:1.0.1'
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
@@ -75,7 +75,7 @@ dependencies {
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
@@ -1,142 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import java.util.ArrayList;
import java.util.List;
/**
* Detects whether a page is one- or two-column from per-line bounding boxes, and classifies an
* X-span into the column it belongs to. Detection is a midpoint vote at {@code pageWidth / 2}.
*
* <p>Capped at two columns by design — sufficient for the redaction target set (single-column
* documents and IEEE-style two-column papers). 3+ column layouts (newspapers, magazines) and
* off-centre gutters (asymmetric two-column) would need a histogram or clustering approach to
* detect the actual gutter X. (future work)
*
* <p>Coordinates are PDFTextStripper screen space (top-left origin, Y increases downward).
*/
public final class PageColumnLayout {
/**
* Slack when checking "crosses a gutter" so single-pixel overshoots don't mark a line as
* spanning.
*/
public static final float SPAN_SLACK_PT = 2f;
/**
* Slack on each side of the page midpoint inside which a line is considered "spanning"
* (covering both columns) rather than belonging to one side.
*/
private static final float MIDPOINT_SLACK_PT = 30f;
/**
* Minimum line width (points) for a line to count toward the two-column tally. Avoids false
* positives where right-aligned dates, page numbers, or short "Link" fragments next to a
* heading look like a second column when they're really just inline metadata.
*/
private static final float MIN_COLUMN_LINE_WIDTH_PT = 100f;
/**
* Minimum number of clearly leftish AND clearly rightish lines (each of width &ge; {@link
* #MIN_COLUMN_LINE_WIDTH_PT}) required to call the page two-column. Anything below this falls
* back to single-column.
*/
private static final int MIN_SIDE_LINES = 3;
private final List<float[]> columns;
private final List<float[]> gutters;
private PageColumnLayout(List<float[]> columns, List<float[]> gutters) {
this.columns = columns;
this.gutters = gutters;
}
/**
* Determines column layout from per-line bounding boxes ({@code [x1, _, x2, _]}). Counts lines
* whose X-midpoint sits clearly left of, or clearly right of, the page midpoint (with {@link
* #MIDPOINT_SLACK_PT} slack each side). If both sides have at least {@link #MIN_SIDE_LINES}
* lines, the page is treated as two-column with the gutter at the page midpoint. Otherwise it's
* single-column.
*
* <p>Cross-column lines must already be split: callers should feed boxes from a line extractor
* that splits same-Y glyphs at large X gaps (see {@code AllTextLineExtractor}). Without that
* split, IEEE-style aligned-baseline 2-column PDFs produce one wide merged box per row and the
* side tallies all end up classified as "spanning", falling to single-column.
*/
public static PageColumnLayout fromLineBoxes(List<float[]> lineBoxes, float pageWidth) {
if (lineBoxes == null || lineBoxes.isEmpty()) {
return new PageColumnLayout(List.of(new float[] {0f, pageWidth}), List.of());
}
float pageMid = pageWidth / 2f;
int left = 0, right = 0;
for (float[] lb : lineBoxes) {
if (lb == null || lb.length < 3) continue;
float width = lb[2] - lb[0];
// Skip narrow lines — dates, page numbers, "Link" labels next to a heading should
// not, on their own, make a single-column doc look two-column.
if (width < MIN_COLUMN_LINE_WIDTH_PT) continue;
float mid = (lb[0] + lb[2]) * 0.5f;
if (mid < pageMid - MIDPOINT_SLACK_PT) left++;
else if (mid > pageMid + MIDPOINT_SLACK_PT) right++;
}
if (left < MIN_SIDE_LINES || right < MIN_SIDE_LINES) {
return new PageColumnLayout(List.of(new float[] {0f, pageWidth}), List.of());
}
float gutterL = pageMid - MIDPOINT_SLACK_PT;
float gutterR = pageMid + MIDPOINT_SLACK_PT;
return new PageColumnLayout(
List.of(new float[] {0f, gutterL}, new float[] {gutterR, pageWidth}),
List.of(new float[] {gutterL, gutterR}));
}
/** All columns, left-to-right, as {@code [leftX, rightX]} pairs. Never empty. */
public List<float[]> columns() {
return columns;
}
/** Gutters between columns, left-to-right, as {@code [leftX, rightX]} pairs. */
public List<float[]> gutters() {
return gutters;
}
public int columnCount() {
return columns.size();
}
/**
* Returns the column index containing the X-midpoint of {@code [x1, x2]}, falling back to the
* closest column if the midpoint sits inside a gutter.
*/
public int columnOf(float x1, float x2) {
float mid = (x1 + x2) * 0.5f;
int best = 0;
float bestDist = Float.MAX_VALUE;
for (int i = 0; i < columns.size(); i++) {
float[] c = columns.get(i);
if (mid >= c[0] && mid <= c[1]) return i;
float dist = mid < c[0] ? c[0] - mid : mid - c[1];
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
return best;
}
/**
* Returns every column index whose X-range overlaps {@code [x1, x2]} with at least {@link
* #SPAN_SLACK_PT} of intrusion. A normal in-column line returns one index; a line crossing a
* gutter returns two or more.
*/
public int[] columnsCrossing(float x1, float x2) {
List<Integer> hits = new ArrayList<>();
for (int i = 0; i < columns.size(); i++) {
float[] c = columns.get(i);
float overlap = Math.min(x2, c[1]) - Math.max(x1, c[0]);
if (overlap > SPAN_SLACK_PT) hits.add(i);
}
if (hits.isEmpty()) hits.add(columnOf(x1, x2));
int[] out = new int[hits.size()];
for (int i = 0; i < hits.size(); i++) out[i] = hits.get(i);
return out;
}
}
@@ -1,133 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import java.awt.geom.Point2D;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
import org.apache.pdfbox.util.Matrix;
/**
* PDFGraphicsStreamEngine that intercepts {@code drawImage} calls and records each image's bounding
* box in PDF user-space (origin bottom-left, Y up) by transforming the unit square through the
* current transformation matrix (CTM).
*
* <p>Usage:
*
* <pre>{@code
* PageImageLocator locator = new PageImageLocator(page, pageIndex);
* locator.processPage(page);
* List<ImageBox> boxes = locator.getImageBoxes();
* }</pre>
*
* <p>Each {@link ImageBox} carries the 0-based page index and the axis-aligned bounding box {@code
* (x1, y1, x2, y2)} in PDF user-space coordinates.
*/
public final class PageImageLocator extends PDFGraphicsStreamEngine {
/**
* Bounding box of a raster or vector image found on a PDF page.
*
* @param pageIndex 0-based page index
* @param x1 left edge in PDF user-space (origin bottom-left)
* @param y1 bottom edge in PDF user-space
* @param x2 right edge
* @param y2 top edge
*/
public record ImageBox(int pageIndex, float x1, float y1, float x2, float y2) {}
private final int pageIndex;
private final List<ImageBox> imageBoxes = new ArrayList<>();
private final Point2D.Float currentPoint = new Point2D.Float();
/**
* @param page the PDPage to process
* @param pageIndex 0-based index of this page in the document (stored on each returned {@link
* ImageBox})
*/
public PageImageLocator(PDPage page, int pageIndex) {
super(page);
this.pageIndex = pageIndex;
}
/** Returns all image bounding boxes collected during {@link #processPage}. */
public List<ImageBox> getImageBoxes() {
return imageBoxes;
}
@Override
public void drawImage(PDImage pdImage) throws IOException {
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
// An image occupies the unit square (0,0)→(1,1) in image space.
// Transform all four corners through the CTM to get the page-space bounding box.
float a = ctm.getScaleX();
float b = ctm.getShearY();
float c = ctm.getShearX();
float d = ctm.getScaleY();
float e = ctm.getTranslateX();
float f = ctm.getTranslateY();
float[] xs = {e, a + e, c + e, a + c + e};
float[] ys = {f, b + f, d + f, b + d + f};
float x1 = Float.MAX_VALUE, y1 = Float.MAX_VALUE;
float x2 = -Float.MAX_VALUE, y2 = -Float.MAX_VALUE;
for (float x : xs) {
x1 = Math.min(x1, x);
x2 = Math.max(x2, x);
}
for (float y : ys) {
y1 = Math.min(y1, y);
y2 = Math.max(y2, y);
}
imageBoxes.add(new ImageBox(pageIndex, x1, y1, x2, y2));
}
// ---------- required abstract methods (no-op for path operations) ----------
@Override
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) {}
@Override
public void clip(int windingRule) {}
@Override
public void moveTo(float x, float y) {
currentPoint.setLocation(x, y);
}
@Override
public void lineTo(float x, float y) {
currentPoint.setLocation(x, y);
}
@Override
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3) {
currentPoint.setLocation(x3, y3);
}
@Override
public Point2D getCurrentPoint() {
return currentPoint;
}
@Override
public void closePath() {}
@Override
public void endPath() {}
@Override
public void strokePath() {}
@Override
public void fillPath(int windingRule) {}
@Override
public void fillAndStrokePath(int windingRule) {}
@Override
public void shadingFill(COSName shadingName) {}
}
@@ -2,13 +2,7 @@ package stirling.software.common.cluster;
import java.time.Duration;
/**
* Token-bucket rate limiting backed by the cluster backplane.
*
* <p>In-process implementations enforce a per-JVM limit; distributed implementations enforce a
* single global limit across every node. Both use a Bucket4j greedy-refill token bucket so the
* semantics match across single-node and cluster deployments.
*/
/** Token-bucket rate limiting backed by the cluster backplane. */
public interface RateLimitStore {
/**
@@ -57,38 +57,85 @@ public class JobExecutorService {
this.resourceMonitor = resourceMonitor;
this.jobQueue = jobQueue;
// Parse session timeout and calculate effective timeout once during initialization
long sessionTimeoutMs = parseSessionTimeout(sessionTimeout);
this.effectiveTimeoutMs = Math.min(asyncRequestTimeoutMs, sessionTimeoutMs);
log.debug(
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/**
* Run a job either asynchronously or synchronously
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @return The response
*/
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
/**
* Run a job either asynchronously or synchronously with a custom timeout
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async, Supplier<Object> work, long customTimeoutMs) {
return runJobGeneric(async, work, customTimeoutMs, false, 50);
}
/**
* Run a job either asynchronously or synchronously with custom parameters
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @param queueable Whether this job can be queued when system resources are limited
* @param resourceWeight The resource weight of this job (1-100)
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async,
Supplier<Object> work,
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
// Generate base UUID
String baseJobId = UUID.randomUUID().toString();
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
// Store the scoped job ID in the request for potential use by other components
if (request != null) {
request.setAttribute("jobId", scopedJobKey);
// Also track this job ID in the user's session for authorization purposes
// This ensures users can only cancel their own jobs
if (request.getSession() != null) {
@SuppressWarnings("unchecked")
java.util.Set<String> userJobIds =
(java.util.Set<String>) request.getSession().getAttribute("userJobIds");
if (userJobIds == null) {
userJobIds = new java.util.concurrent.ConcurrentSkipListSet<>();
request.getSession().setAttribute("userJobIds", userJobIds);
}
userJobIds.add(scopedJobKey);
log.debug("Added scoped job ID {} to user session", scopedJobKey);
}
}
String jobId = scopedJobKey;
// Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
log.debug(
@@ -99,6 +146,7 @@ public class JobExecutorService {
queueable,
resourceWeight);
// Check if we need to queue this job based on resource availability
boolean shouldQueue =
queueable
&& async
@@ -106,6 +154,7 @@ public class JobExecutorService {
resourceMonitor.shouldQueueJob(resourceWeight);
if (shouldQueue) {
// Queue the job instead of executing immediately
log.debug(
"Queueing job {} due to resource constraints (weight: {})",
jobId,
@@ -113,12 +162,18 @@ public class JobExecutorService {
taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager
final String capturedJobIdForQueue = jobId;
Supplier<Object> wrappedWork =
() -> {
try {
// Set jobId in ThreadLocal context for the queued job
stirling.software.common.util.JobContext.setJobId(
capturedJobIdForQueue);
log.debug(
"Set jobId {} in JobContext for queued job execution",
capturedJobIdForQueue);
Object result = work.get();
processJobResult(capturedJobIdForQueue, result);
return result;
@@ -131,17 +186,21 @@ public class JobExecutorService {
taskManager.setError(capturedJobIdForQueue, e.getMessage());
throw e;
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
}
};
// Queue the job and get the future
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob(jobId, resourceWeight, wrappedWork, timeoutToUse);
// Return immediately with job ID
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
// Capture the jobId for the async thread
final String capturedJobId = jobId;
executor.execute(
@@ -152,7 +211,13 @@ public class JobExecutorService {
capturedJobId,
timeoutToUse);
// Set jobId in ThreadLocal context for the async thread
stirling.software.common.util.JobContext.setJobId(capturedJobId);
log.debug(
"Set jobId {} in JobContext for async execution",
capturedJobId);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(capturedJobId, result);
} catch (TimeoutException te) {
@@ -162,6 +227,7 @@ public class JobExecutorService {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
}
});
@@ -171,19 +237,27 @@ public class JobExecutorService {
try {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Make jobId available to downstream components on the worker thread
stirling.software.common.util.JobContext.setJobId(jobId);
log.debug("Set jobId {} in JobContext for sync execution", jobId);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
// If the result is already a ResponseEntity, return it directly
if (result instanceof ResponseEntity) {
return (ResponseEntity<?>) result;
}
// Process different result types
return handleResultForSyncJob(result);
} catch (TimeoutException te) {
log.error("Synchronous job timed out after {} ms", timeoutToUse);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
} catch (RuntimeException e) {
// Check if this is a typed exception that should be handled by
// GlobalExceptionHandler (either directly or wrapped)
Throwable cause = e.getCause();
if (e instanceof IllegalArgumentException
|| cause
@@ -193,13 +267,16 @@ public class JobExecutorService {
instanceof
stirling.software.common.util.ExceptionUtils
.BaseValidationException) {
// Rethrow so GlobalExceptionHandler can handle with proper HTTP status codes
throw e;
}
// Handle other RuntimeExceptions as generic errors
log.error("Error executing synchronous job: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
} catch (Exception e) {
log.error("Error executing synchronous job: {}", e.getMessage(), e);
// Construct a JSON error response
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
} finally {
@@ -208,13 +285,23 @@ public class JobExecutorService {
}
}
/**
* Process the result of an asynchronous job
*
* @param jobId The job ID
* @param result The result
*/
private void processJobResult(String jobId, Object result) {
try {
if (result instanceof byte[]) {
// Store byte array directly to disk to avoid double memory consumption
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
taskManager.setFileResult(
jobId, fileId, "result.pdf", MediaType.APPLICATION_PDF_VALUE);
log.debug("Stored byte[] result with fileId: {}", fileId);
// Let the byte array get collected naturally in the next GC cycle
// We don't need to force System.gc() which can be harmful
} else if (result instanceof ResponseEntity) {
ResponseEntity<?> response = (ResponseEntity<?>) result;
Object body = response.getBody();
@@ -243,13 +330,16 @@ public class JobExecutorService {
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<Resource> result with fileId: {}", fileId);
} else {
// Check if the response body contains a fileId
if (body != null && body.toString().contains("fileId")) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
body.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(body);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = MediaType.APPLICATION_PDF_VALUE;
@@ -289,6 +379,7 @@ public class JobExecutorService {
}
}
// Store generic result
taskManager.setResult(jobId, body);
}
} else if (result instanceof MultipartFile file) {
@@ -297,13 +388,16 @@ public class JobExecutorService {
jobId, fileId, file.getOriginalFilename(), file.getContentType());
log.debug("Stored MultipartFile result with fileId: {}", fileId);
} else {
// Check if result has a fileId field
if (result != null) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
result.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(result);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = MediaType.APPLICATION_PDF_VALUE;
@@ -341,6 +435,7 @@ public class JobExecutorService {
}
}
// Default case: store the result as is
taskManager.setResult(jobId, result);
}
@@ -351,8 +446,16 @@ public class JobExecutorService {
}
}
/**
* Handle different result types for synchronous jobs
*
* @param result The result object
* @return The appropriate ResponseEntity
* @throws IOException If there is an error processing the result
*/
private ResponseEntity<?> handleResultForSyncJob(Object result) throws IOException {
if (result instanceof byte[]) {
// Return byte array as PDF
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(
@@ -360,6 +463,7 @@ public class JobExecutorService {
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
.body(result);
} else if (result instanceof MultipartFile file) {
// Return MultipartFile content
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.getContentType()))
.header(
@@ -369,6 +473,7 @@ public class JobExecutorService {
+ "\"")
.body(file.getBytes());
} else {
// Default case: return as JSON
return ResponseEntity.ok(result);
}
}
@@ -388,9 +493,15 @@ public class JobExecutorService {
return mediaType != null ? mediaType.toString() : MediaType.APPLICATION_PDF_VALUE;
}
/**
* Parse session timeout string (e.g., "30m", "1h") to milliseconds
*
* @param timeout The timeout string
* @return The timeout in milliseconds
*/
private long parseSessionTimeout(String timeout) {
if (timeout == null || timeout.isEmpty()) {
return 30 * 60 * 1000;
return 30 * 60 * 1000; // Default: 30 minutes
}
try {
@@ -412,16 +523,27 @@ public class JobExecutorService {
case "m" -> (long) (numericValue * 60 * 1000);
case "h" -> (long) (numericValue * 60 * 60 * 1000);
case "d" -> (long) (numericValue * 24 * 60 * 60 * 1000);
default -> (long) (numericValue * 60 * 1000);
default -> (long) (numericValue * 60 * 1000); // Default to minutes
};
} catch (Exception e) {
log.warn("Could not parse session timeout '{}', using default", timeout);
return 30 * 60 * 1000;
return 30 * 60 * 1000; // Default: 30 minutes
}
}
/**
* Execute a supplier with a timeout
*
* @param supplier The supplier to execute
* @param timeoutMs The timeout in milliseconds
* @return The result from the supplier
* @throws TimeoutException If the execution times out
* @throws Exception If the supplier throws an exception
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
throws TimeoutException, Exception {
// Use the same executor as other async jobs for consistency
// This ensures all operations run on the same thread pool
String currentJobId = stirling.software.common.util.JobContext.getJobId();
java.util.concurrent.CompletableFuture<T> future =
@@ -455,10 +577,17 @@ public class JobExecutorService {
}
}
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
}
@@ -1,48 +0,0 @@
package stirling.software.common.util.propertyeditor;
import java.beans.PropertyEditorSupport;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Binds a multipart form value containing a JSON array into a typed {@code List<T>}. Used for
* endpoints that accept structured list parameters via {@code @ModelAttribute} — the form field
* carries the full JSON array as its value and the editor parses it once.
*/
@Slf4j
public class JsonListPropertyEditor<T> extends PropertyEditorSupport {
private static final ObjectMapper OBJECT_MAPPER =
JsonMapper.builder()
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
private final TypeReference<? extends List<T>> typeRef;
public JsonListPropertyEditor(TypeReference<? extends List<T>> typeRef) {
this.typeRef = typeRef;
}
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (text == null || text.trim().isEmpty()) {
setValue(new ArrayList<T>());
return;
}
try {
setValue(OBJECT_MAPPER.readValue(text, typeRef));
} catch (Exception e) {
log.error("Failed to parse JSON list value", e);
throw new IllegalArgumentException(
"Expected a JSON array but could not parse: " + e.getMessage());
}
}
}
@@ -1,41 +0,0 @@
package stirling.software.common.util.propertyeditor;
import java.beans.PropertyEditorSupport;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Binds a multipart form value containing a JSON object into a typed {@code T}. Companion to {@link
* JsonListPropertyEditor} for single-object nested fields on {@code @ModelAttribute} endpoints.
*/
@Slf4j
public class JsonObjectPropertyEditor<T> extends PropertyEditorSupport {
private static final ObjectMapper OBJECT_MAPPER =
JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
private final Class<T> type;
public JsonObjectPropertyEditor(Class<T> type) {
this.type = type;
}
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (text == null || text.trim().isEmpty()) {
setValue(null);
return;
}
try {
setValue(OBJECT_MAPPER.readValue(text, type));
} catch (Exception e) {
log.error("Failed to parse JSON object value", e);
throw new IllegalArgumentException(
"Expected a JSON object but could not parse: " + e.getMessage());
}
}
}
@@ -0,0 +1,53 @@
package stirling.software.common.util.propertyeditor;
import java.beans.PropertyEditorSupport;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Spring property editor that decodes a JSON string into a typed {@link ArrayList}. Used to bind
* complex list parameters (e.g. {@code List<RedactionArea>}, {@code List<EditTextOperation>}) from
* multipart form fields, where Spring's default binding cannot deserialize a JSON array.
*/
@Slf4j
public class StringToArrayListPropertyEditor<T> extends PropertyEditorSupport {
private final ObjectMapper objectMapper =
JsonMapper.builder()
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
private final Class<T> elementType;
public StringToArrayListPropertyEditor(Class<T> elementType) {
this.elementType = elementType;
}
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (text == null || text.trim().isEmpty()) {
setValue(new ArrayList<>());
return;
}
try {
JavaType listType =
objectMapper
.getTypeFactory()
.constructCollectionType(ArrayList.class, elementType);
List<T> list = objectMapper.readValue(text, listType);
setValue(list);
} catch (Exception e) {
log.error("Exception while converting {}", e);
throw new IllegalArgumentException(
"Failed to convert java.lang.String to java.util.List");
}
}
}
@@ -1,149 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link PageColumnLayout} gutter detection and column classification. */
class PageColumnLayoutTest {
private static final float PAGE_WIDTH = 612f; // Letter portrait
// ── single-column ────────────────────────────────────────────────────────────────────────────
@Test
void singleColumn_oneColumnNoGutters() {
List<float[]> lines = List.of(lineBox(72f, 396f));
PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
assertThat(layout.columnCount()).isEqualTo(1);
assertThat(layout.gutters()).isEmpty();
}
@Test
void singleColumn_classifyAnchor_returnsZero() {
PageColumnLayout layout =
PageColumnLayout.fromLineBoxes(List.of(lineBox(72f, 396f)), PAGE_WIDTH);
assertThat(layout.columnOf(100f, 200f)).isEqualTo(0);
}
// ── two-column ───────────────────────────────────────────────────────────────────────────────
@Test
void twoColumn_detectsGutter() {
PageColumnLayout layout =
PageColumnLayout.fromLineBoxes(buildTwoColumnLines(3), PAGE_WIDTH);
assertThat(layout.columnCount()).isEqualTo(2);
assertThat(layout.gutters()).hasSize(1);
float[] gutter = layout.gutters().get(0);
// Gutter is centered on pageWidth/2 with PageColumnLayout.MIDPOINT_SLACK_PT slack each
// side.
float pageMid = PAGE_WIDTH / 2f;
assertThat(gutter[0]).isBetween(pageMid - 40f, pageMid - 20f);
assertThat(gutter[1]).isBetween(pageMid + 20f, pageMid + 40f);
}
@Test
void twoColumn_classifyLeftAndRightAnchors() {
PageColumnLayout layout =
PageColumnLayout.fromLineBoxes(buildTwoColumnLines(3), PAGE_WIDTH);
assertThat(layout.columnOf(100f, 200f)).isEqualTo(0);
assertThat(layout.columnOf(380f, 460f)).isEqualTo(1);
}
@Test
void twoColumn_columnsCrossing_leftLineOnlyHitsLeft() {
PageColumnLayout layout =
PageColumnLayout.fromLineBoxes(buildTwoColumnLines(3), PAGE_WIDTH);
assertThat(layout.columnsCrossing(72f, 280f)).containsExactly(0);
assertThat(layout.columnsCrossing(320f, 540f)).containsExactly(1);
}
@Test
void twoColumn_spanningLine_returnsBothColumns() {
List<float[]> lines = new ArrayList<>(buildTwoColumnLines(3));
// Full-width header that crosses pageWidth/2.
lines.add(lineBox(72f, 396f));
PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
assertThat(layout.columnsCrossing(72f, 540f)).containsExactly(0, 1);
assertThat(layout.columnsCrossing(72f, 280f)).containsExactly(0);
}
private static List<float[]> buildTwoColumnLines(int rowsPerColumn) {
List<float[]> lines = new ArrayList<>();
for (int i = 0; i < rowsPerColumn; i++) {
lines.add(lineBox(72f, 136f)); // left column body (72..208)
lines.add(lineBox(320f, 220f)); // right column body (320..540)
}
return lines;
}
// ── three-column ─────────────────────────────────────────────────────────────────────────────
@Test
void threeColumn_collapsesToLeftRightSplit() {
// The midpoint-based detector splits the page at pageWidth/2 and treats anything else as
// single-column or spanning. A genuine 3-column layout collapses to 2 columns; the middle
// column's content ends up classified by midpoint as left or right of pageMid.
List<float[]> lines = new ArrayList<>();
for (int i = 0; i < 6; i++) {
lines.add(lineBox(72f, 150f)); // 72..222
lines.add(lineBox(252f, 150f)); // 252..402
lines.add(lineBox(432f, 150f)); // 432..582
}
PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
assertThat(layout.columnCount()).isEqualTo(2);
assertThat(layout.gutters()).hasSize(1);
}
// ── empty page ───────────────────────────────────────────────────────────────────────────────
@Test
void emptyPage_singleColumnFallback() {
PageColumnLayout layout = PageColumnLayout.fromLineBoxes(List.of(), PAGE_WIDTH);
assertThat(layout.columnCount()).isEqualTo(1);
assertThat(layout.gutters()).isEmpty();
}
@Test
void onlyShortFragments_singleColumnFallback() {
// Page numbers / decorations — too narrow to vote either side.
List<float[]> lines = List.of(lineBox(300f, 6f));
PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
assertThat(layout.columnCount()).isEqualTo(1);
}
// ── narrow gap should not be confused for a gutter ───────────────────────────────────────────
@Test
void narrowInternalGap_doesNotProduceGutter() {
// Both halves sit left of the page midpoint, so no line votes for a right column and
// detection falls back to single-column.
List<float[]> lines = new ArrayList<>();
lines.add(lineBox(72f, 100f));
lines.add(lineBox(180f, 100f));
for (int i = 0; i < 5; i++) {
lines.add(lineBox(72f, 208f));
}
PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
assertThat(layout.columnCount()).isEqualTo(1);
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
/** Builds a line bounding box {@code [x1, 0, x1+width, 0]}; Y is unused by detection. */
private static float[] lineBox(float x1, float width) {
return new float[] {x1, 0f, x1 + width, 0f};
}
}
@@ -24,9 +24,7 @@ class InProcessDistributedLockTest {
}
@Test
void reentryFromSameThreadFails_parityWithValkey() {
// The Valkey impl refuses reentry (SET NX semantics); the in-process impl must match,
// otherwise code working in single-instance silently breaks in cluster mode.
void reentryFromSameThreadFails() {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
Optional<DistributedLock.LockHandle> reentry = lock.tryAcquire("k", Duration.ofSeconds(30));
@@ -86,8 +86,6 @@ class TaskManagerJobStoreDelegationTest {
@Override
public boolean shouldRunLocalCleanup() {
// Distributed backplanes own job TTL eviction themselves; this mock
// mirrors the real ValkeyClusterBackplane override of the default true.
return false;
}
};
@@ -0,0 +1,163 @@
package stirling.software.common.util.propertyeditor;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.security.RedactionArea;
class StringToArrayListPropertyEditorTest {
private StringToArrayListPropertyEditor<RedactionArea> editor;
@BeforeEach
void setUp() {
editor = new StringToArrayListPropertyEditor<>(RedactionArea.class);
}
@Test
void testSetAsText_ValidJson() {
// Arrange
String json =
"[{\"x\":10.5,\"y\":20.5,\"width\":100.0,\"height\":50.0,\"page\":1,\"color\":\"#FF0000\"}]";
// Act
editor.setAsText(json);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertEquals(1, list.size(), "List should have 1 entry");
RedactionArea area = list.get(0);
assertEquals(10.5, area.getX(), "X should be 10.5");
assertEquals(20.5, area.getY(), "Y should be 20.5");
assertEquals(100.0, area.getWidth(), "Width should be 100.0");
assertEquals(50.0, area.getHeight(), "Height should be 50.0");
assertEquals(1, area.getPage(), "Page should be 1");
assertEquals("#FF0000", area.getColor(), "Color should be #FF0000");
}
@Test
void testSetAsText_MultipleItems() {
// Arrange
String json =
"["
+ "{\"x\":10.0,\"y\":20.0,\"width\":100.0,\"height\":50.0,\"page\":1,\"color\":\"#FF0000\"},"
+ "{\"x\":30.0,\"y\":40.0,\"width\":200.0,\"height\":150.0,\"page\":2,\"color\":\"#00FF00\"}"
+ "]";
// Act
editor.setAsText(json);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertEquals(2, list.size(), "List should have 2 entries");
RedactionArea area1 = list.get(0);
assertEquals(10.0, area1.getX(), "X should be 10.0");
assertEquals(20.0, area1.getY(), "Y should be 20.0");
assertEquals(1, area1.getPage(), "Page should be 1");
RedactionArea area2 = list.get(1);
assertEquals(30.0, area2.getX(), "X should be 30.0");
assertEquals(40.0, area2.getY(), "Y should be 40.0");
assertEquals(2, area2.getPage(), "Page should be 2");
}
@Test
void testSetAsText_EmptyString() {
// Arrange
String json = "";
// Act
editor.setAsText(json);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertTrue(list.isEmpty(), "List should be empty");
}
@Test
void testSetAsText_NullString() {
// Act
editor.setAsText(null);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertTrue(list.isEmpty(), "List should be empty");
}
@Test
void testSetAsText_SingleItemAsArray() {
// Arrange - note this is a single object, not an array
String json =
"{\"x\":10.0,\"y\":20.0,\"width\":100.0,\"height\":50.0,\"page\":1,\"color\":\"#FF0000\"}";
// Act
editor.setAsText(json);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertEquals(1, list.size(), "List should have 1 entry");
RedactionArea area = list.get(0);
assertEquals(10.0, area.getX(), "X should be 10.0");
assertEquals(20.0, area.getY(), "Y should be 20.0");
}
@Test
void testSetAsText_InvalidJson() {
// Arrange
String json = "invalid json";
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> editor.setAsText(json));
}
@Test
void testSetAsText_UnknownProperties() {
// Arrange - this JSON contains properties not in RedactionArea
// With FAIL_ON_UNKNOWN_PROPERTIES disabled, this should ignore the unknown properties
String json = "[{\"invalid\":\"structure\"}]";
// Act
editor.setAsText(json);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertEquals(1, list.size(), "List should have 1 entry (empty object)");
}
}
+1 -17
View File
@@ -207,29 +207,13 @@ def resourcesStaticDir = file('src/main/resources/static')
def generatedFrontendPaths = [
'assets',
'index.html',
'index.html.gz',
'index.html.br',
'sw.js',
'sw.js.gz',
'sw.js.br',
'manifest.json.gz',
'manifest.json.br',
'site.webmanifest.gz',
'site.webmanifest.br',
'browserconfig.xml.gz',
'browserconfig.xml.br',
'manifest-classic.json',
'manifest-classic.json.gz',
'manifest-classic.json.br',
'locales',
'Login',
'classic-logo',
'modern-logo',
'og_images',
'samples',
'pdfium',
'vendor',
'pdfjs'
'manifest-classic.json'
]
tasks.register('npmInstall', Exec) {
@@ -21,13 +21,6 @@ public class EndpointInterceptor implements HandlerInterceptor {
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String requestURI = request.getRequestURI();
// Prevent API responses from being stored by browsers or intermediary caches by default
String servletPath = request.getServletPath();
if (servletPath != null && servletPath.startsWith("/api/")) {
response.setHeader("Cache-Control", "private, no-store");
}
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
if (!isEnabled) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
@@ -1,8 +1,5 @@
package stirling.software.SPDF.config;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
@@ -13,7 +10,6 @@ import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.EncodedResourceResolver;
import lombok.RequiredArgsConstructor;
@@ -28,10 +24,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
private static final CacheControl NO_CACHE = CacheControl.noCache();
private static final CacheControl IMMUTABLE_ONE_YEAR =
CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable();
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
@@ -39,106 +31,37 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String staticPath =
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath();
// 1. Service worker and PWA metadata (never store)
// Browsers revalidate SW bytes anyway; no-store is the safest for atomic updates.
registry.addResourceHandler(
"/sw.js", "/manifest.json", "/site.webmanifest", "/browserconfig.xml")
.addResourceLocations(staticPath, "classpath:/static/")
.setCacheControl(CacheControl.noStore())
.resourceChain(true)
.addResolver(new EncodedResourceResolver());
// 2. Vite fingerprinted assets (immutable)
// These already have content hashes in filenames (e.g. index-ChAS4tCC.js)
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
// Check customFiles/static first, then fall back to classpath
registry.addResourceHandler("/assets/**")
.addResourceLocations(staticPath + "assets/", "classpath:/static/assets/")
.setCacheControl(IMMUTABLE_ONE_YEAR)
.resourceChain(true)
.addResolver(new EncodedResourceResolver());
// 3. Media and fonts (immutable)
registry.addResourceHandler("/images/**", "/fonts/**")
.addResourceLocations(
staticPath + "images/",
"classpath:/static/images/",
staticPath + "fonts/",
"classpath:/static/fonts/")
.setCacheControl(IMMUTABLE_ONE_YEAR)
.resourceChain(true)
.addResolver(new EncodedResourceResolver());
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath()
+ "assets/",
"classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// 4. Branding and stable non-fingerprinted assets (1 day + SWR)
// Use stale-while-revalidate to improve perceived performance.
registry.addResourceHandler(
"/favicon.*",
"/apple-touch-icon.png",
"/android-chrome-*.png",
"/mstile-*.png",
"/safari-pinned-tab.svg",
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
"/pdfium/**",
"/locales/**",
"/css/**",
"/js/**",
"/vendor/**",
"/samples/**",
"/og_images/**",
"/Login/**",
"/manifest-classic.json")
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
// Note: index.html is handled by ReactRoutingController for dynamic processing
registry.addResourceHandler("/index.html")
.addResourceLocations(
staticPath,
"classpath:/static/",
staticPath + "pdfjs/",
"classpath:/static/pdfjs/",
staticPath + "pdfjs-legacy/",
"classpath:/static/pdfjs-legacy/",
staticPath + "pdfium/",
"classpath:/static/pdfium/",
staticPath + "locales/",
"classpath:/static/locales/",
staticPath + "css/",
"classpath:/static/css/",
staticPath + "js/",
"classpath:/static/js/",
staticPath + "vendor/",
"classpath:/static/vendor/",
staticPath + "samples/",
"classpath:/static/samples/",
staticPath + "og_images/",
"classpath:/static/og_images/",
staticPath + "Login/",
"classpath:/static/Login/",
staticPath + "icons/",
"classpath:/static/icons/",
staticPath + "modern-logo/",
"classpath:/static/modern-logo/",
staticPath + "classic-logo/",
"classpath:/static/classic-logo/")
.setCacheControl(
CacheControl.maxAge(Duration.ofDays(1))
.cachePublic()
.staleWhileRevalidate(Duration.ofDays(7)))
.resourceChain(true)
.addResolver(new EncodedResourceResolver());
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
// 5. Catch-all (SPA fallback)
// Must check with server to ensure index.html is always fresh.
// Handle all other static resources (js, css, images, fonts, etc.)
// Check customFiles/static first for user overrides
registry.addResourceHandler("/**")
.addResourceLocations(staticPath, "classpath:/static/")
.setCacheControl(NO_CACHE)
.resourceChain(true)
.addResolver(new EncodedResourceResolver());
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
}
@Override
@@ -192,8 +115,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
applicationProperties.getSystem().getCorsAllowedOrigins());
// Combine user-configured origins with Tauri origins
List<String> allOrigins =
new ArrayList<>(applicationProperties.getSystem().getCorsAllowedOrigins());
java.util.List<String> allOrigins =
new java.util.ArrayList<>(
applicationProperties.getSystem().getCorsAllowedOrigins());
// Always include Tauri origins for desktop app compatibility
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
@@ -234,8 +158,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
} else {
// Default to allowing all origins when nothing is configured
logger.debug(
"No CORS allowed origins configured in settings.yml"
+ " (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
@@ -39,9 +39,7 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.common.util.propertyeditor.JsonListPropertyEditor;
import tools.jackson.core.type.TypeReference;
import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEditor;
/**
* Find/replace text editing for PDFs. Round-trips through {@link PdfJsonConversionService}: the
@@ -75,7 +73,7 @@ public class EditTextController {
binder.registerCustomEditor(
List.class,
"edits",
new JsonListPropertyEditor<>(new TypeReference<List<EditTextOperation>>() {}));
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
}
@AutoJobPostMapping(
@@ -119,30 +119,11 @@ public class ConfigController {
String localIp = GeneralUtils.getLocalNetworkIp();
if (localIp != null) {
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
}
return "";
}
/**
* The port the embedded server is actually listening on. With {@code server.port=0} (an
* ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value
* stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port}
* once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the
* real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT.
*/
// visible for testing
String resolveEffectiveServerPort(AppConfig appConfig) {
String configured = appConfig.getServerPort();
if (configured == null || "0".equals(configured.trim())) {
String actual = applicationContext.getEnvironment().getProperty("local.server.port");
if (actual != null && !actual.isBlank()) {
return actual;
}
}
return configured;
}
private static boolean isLoopbackHost(String host) {
return "localhost".equalsIgnoreCase(host)
|| "127.0.0.1".equals(host)
@@ -180,7 +161,7 @@ public class ConfigController {
// Note: Frontend expects "baseUrl" field name for compatibility
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", resolveEffectiveServerPort(appConfig));
configData.put("serverPort", appConfig.getServerPort());
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
@@ -1,127 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
/**
* PDFTextStripper subclass that collects all text positions and groups them into line-level
* bounding boxes.
*
* <p>Two outputs are maintained in parallel:
*
* <ul>
* <li>{@link #getLineBoxes()} returns {@code [x1, pdfYbottom, x2, pdfYtop]} in PDF user-space
* (origin bottom-left, Y up). This is what existing callers expect.
* <li>{@link #getScreenLineBoxes()} returns {@code [x1, screenYtop, x2, screenYbottom]} computed
* directly from glyph positions without a PDF↔screen round-trip — used by column-aware
* redaction where ulp-level drift in the round-trip caused false rejects against anchors.
* </ul>
*
* <p>Lines are flushed not only on Y jumps but also on large X gaps within the same Y row. That way
* left-column glyphs and right-column glyphs that happen to share a baseline (common in IEEE
* conference templates) get emitted as two distinct line boxes instead of one wide merged box.
*/
final class AllTextLineExtractor extends PDFTextStripper {
/** Min vertical jump (screen Y) before the next glyph is treated as a new line. */
private static final float LINE_Y_TOLERANCE = 3.0f;
/**
* Min horizontal gap (screen X) between consecutive glyphs on the same Y that indicates a
* column boundary. Chosen large enough to not split normal inter-word spacing (~610pt for 11pt
* text) but small enough to catch standard column gutters (typically ≥15pt).
*/
private static final float COLUMN_GAP_X = 14f;
private final float pageHeight;
private final List<float[]> lineBoxes = new ArrayList<>();
private final List<float[]> screenLineBoxes = new ArrayList<>();
private final List<TextPosition> currentLine = new ArrayList<>();
private float lastScreenY = Float.NaN;
private float lastGlyphRight = Float.NaN;
AllTextLineExtractor(int pageNumber, float pageHeight) throws IOException {
this.pageHeight = pageHeight;
setStartPage(pageNumber);
setEndPage(pageNumber);
setSortByPosition(true);
}
List<float[]> getLineBoxes() {
return lineBoxes;
}
/**
* Returns line boxes as {@code [x1, screenYtop, x2, screenYbottom]}. {@code screenYtop} is the
* minimum {@code TextPosition.getY() - getHeight()} on the line and {@code screenYbottom} is
* the maximum {@code getY()} (the line's baseline). These values come straight from PDFBox
* without going through {@code pageHeight - …}, so they're stable for ulp-sensitive comparisons
* against anchor screen Ys.
*/
List<float[]> getScreenLineBoxes() {
return screenLineBoxes;
}
@Override
protected void writeString(String text, List<TextPosition> positions) {
for (TextPosition tp : positions) {
// Skip whitespace-only positions (spaces, newline markers, indent characters).
// These have a TextPosition but no visible glyph; including them causes
// space-only "lines" to produce degenerate segments that appear as thin
// black bars after redaction.
String unicode = tp.getUnicode();
if (unicode == null || unicode.isBlank()) {
continue;
}
float screenY = tp.getY();
float screenX = tp.getX();
boolean yJump =
!Float.isNaN(lastScreenY) && Math.abs(screenY - lastScreenY) > LINE_Y_TOLERANCE;
boolean xJump =
!Float.isNaN(lastGlyphRight) && (screenX - lastGlyphRight) > COLUMN_GAP_X;
if (yJump || xJump) {
flushLine();
}
lastScreenY = screenY;
lastGlyphRight = screenX + tp.getWidth();
currentLine.add(tp);
}
}
@Override
protected void endPage(PDPage page) throws IOException {
flushLine();
super.endPage(page);
}
private void flushLine() {
if (currentLine.isEmpty()) {
return;
}
float minX = Float.MAX_VALUE, maxX = -Float.MAX_VALUE;
float minScreenY = Float.MAX_VALUE, maxScreenY = -Float.MAX_VALUE;
for (TextPosition tp : currentLine) {
minX = Math.min(minX, tp.getX());
maxX = Math.max(maxX, tp.getX() + tp.getWidth());
minScreenY = Math.min(minScreenY, tp.getY() - tp.getHeight());
maxScreenY = Math.max(maxScreenY, tp.getY());
}
emitSegment(minX, maxX, minScreenY, maxScreenY);
currentLine.clear();
lastScreenY = Float.NaN;
lastGlyphRight = Float.NaN;
}
private void emitSegment(float minX, float maxX, float minScreenY, float maxScreenY) {
float pdfY1 = pageHeight - maxScreenY; // bottom in PDF coords
float pdfY2 = pageHeight - minScreenY; // top in PDF coords
lineBoxes.add(new float[] {minX, pdfY1, maxX, pdfY2});
screenLineBoxes.add(new float[] {minX, minScreenY, maxX, maxScreenY});
}
}
@@ -1,403 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.awt.Color;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PDFText;
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
import stirling.software.SPDF.pdf.parser.PageImageLocator;
import stirling.software.common.model.api.security.RedactionArea;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@Service
@Slf4j
@RequiredArgsConstructor
class ManualRedactionService {
private static final float DEFAULT_TEXT_PADDING_MULTIPLIER = 0.6f;
private static final float REDACTION_WIDTH_REDUCTION_FACTOR = 0.9f;
private final TempFileManager tempFileManager;
// -----------------------------------------------------------------------
// Area and page redaction
// -----------------------------------------------------------------------
void redactAreas(List<RedactionArea> redactionAreas, PDDocument document, PDPageTree allPages)
throws IOException {
if (redactionAreas == null || redactionAreas.isEmpty()) {
return;
}
Map<Integer, List<RedactionArea>> redactionsByPage = new HashMap<>();
for (RedactionArea redactionArea : redactionAreas) {
if (redactionArea.getPage() == null
|| redactionArea.getPage() <= 0
|| redactionArea.getHeight() == null
|| redactionArea.getHeight() <= 0.0D
|| redactionArea.getWidth() == null
|| redactionArea.getWidth() <= 0.0D) {
continue;
}
redactionsByPage
.computeIfAbsent(redactionArea.getPage(), k -> new ArrayList<>())
.add(redactionArea);
}
for (Map.Entry<Integer, List<RedactionArea>> entry : redactionsByPage.entrySet()) {
Integer pageNumber = entry.getKey();
List<RedactionArea> areasForPage = entry.getValue();
if (pageNumber > allPages.getCount()) {
continue;
}
PDPage page = allPages.get(pageNumber - 1);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
contentStream.saveGraphicsState();
for (RedactionArea redactionArea : areasForPage) {
Color redactColor = decodeOrDefault(redactionArea.getColor());
contentStream.setNonStrokingColor(redactColor);
float x = redactionArea.getX().floatValue();
float y = redactionArea.getY().floatValue();
float width = redactionArea.getWidth().floatValue();
float height = redactionArea.getHeight().floatValue();
float pdfY = page.getBBox().getHeight() - y - height;
contentStream.addRect(x, pdfY, width, height);
contentStream.fill();
}
contentStream.restoreGraphicsState();
}
}
}
void redactPages(ManualRedactPdfRequest request, PDDocument document, PDPageTree allPages)
throws IOException {
Color redactColor = decodeOrDefault(request.getPageRedactionColor());
List<Integer> pageNumbers = getPageNumbers(request, allPages.getCount());
for (Integer pageNumber : pageNumbers) {
PDPage page = allPages.get(pageNumber);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
contentStream.setNonStrokingColor(redactColor);
PDRectangle box = page.getBBox();
contentStream.addRect(0, 0, box.getWidth(), box.getHeight());
contentStream.fill();
}
}
}
// -----------------------------------------------------------------------
// Overlay drawing
// -----------------------------------------------------------------------
void redactFoundText(
PDDocument document,
List<PDFText> blocks,
float customPadding,
Color redactColor,
boolean isTextRemovalMode)
throws IOException {
var allPages = document.getDocumentCatalog().getPages();
Map<Integer, List<PDFText>> blocksByPage = new HashMap<>();
for (PDFText block : blocks) {
blocksByPage.computeIfAbsent(block.getPageIndex(), k -> new ArrayList<>()).add(block);
}
for (Map.Entry<Integer, List<PDFText>> entry : blocksByPage.entrySet()) {
Integer pageIndex = entry.getKey();
List<PDFText> pageBlocks = entry.getValue();
if (pageIndex >= allPages.getCount()) {
continue;
}
var page = allPages.get(pageIndex);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
contentStream.saveGraphicsState();
try {
contentStream.setNonStrokingColor(redactColor);
PDRectangle pageBox = page.getBBox();
for (PDFText block : pageBlocks) {
float padding =
(block.getY2() - block.getY1()) * DEFAULT_TEXT_PADDING_MULTIPLIER
+ customPadding;
float originalWidth = block.getX2() - block.getX1();
float boxWidth;
float boxX;
if (isTextRemovalMode) {
boxWidth = originalWidth * REDACTION_WIDTH_REDUCTION_FACTOR;
float widthReduction = originalWidth - boxWidth;
boxX = block.getX1() + (widthReduction / 2);
} else {
boxWidth = originalWidth;
boxX = block.getX1();
}
contentStream.addRect(
boxX,
pageBox.getHeight() - block.getY2() - padding,
boxWidth,
block.getY2() - block.getY1() + 2 * padding);
}
contentStream.fill();
} finally {
contentStream.restoreGraphicsState();
}
}
// Remove annotations whose bounding rect overlaps a redacted block, to prevent
// users from hovering over redacted URLs and seeing the underlying destination.
try {
float pageH = page.getBBox().getHeight();
List<PDAnnotation> kept = new ArrayList<>();
for (PDAnnotation ann : page.getAnnotations()) {
PDRectangle ar = ann.getRectangle();
boolean overlaps = false;
if (ar != null) {
for (PDFText block : pageBlocks) {
float padding =
(block.getY2() - block.getY1())
* DEFAULT_TEXT_PADDING_MULTIPLIER
+ customPadding;
float bx1 = block.getX1();
float bx2 = block.getX2();
float by1 = pageH - block.getY2() - padding;
float by2 = pageH - block.getY1() + padding;
if (ar.getLowerLeftX() < bx2
&& ar.getUpperRightX() > bx1
&& ar.getLowerLeftY() < by2
&& ar.getUpperRightY() > by1) {
overlaps = true;
break;
}
}
}
if (!overlaps) {
kept.add(ann);
}
}
page.setAnnotations(kept);
} catch (Exception e) {
log.debug(
"[redact] could not remove annotations on page {}: {}",
pageIndex,
e.getMessage());
}
}
}
void redactImageBoxes(PDDocument document, List<float[]> imageBoxes, Color color)
throws IOException {
Map<Integer, List<float[]>> byPage = new HashMap<>();
for (float[] box : imageBoxes) {
byPage.computeIfAbsent((int) box[0], k -> new ArrayList<>()).add(box);
}
PDPageTree pages = document.getDocumentCatalog().getPages();
for (Map.Entry<Integer, List<float[]>> entry : byPage.entrySet()) {
int pageIdx = entry.getKey();
if (pageIdx < 0 || pageIdx >= pages.getCount()) {
log.warn("[redact/execute] image box references out-of-range page {}", pageIdx);
continue;
}
PDPage page = pages.get(pageIdx);
try (PDPageContentStream cs =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.saveGraphicsState();
cs.setNonStrokingColor(color);
for (float[] box : entry.getValue()) {
float x1 = box[1], y1 = box[2], x2 = box[3], y2 = box[4];
cs.addRect(x1, y1, x2 - x1, y2 - y1);
}
cs.fill();
cs.restoreGraphicsState();
}
}
}
// -----------------------------------------------------------------------
// Page element extraction
// -----------------------------------------------------------------------
/**
* Returns bounding boxes for every text line and image on {@code page} in PDF user-space
* coordinates: {@code [x1, y1, x2, y2]} (origin bottom-left, Y increases upward).
*/
List<float[]> extractPageElementBoxes(PDDocument document, PDPage page, int pageIndex)
throws IOException {
List<float[]> boxes = new ArrayList<>();
AllTextLineExtractor textExtractor =
new AllTextLineExtractor(pageIndex + 1, page.getBBox().getHeight());
textExtractor.getText(document);
boxes.addAll(textExtractor.getLineBoxes());
PageImageLocator imgLocator = new PageImageLocator(page, pageIndex);
imgLocator.processPage(page);
for (PageImageLocator.ImageBox imgBox : imgLocator.getImageBoxes()) {
boxes.add(new float[] {imgBox.x1(), imgBox.y1(), imgBox.x2(), imgBox.y2()});
}
return boxes;
}
// -----------------------------------------------------------------------
// Finalization
// -----------------------------------------------------------------------
TempFile finalizeRedaction(
PDDocument document,
Map<Integer, List<PDFText>> allFoundTextsByPage,
String colorString,
float customPadding,
Boolean convertToImage,
boolean isTextRemovalMode)
throws IOException {
List<PDFText> allFoundTexts = new ArrayList<>();
for (List<PDFText> pageTexts : allFoundTextsByPage.values()) {
allFoundTexts.addAll(pageTexts);
}
if (!allFoundTexts.isEmpty()) {
Color redactColor = decodeOrDefault(colorString);
redactFoundText(document, allFoundTexts, customPadding, redactColor, isTextRemovalMode);
cleanDocumentMetadata(document);
}
if (Boolean.TRUE.equals(convertToImage)) {
try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) {
cleanDocumentMetadata(convertedPdf);
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try {
convertedPdf.save(tempOut.getFile());
} catch (IOException e) {
tempOut.close();
throw e;
}
log.info(
"Redaction finalized (image mode): {} pages ➜ {} KB",
convertedPdf.getNumberOfPages(),
tempOut.getFile().length() / 1024);
return tempOut;
}
}
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try {
document.save(tempOut.getFile());
} catch (IOException e) {
tempOut.close();
throw e;
}
log.info(
"Redaction finalized: {} pages ➜ {} KB",
document.getNumberOfPages(),
tempOut.getFile().length() / 1024);
return tempOut;
}
private void cleanDocumentMetadata(PDDocument document) {
try {
var documentInfo = document.getDocumentInformation();
if (documentInfo != null) {
documentInfo.setAuthor(null);
documentInfo.setSubject(null);
documentInfo.setKeywords(null);
documentInfo.setModificationDate(java.util.Calendar.getInstance());
log.debug("Cleaned document metadata for security");
}
if (document.getDocumentCatalog() != null) {
try {
document.getDocumentCatalog().setMetadata(null);
} catch (Exception e) {
log.debug("Could not clear XMP metadata: {}", e.getMessage());
}
}
} catch (Exception e) {
log.warn("Failed to clean document metadata: {}", e.getMessage());
}
}
// -----------------------------------------------------------------------
// Utilities
// -----------------------------------------------------------------------
static Color decodeOrDefault(String hex) {
if (hex == null) {
return Color.BLACK;
}
String colorString = hex.startsWith("#") ? hex : "#" + hex;
try {
return Color.decode(colorString);
} catch (NumberFormatException e) {
return Color.BLACK;
}
}
private List<Integer> getPageNumbers(ManualRedactPdfRequest request, int pagesCount) {
String pageNumbersInput = request.getPageNumbers();
String[] parsedPageNumbers =
pageNumbersInput != null ? pageNumbersInput.split(",") : new String[0];
List<Integer> pageNumbers =
GeneralUtils.parsePageList(parsedPageNumbers, pagesCount, false);
Collections.sort(pageNumbers);
return pageNumbers;
}
}
@@ -1,174 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PDFText;
/**
* Scans a PDF document once and matches all provided patterns in a single pass, collecting
* bounding-box positions for every match. Use in place of creating one {@code TextFinder} per
* search term to avoid O(n) full-document scans.
*/
@Slf4j
final class MultiPatternTextFinder extends PDFTextStripper {
private static final long REGEX_MATCH_TIMEOUT_SECONDS = 30;
private static final ExecutorService REGEX_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
private final List<Pattern> patterns;
private final Map<Integer, List<PDFText>> foundTextsByPage = new HashMap<>();
private final List<TextPosition> pageTextPositions = new ArrayList<>();
private final StringBuilder pageTextBuilder = new StringBuilder();
MultiPatternTextFinder(List<Pattern> patterns) throws IOException {
this.patterns = patterns;
this.setWordSeparator(" ");
this.setLineSeparator("\n");
}
Map<Integer, List<PDFText>> getFoundTextsByPage() {
return foundTextsByPage;
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
pageTextPositions.clear();
pageTextBuilder.setLength(0);
}
@Override
protected void writeString(String text, List<TextPosition> textPositions) {
pageTextBuilder.append(text);
pageTextPositions.addAll(textPositions);
}
@Override
protected void writeWordSeparator() {
pageTextBuilder.append(getWordSeparator());
pageTextPositions.add(null);
}
@Override
protected void writeLineSeparator() {
pageTextBuilder.append(getLineSeparator());
pageTextPositions.add(null);
}
@Override
protected void endPage(PDPage page) throws IOException {
String text = pageTextBuilder.toString();
if (!text.isEmpty()) {
int pageIndex = getCurrentPageNo() - 1;
for (Pattern pattern : patterns) {
Matcher matcher = pattern.matcher(text);
while (safeFind(matcher)) {
PDFText pdfText = resolveMatchPosition(matcher, pageIndex);
if (pdfText != null) {
foundTextsByPage
.computeIfAbsent(pageIndex, k -> new ArrayList<>())
.add(pdfText);
}
}
}
}
super.endPage(page);
}
/**
* Wraps a single {@code matcher.find()} call with a {@value #REGEX_MATCH_TIMEOUT_SECONDS}
* second timeout. Prevents pathological regex backtracking from blocking the request
* indefinitely; per-match timeout so fast legitimate scans are unaffected.
*/
private static boolean safeFind(Matcher matcher) throws IOException {
Future<Boolean> future =
REGEX_EXECUTOR.submit((java.util.concurrent.Callable<Boolean>) matcher::find);
try {
return future.get(REGEX_MATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new IOException(
"Regex match timed out after "
+ REGEX_MATCH_TIMEOUT_SECONDS
+ "s — pattern may cause catastrophic backtracking");
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new IOException("Regex match interrupted", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException ioEx) throw ioEx;
throw new IOException("Regex match failed: " + cause.getMessage(), cause);
}
}
private PDFText resolveMatchPosition(Matcher matcher, int pageIndex) {
int matchStart = matcher.start();
int matchEnd = matcher.end();
float minX = Float.MAX_VALUE;
float minY = Float.MAX_VALUE;
float maxX = Float.MIN_VALUE;
float maxY = Float.MIN_VALUE;
boolean foundPosition = false;
for (int i = matchStart; i < matchEnd; i++) {
if (i >= pageTextPositions.size()) break;
TextPosition pos = pageTextPositions.get(i);
if (pos != null) {
foundPosition = true;
minX = Math.min(minX, pos.getX());
maxX = Math.max(maxX, pos.getX() + pos.getWidth());
minY = Math.min(minY, pos.getY() - pos.getHeight());
maxY = Math.max(maxY, pos.getY());
}
}
if (!foundPosition && matchStart < pageTextPositions.size()) {
for (int i = Math.max(0, matchStart - 5);
i < Math.min(pageTextPositions.size(), matchEnd + 5);
i++) {
TextPosition pos = pageTextPositions.get(i);
if (pos != null) {
foundPosition = true;
minX = Math.min(minX, pos.getX());
maxX = Math.max(maxX, pos.getX() + pos.getWidth());
minY = Math.min(minY, pos.getY() - pos.getHeight());
maxY = Math.max(maxY, pos.getY());
break;
}
}
}
if (!foundPosition) {
log.warn(
"Found text match '{}' but no valid position data at {}-{}",
matcher.group(),
matchStart,
matchEnd);
return null;
}
return new PDFText(pageIndex, minX, minY, maxX, maxY, matcher.group());
}
}
@@ -1,850 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.awt.Color;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PDFText;
import stirling.software.SPDF.model.api.security.RedactExecuteRequest;
import stirling.software.SPDF.model.api.security.RedactExecuteRequest.ImageBox;
import stirling.software.SPDF.model.api.security.RedactExecuteRequest.RedactStyle;
import stirling.software.SPDF.model.api.security.RedactExecuteRequest.TextRange;
import stirling.software.SPDF.pdf.parser.PageColumnLayout;
import stirling.software.SPDF.pdf.parser.PageImageLocator;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
@Service
@Slf4j
@RequiredArgsConstructor
class RedactExecuteService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ManualRedactionService manualRedactionService;
private final TextRedactionService textRedactionService;
TempFile execute(RedactExecuteRequest request) throws IOException {
RedactStyle style = request.getStyle() != null ? request.getStyle() : new RedactStyle();
List<String> textValues = orEmpty(request.getTextValues());
List<String> regexPatterns = orEmpty(request.getRegexPatterns());
List<Integer> wipePages = orEmpty(request.getWipePages());
List<TextRange> ranges = orEmpty(request.getRanges());
List<ImageBox> imageBoxes = orEmpty(request.getImageBoxes());
boolean hasTargets =
!textValues.isEmpty()
|| !regexPatterns.isEmpty()
|| !wipePages.isEmpty()
|| !ranges.isEmpty()
|| !imageBoxes.isEmpty()
|| request.getRedactImagePages() != null;
if (!hasTargets) {
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.targets", "No redaction targets provided");
}
boolean overlayOnly =
RedactExecuteRequest.RedactionStrategy.OVERLAY_ONLY.equals(style.getStrategy());
boolean imageFinalize =
RedactExecuteRequest.RedactionStrategy.IMAGE_FINALIZE.equals(style.getStrategy());
boolean convertToImage = imageFinalize || style.isConvertToImage();
boolean hasTextOps = !textValues.isEmpty() || !regexPatterns.isEmpty();
log.info(
"[redact/execute] strategy={} textValues={} regexPatterns={} wipePages={} ranges={} imageBoxes={} imagePages={}",
style.getStrategy(),
textValues.size(),
regexPatterns.size(),
wipePages.size(),
ranges.size(),
imageBoxes.size(),
request.getRedactImagePages());
if (request.getFileInput() == null) {
throw ExceptionUtils.createFileNullOrEmptyException();
}
PDDocument document = null;
try {
document = pdfDocumentFactory.load(request.getFileInput());
// Single-pass text scan: collect all text-based targets so we run the PDF
// stripper only once across the entire execute() call rather than once per target.
Map<Integer, List<PDFText>> foundTexts =
hasTextOps ? collectTextMatches(document, request) : new HashMap<>();
int totalMatches = foundTexts.values().stream().mapToInt(List::size).sum();
log.info(
"[redact/execute] scan complete: {} text matches across {} pages",
totalMatches,
foundTexts.size());
// Text removal (content-stream rewriting) — skipped in overlay-only mode.
boolean needsOverlayOnly = overlayOnly;
if (hasTextOps && !foundTexts.isEmpty() && !overlayOnly) {
needsOverlayOnly = applyTextRemoval(document, request);
} else if (overlayOnly) {
log.info(
"[redact/execute] overlay-only mode requested — skipping content-stream rewriting");
}
// Reload fresh document on fallback so we overlay onto clean content.
if (needsOverlayOnly && !foundTexts.isEmpty()) {
log.info("[redact/execute] reloading document for clean overlay pass");
document.close();
document = pdfDocumentFactory.load(request.getFileInput());
foundTexts.clear();
if (hasTextOps) {
foundTexts.putAll(collectTextMatches(document, request));
}
}
// Non-text operations.
Map<Integer, PageColumnLayout> layoutCache = new HashMap<>();
if (!wipePages.isEmpty()) {
applyPageWipe(document, wipePages, style);
}
for (TextRange range : ranges) {
applyRangeRedaction(document, range, style, layoutCache);
}
for (ImageBox box : imageBoxes) {
applyImageBoxRedaction(document, box, style);
}
if (request.getRedactImagePages() != null) {
applyAllImagesRedaction(document, request.getRedactImagePages(), style);
}
return manualRedactionService.finalizeRedaction(
document,
foundTexts,
style.getColor(),
style.getPadding(),
convertToImage,
!needsOverlayOnly);
} catch (Exception e) {
log.error("Execute redaction failed: {}", e.getMessage(), e);
throw new RuntimeException("Failed to perform PDF redaction: " + e.getMessage(), e);
} finally {
if (document != null) {
try {
document.close();
} catch (IOException e) {
log.warn("Failed to close document: {}", e.getMessage());
}
}
}
}
// -----------------------------------------------------------------------
// Single-pass text scan (one stripper pass per execute() call)
// -----------------------------------------------------------------------
/**
* Runs a single PDF text-stripper pass over all text-based targets and returns the merged hit
* map.
*/
private Map<Integer, List<PDFText>> collectTextMatches(
PDDocument document, RedactExecuteRequest request) {
Map<Integer, List<PDFText>> found = new HashMap<>();
String[] terms = cleanStrings(request.getTextValues());
if (terms.length > 0) {
textRedactionService
.findTextToRedact(document, terms, false, false)
.forEach(
(page, hits) ->
found.computeIfAbsent(page, k -> new ArrayList<>())
.addAll(hits));
}
String[] patterns = cleanStrings(request.getRegexPatterns());
if (patterns.length > 0) {
textRedactionService
.findTextToRedact(document, patterns, true, false)
.forEach(
(page, hits) ->
found.computeIfAbsent(page, k -> new ArrayList<>())
.addAll(hits));
}
return found;
}
// -----------------------------------------------------------------------
// Text removal (content-stream rewriting)
// -----------------------------------------------------------------------
/**
* Attempts content-stream text removal for all text/regex targets. Returns {@code true} if the
* document fell back to overlay-only mode.
*/
private boolean applyTextRemoval(PDDocument document, RedactExecuteRequest request) {
try {
boolean fallback = false;
String[] terms = cleanStrings(request.getTextValues());
if (terms.length > 0) {
Map<Integer, List<PDFText>> exactFound =
textRedactionService.findTextToRedact(document, terms, false, false);
if (!exactFound.isEmpty()) {
fallback |=
textRedactionService.performTextReplacement(
document, exactFound, terms, false, false);
}
}
String[] patterns = cleanStrings(request.getRegexPatterns());
if (patterns.length > 0) {
Map<Integer, List<PDFText>> regexFound =
textRedactionService.findTextToRedact(document, patterns, true, false);
if (!regexFound.isEmpty()) {
fallback |=
textRedactionService.performTextReplacement(
document, regexFound, patterns, true, false);
}
}
if (fallback) {
log.warn(
"[redact/execute] font compatibility issue — falling back to overlay-only");
} else {
log.info("[redact/execute] content-stream text removal applied successfully");
}
return fallback;
} catch (Exception e) {
log.warn(
"[redact/execute] text removal failed, falling back to overlay: {}",
e.getMessage());
return true;
}
}
// -----------------------------------------------------------------------
// Per-operation dispatch methods
// -----------------------------------------------------------------------
private void applyPageWipe(PDDocument document, List<Integer> pageNumbers, RedactStyle style)
throws IOException {
List<Integer> pageIndices = toZeroBasedIndices(pageNumbers);
if (pageIndices.isEmpty()) return;
PDPageTree allPages = document.getDocumentCatalog().getPages();
Color pageColor = ManualRedactionService.decodeOrDefault(style.getColor());
Collections.sort(pageIndices);
log.info("[redact/execute] full-page wipe: {} pages ({})", pageIndices.size(), pageIndices);
Map<Integer, List<float[]>> pageElementBoxes = new HashMap<>();
for (Integer idx : pageIndices) {
if (idx >= 0 && idx < allPages.getCount()) {
try {
pageElementBoxes.put(
idx,
manualRedactionService.extractPageElementBoxes(
document, allPages.get(idx), idx));
} catch (Exception e) {
log.warn(
"[redact/execute] element extraction failed for page {}: {}",
idx,
e.getMessage());
}
}
}
for (Integer idx : pageIndices) {
if (idx >= 0 && idx < allPages.getCount()) {
PDPage page = allPages.get(idx);
List<float[]> elementBoxes =
pageElementBoxes.getOrDefault(idx, Collections.emptyList());
page.getCOSObject().removeItem(COSName.CONTENTS);
page.setResources(new PDResources());
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
cs.setNonStrokingColor(pageColor);
if (elementBoxes.isEmpty()) {
PDRectangle box = page.getBBox();
cs.addRect(0, 0, box.getWidth(), box.getHeight());
} else {
log.info(
"[redact/execute] page {}: drawing {} element boxes",
idx + 1,
elementBoxes.size());
for (float[] r : elementBoxes) {
cs.addRect(r[0], r[1], r[2] - r[0], r[3] - r[1]);
}
}
cs.fill();
}
}
}
}
private void applyRangeRedaction(
PDDocument document,
TextRange range,
RedactStyle style,
Map<Integer, PageColumnLayout> layoutCache)
throws IOException {
String rangeStart = trimOrEmpty(range.startString());
String rangeEnd = trimOrEmpty(range.endString());
log.info("[redact/execute] range redaction: start='{}' end='{}'", rangeStart, rangeEnd);
try {
List<PDFText> blocks = collectRangeBlocks(document, rangeStart, rangeEnd, layoutCache);
if (!blocks.isEmpty()) {
manualRedactionService.redactFoundText(
document,
blocks,
style.getPadding(),
ManualRedactionService.decodeOrDefault(style.getColor()),
false);
} else {
log.warn(
"[redact/execute] range not found: start='{}' end='{}'",
rangeStart,
rangeEnd);
}
} catch (Exception e) {
log.warn("[redact/execute] range redaction failed: {}", e.getMessage());
}
}
private void applyImageBoxRedaction(PDDocument document, ImageBox box, RedactStyle style)
throws IOException {
List<float[]> boxes =
List.of(
new float[] {
(float) box.pageIndex(), box.x1(), box.y1(), box.x2(), box.y2()
});
log.info("[redact/execute] image box overlay on page {}", box.pageIndex());
Color boxColor = ManualRedactionService.decodeOrDefault(style.getColor());
manualRedactionService.redactImageBoxes(document, boxes, boxColor);
}
private void applyAllImagesRedaction(
PDDocument document, List<Integer> pageNumbers, RedactStyle style) throws IOException {
PDPageTree allPages = document.getDocumentCatalog().getPages();
Color imgColor = ManualRedactionService.decodeOrDefault(style.getColor());
List<Integer> imagePageIndices = toZeroBasedIndices(pageNumbers);
if (imagePageIndices.isEmpty()) {
imagePageIndices = new ArrayList<>();
for (int i = 0; i < allPages.getCount(); i++) {
imagePageIndices.add(i);
}
}
List<float[]> detectedBoxes = new ArrayList<>();
for (int pageIdx : imagePageIndices) {
if (pageIdx < 0 || pageIdx >= allPages.getCount()) continue;
try {
PDPage page = allPages.get(pageIdx);
PageImageLocator locator = new PageImageLocator(page, pageIdx);
locator.processPage(page);
for (PageImageLocator.ImageBox ib : locator.getImageBoxes()) {
detectedBoxes.add(new float[] {pageIdx, ib.x1(), ib.y1(), ib.x2(), ib.y2()});
}
} catch (Exception e) {
log.warn(
"[redact/execute] image detection failed for page {}: {}",
pageIdx + 1,
e.getMessage());
}
}
log.info(
"[redact/execute] auto image detection: {} images across {} pages",
detectedBoxes.size(),
imagePageIndices.size());
if (!detectedBoxes.isEmpty()) {
manualRedactionService.redactImageBoxes(document, detectedBoxes, imgColor);
}
}
// -----------------------------------------------------------------------
// Range collection helpers
// -----------------------------------------------------------------------
/**
* Locates {@code startStr} in the document and returns {@link PDFText} blocks for every text
* line and image from that point up to (but NOT including) the line where {@code endStr}
* begins. If {@code endStr} is blank, redacts from {@code startStr} to the end of the document.
*
* <p>Multi-column pages follow reading order: down the start column, jump to the top of the
* next column, continue to the end anchor. Single-column pages reduce to a plain Y-band check.
*/
List<PDFText> collectRangeBlocks(
PDDocument document,
String startStr,
String endStr,
Map<Integer, PageColumnLayout> layoutCache)
throws IOException {
PDPageTree allPages = document.getDocumentCatalog().getPages();
int totalPages = allPages.getCount();
Map<Integer, List<PDFText>> startMatchesByPage = findWithFallbacks(document, startStr);
if (startMatchesByPage.isEmpty()) {
log.warn("[redact/execute] range start not found: '{}'", startStr);
return Collections.emptyList();
}
List<Anchor> starts = toAnchors(document, startMatchesByPage, layoutCache);
starts.sort(READING_ORDER);
log.info(
"[redact/execute] start='{}' matched {} anchor(s): {}",
startStr,
starts.size(),
anchorSummary(starts));
boolean openEnded = (endStr == null || endStr.isBlank());
List<Anchor> ends = new ArrayList<>();
if (!openEnded) {
Map<Integer, List<PDFText>> endMatchesByPage = findWithFallbacks(document, endStr);
if (endMatchesByPage.isEmpty()) {
log.warn(
"[redact/execute] range end '{}' not found in document - skipping range"
+ " (start='{}')",
endStr,
startStr);
return Collections.emptyList();
}
ends = toAnchors(document, endMatchesByPage, layoutCache);
ends.sort(READING_ORDER);
log.info(
"[redact/execute] end='{}' matched {} anchor(s): {}",
endStr,
ends.size(),
anchorSummary(ends));
}
List<PDFText> blocks = new ArrayList<>();
for (Anchor start : starts) {
Anchor end = null;
int endPage;
if (openEnded) {
endPage = totalPages - 1;
} else {
for (Anchor candidate : ends) {
if (READING_ORDER.compare(candidate, start) > 0) {
end = candidate;
break;
}
}
if (end == null) {
log.warn(
"[redact/execute] no end anchor after start at (page={}, col={}, y={}) — skipping",
start.page + 1,
start.col,
start.y);
continue;
}
endPage = end.page;
}
log.info(
"[redact/execute] range pages {}-{}: start='{}' (col {}) end='{}'",
start.page + 1,
endPage + 1,
startStr,
start.col,
openEnded ? "<end of document>" : endStr);
collectBlocksForRange(document, allPages, start, end, openEnded, blocks, layoutCache);
}
log.info(
"[redact/execute] range '{}'→'{}': {} total blocks",
startStr,
openEnded ? "<end of document>" : endStr,
blocks.size());
return blocks;
}
/**
* Collects all redactable content (text line segments and images) between two anchor positions.
*
* <p>Line boxes are cached per page number in {@code lineBoxCache} and reused across range
* iterations within one execute() call, avoiding redundant {@link AllTextLineExtractor} passes.
*/
private void collectBlocksForRange(
PDDocument document,
PDPageTree allPages,
Anchor start,
Anchor end,
boolean openEnded,
List<PDFText> blocks,
Map<Integer, PageColumnLayout> layoutCache)
throws IOException {
int startPage = start.page;
int endPage = openEnded ? allPages.getCount() - 1 : end.page;
int endCol =
openEnded ? layoutFor(document, endPage, layoutCache).columnCount() - 1 : end.col;
float startY = start.y;
// Use bottom of end anchor so the end anchor line itself is included (inclusive range).
float endY = openEnded ? Float.POSITIVE_INFINITY : end.text.getY2();
// Line-box cache: populated lazily per page, reused across range iterations.
// Cannot use computeIfAbsent because AllTextLineExtractor's constructor throws IOException.
Map<Integer, List<float[]>> lineBoxCache = new HashMap<>();
for (int pageIdx = startPage; pageIdx <= endPage; pageIdx++) {
PDPage page = allPages.get(pageIdx);
float pageHeight = page.getBBox().getHeight();
PageColumnLayout layout = layoutFor(document, pageIdx, layoutCache);
List<float[]> screenLineBoxes = lineBoxCache.get(pageIdx);
if (screenLineBoxes == null) {
AllTextLineExtractor textExtractor =
new AllTextLineExtractor(pageIdx + 1, pageHeight);
textExtractor.getText(document);
screenLineBoxes = textExtractor.getScreenLineBoxes();
lineBoxCache.put(pageIdx, screenLineBoxes);
}
for (float[] sb : screenLineBoxes) {
emitColumnSlices(
pageIdx, layout, sb[0], sb[2], sb[1], sb[3], start.col, startPage, startY,
endCol, endPage, endY, blocks);
}
PageImageLocator imgLocator = new PageImageLocator(page, pageIdx);
imgLocator.processPage(page);
for (PageImageLocator.ImageBox ib : imgLocator.getImageBoxes()) {
// ImageBox coordinates are in PDF user-space (Y up); convert to screen-Y (Y down).
float screenY1 = pageHeight - ib.y2();
float screenY2 = pageHeight - ib.y1();
emitColumnSlices(
pageIdx, layout, ib.x1(), ib.x2(), screenY1, screenY2, start.col, startPage,
startY, endCol, endPage, endY, blocks);
}
}
}
/** Emits each per-column sub-box accepted by the reading-order predicate. */
private static void emitColumnSlices(
int pageIdx,
PageColumnLayout layout,
float x1,
float x2,
float yTop,
float yBottom,
int startCol,
int startPage,
float startY,
int endCol,
int endPage,
float endY,
List<PDFText> blocks) {
int[] cols = layout.columnsCrossing(x1, x2);
if (cols.length == 1) {
if (inColumnZone(
pageIdx, cols[0], yTop, yBottom, startPage, startCol, startY, endPage, endCol,
endY)) {
blocks.add(new PDFText(pageIdx, x1, yTop, x2, yBottom, ""));
}
return;
}
for (int col : cols) {
if (!inColumnZone(
pageIdx, col, yTop, yBottom, startPage, startCol, startY, endPage, endCol,
endY)) {
return;
}
}
blocks.add(new PDFText(pageIdx, x1, yTop, x2, yBottom, ""));
}
/**
* Reading-order predicate: true when (col, yBottom) on page {@code pageIdx} sits between the
* start anchor (inclusive) and end anchor (inclusive).
*/
static boolean inColumnZone(
int pageIdx,
int col,
float yTop,
float yBottom,
int startPage,
int startCol,
float startY,
int endPage,
int endCol,
float endY) {
if (pageIdx > startPage && pageIdx < endPage) return true;
if (pageIdx == startPage && pageIdx == endPage) {
if (startCol == endCol) {
return col == startCol && yBottom >= startY && yBottom <= endY;
}
if (startCol < endCol) {
if (col < startCol || col > endCol) return false;
if (col == startCol) return yBottom >= startY;
if (col == endCol) return yBottom <= endY;
return true;
}
return col == startCol && yBottom >= startY;
}
if (pageIdx == startPage) {
if (col == startCol) return yBottom >= startY;
return col > startCol;
}
if (pageIdx == endPage) {
if (col == endCol) return yBottom <= endY;
return col < endCol;
}
return false;
}
/** Lazily builds and caches the column layout for a single page. */
private PageColumnLayout layoutFor(
PDDocument document, int pageIdx, Map<Integer, PageColumnLayout> cache)
throws IOException {
PageColumnLayout cached = cache.get(pageIdx);
if (cached != null) return cached;
PDPage page = document.getDocumentCatalog().getPages().get(pageIdx);
float pageWidth = page.getBBox().getWidth();
float pageHeight = page.getBBox().getHeight();
AllTextLineExtractor extractor = new AllTextLineExtractor(pageIdx + 1, pageHeight);
extractor.getText(document);
PageColumnLayout layout =
PageColumnLayout.fromLineBoxes(extractor.getLineBoxes(), pageWidth);
if (layout.columnCount() > 1) {
float[] g = layout.gutters().get(0);
log.info(
"[redact/execute] page {} layout: 2 cols, gutter x=[{}, {}]",
pageIdx + 1,
g[0],
g[1]);
} else {
log.info("[redact/execute] page {} layout: 1 col (single-column mode)", pageIdx + 1);
}
cache.put(pageIdx, layout);
return layout;
}
private List<Anchor> toAnchors(
PDDocument document,
Map<Integer, List<PDFText>> matchesByPage,
Map<Integer, PageColumnLayout> layoutCache)
throws IOException {
List<Anchor> out = new ArrayList<>();
for (int page : matchesByPage.keySet().stream().sorted().toList()) {
PageColumnLayout layout = layoutFor(document, page, layoutCache);
for (PDFText hit : matchesByPage.get(page)) {
int col = layout.columnOf(hit.getX1(), hit.getX2());
out.add(new Anchor(page, col, hit.getY1(), hit));
}
}
return out;
}
/** Lexicographic ordering by (page, column, screenY). */
private static final Comparator<Anchor> READING_ORDER =
Comparator.comparingInt((Anchor a) -> a.page)
.thenComparingInt(a -> a.col)
.thenComparingDouble(a -> a.y);
private static String anchorSummary(List<Anchor> anchors) {
StringBuilder sb = new StringBuilder();
int max = Math.min(anchors.size(), 5);
for (int i = 0; i < max; i++) {
Anchor a = anchors.get(i);
if (i > 0) sb.append(", ");
sb.append(String.format("(p=%d,c=%d,y=%.1f)", a.page + 1, a.col, a.y));
}
if (anchors.size() > max) sb.append(", …");
return sb.toString();
}
private record Anchor(int page, int col, float y, PDFText text) {}
/**
* Tries progressively more permissive variants: raw (regex then literal), letter-spacing
* collapsed, then a punctuation-tolerant regex over alphanumeric runs.
*/
private Map<Integer, List<PDFText>> findWithFallbacks(PDDocument document, String raw) {
String trimmed = raw.trim();
String collapsed = collapseLetterSpacing(trimmed);
String tolerant = punctuationTolerantRegex(trimmed);
List<Candidate> candidates = new ArrayList<>();
candidates.add(new Candidate(trimmed, true));
candidates.add(new Candidate(trimmed, false));
if (!collapsed.equals(trimmed)) {
candidates.add(new Candidate(collapsed, true));
candidates.add(new Candidate(collapsed, false));
}
if (tolerant != null && !tolerant.equals(trimmed)) {
candidates.add(new Candidate(tolerant, true));
}
// If the anchor spans multiple lines (model provided entire paragraph instead of a short
// phrase), try just the first non-empty line — it's usually sufficient to locate the
// position and avoids mismatches from mid-paragraph text extraction artifacts.
if (trimmed.contains("\n")) {
String firstLine =
Arrays.stream(trimmed.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.findFirst()
.orElse(null);
if (firstLine != null && firstLine.length() >= 4) {
String firstLineCollapsed = collapseLetterSpacing(firstLine);
String firstLineTolerant = punctuationTolerantRegex(firstLine);
candidates.add(new Candidate(firstLine, false));
if (!firstLineCollapsed.equals(firstLine)) {
candidates.add(new Candidate(firstLineCollapsed, false));
}
if (firstLineTolerant != null && !firstLineTolerant.equals(firstLine)) {
candidates.add(new Candidate(firstLineTolerant, true));
}
}
}
for (Candidate c : candidates) {
Map<Integer, List<PDFText>> m =
textRedactionService.findTextToRedact(
document, new String[] {c.pattern}, c.useRegex, false);
if (!m.isEmpty()) {
if (!c.pattern.equals(trimmed)) {
log.info(
"[redact/execute] range boundary matched via fallback: '{}' → '{}'",
trimmed,
c.pattern);
}
return m;
}
}
return Collections.emptyMap();
}
private record Candidate(String pattern, boolean useRegex) {}
// -----------------------------------------------------------------------
// Static helpers
// -----------------------------------------------------------------------
/**
* Joins {@code raw}'s alphanumeric runs with {@code \W*} so anchors match across punctuation
* drift. Returns {@code null} when fewer than two tokens exist.
*/
private static String punctuationTolerantRegex(String raw) {
List<String> tokens = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (int i = 0; i < raw.length(); i++) {
char ch = raw.charAt(i);
if (Character.isLetterOrDigit(ch)) {
current.append(ch);
} else if (current.length() > 0) {
tokens.add(current.toString());
current.setLength(0);
}
}
if (current.length() > 0) tokens.add(current.toString());
if (tokens.size() < 2) return null;
StringBuilder out = new StringBuilder();
for (int i = 0; i < tokens.size(); i++) {
if (i > 0) out.append("\\W*");
out.append(Pattern.quote(tokens.get(i)));
}
return out.toString();
}
/**
* Collapses letter-spaced text produced by position-sorted text extraction.
*
* <p>When a PDF text stripper runs with {@code setSortByPosition(true)}, letter-spaced headings
* come out as {@code "T a b l e o f c o n t e n t s"}. This method converts the spaced form
* back to words.
*/
private static String collapseLetterSpacing(String text) {
String[] tokens = text.split(" ", -1);
StringBuilder result = new StringBuilder();
StringBuilder current = new StringBuilder();
for (String token : tokens) {
if (token.isEmpty()) {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
result.append(current);
current.setLength(0);
}
} else if (token.length() == 1) {
current.append(token);
} else {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
result.append(current);
current.setLength(0);
}
if (result.length() > 0) result.append(' ');
result.append(token);
}
}
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
result.append(current);
}
return result.toString().trim();
}
private static <T> List<T> orEmpty(List<T> list) {
return list != null ? list : List.of();
}
private static String[] cleanStrings(List<String> input) {
if (input == null || input.isEmpty()) {
return new String[0];
}
return input.stream()
.filter(s -> s != null)
.map(String::trim)
.filter(s -> !s.isEmpty())
.toArray(String[]::new);
}
/**
* Converts 1-based page numbers from the request to the 0-based indices used internally.
* Out-of-range and non-positive values are silently dropped.
*/
private static List<Integer> toZeroBasedIndices(List<Integer> oneBasedPageNumbers) {
if (oneBasedPageNumbers == null || oneBasedPageNumbers.isEmpty()) {
return new ArrayList<>();
}
List<Integer> result = new ArrayList<>();
for (Integer page : oneBasedPageNumbers) {
if (page != null && page > 0) {
result.add(page - 1);
}
}
return result;
}
private static String trimOrEmpty(String s) {
return s == null ? "" : s.trim();
}
}
@@ -12,7 +12,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -135,22 +134,13 @@ public class ReactRoutingController {
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
try {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(cachedIndexHtml);
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(processIndexHtml());
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
} catch (Exception ex) {
log.error("Failed to serve index.html, returning fallback", ex);
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(buildFallbackHtml());
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml());
}
}
@@ -1,132 +0,0 @@
package stirling.software.SPDF.model.api.security;
import java.util.ArrayList;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class RedactExecuteRequest extends PDFFile {
@Schema(
description =
"Exact strings to find and black out. One entry per phrase to redact."
+ " Best for known names, identifiers, and specific text found in the document.")
private List<String> textValues = new ArrayList<>();
@Schema(
description =
"Regex patterns to match and redact. Each match anywhere in the document is blacked out."
+ " Uses Java/PCRE regex syntax. Well-suited for strings that follow known patterns, like"
+ " phone numbers, email addresses, national ID numbers, or"
+ " dates (which can appear with different separators, optional country codes,"
+ " etc.). For fixed known strings such as names, use textValues instead.")
private List<String> regexPatterns = new ArrayList<>();
@Schema(
description =
"1-indexed page numbers to wipe entirely (all content removed from those pages).")
private List<Integer> wipePages = new ArrayList<>();
@Schema(
description =
"Text ranges to redact by specifying a start and end anchor phrase. All"
+ " content between the two phrases (inclusive) is redacted. Anchors"
+ " work best when short and unique. They must appear"
+ " verbatim in the document.")
private List<TextRange> ranges = new ArrayList<>();
@Schema(
description =
"Rectangular areas to black out, each defined by a page number and bounding box coordinates.")
private List<ImageBox> imageBoxes = new ArrayList<>();
@Schema(
description =
"1-indexed page numbers to redact all detected images from. Pass an empty list to redact images from every page. Omit or pass null to skip image redaction entirely.")
private List<Integer> redactImagePages;
@Schema(description = "Redaction style options")
private RedactStyle style = new RedactStyle();
public record TextRange(
@Schema(
description =
"A short, distinctive phrase (515 words) that marks where"
+ " redaction begins (inclusive). Must appear verbatim in"
+ " the document — e.g. a section heading or a unique"
+ " sentence fragment.",
requiredMode = Schema.RequiredMode.REQUIRED,
minLength = 1)
String startString,
@Schema(
description =
"A short, distinctive phrase (515 words) that marks where"
+ " redaction ends (inclusive). Must appear verbatim in the"
+ " document. Shorter phrases match more reliably.",
requiredMode = Schema.RequiredMode.REQUIRED,
minLength = 1)
String endString) {
public TextRange {
if (endString == null) endString = "";
}
}
public record ImageBox(
@Schema(
description = "0-indexed page number (first page = 0).",
requiredMode = Schema.RequiredMode.REQUIRED)
int pageIndex,
@Schema(
description =
"Left x coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float x1,
@Schema(
description =
"Top y coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float y1,
@Schema(
description =
"Right x coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float x2,
@Schema(
description =
"Bottom y coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float y2) {}
public enum RedactionStrategy {
AUTO,
OVERLAY_ONLY,
IMAGE_FINALIZE
}
@Data
public static class RedactStyle {
@Schema(description = "Hex redaction box color", defaultValue = "#000000")
private String color = "#000000";
@Schema(
description = "Extra padding around each box in points",
type = "number",
defaultValue = "0")
private float padding = 0f;
@Schema(description = "Rasterize output to prevent text extraction", defaultValue = "false")
private boolean convertToImage = false;
@Schema(
description = "Execution strategy hint for the redaction pipeline",
defaultValue = "AUTO")
private RedactionStrategy strategy = RedactionStrategy.AUTO;
}
}
@@ -4,7 +4,6 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -23,10 +22,6 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.StickyMissRecorder;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
@@ -35,6 +30,7 @@ import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.RegexPatternUtils;
/** REST controller for job-related endpoints */
@RestController
@RequiredArgsConstructor
@Slf4j
@@ -46,29 +42,20 @@ public class JobController {
private final FileStorage fileStorage;
private final JobQueue jobQueue;
private final HttpServletRequest request;
private final ClusterBackplane clusterBackplane;
private final JobStore jobStore;
// Short-TTL local cache fronting JobStore.get() on the sticky-410 path to avoid a Valkey
// HGETALL round-trip on every download retry for the same job.
private final JobOwnershipCache ownershipCache = new JobOwnershipCache();
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
@Autowired(required = false)
private StickyMissRecorder stickyMissRecorder;
/**
* Get the status of a job
*
* @param jobId The job ID
* @return The job result
*/
@GetMapping("/job/{jobId}")
@Operation(summary = "Get job status")
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
// Sticky-410 must run before user-auth: a 403 here would leak job existence and defeat
// LB re-routing. The owner node is where the real auth check should happen.
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
if (peerOwned.isPresent()) {
return peerOwned.get();
}
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job status: {}", jobId);
return ResponseEntity.status(403)
@@ -80,6 +67,7 @@ public class JobController {
return ResponseEntity.notFound().build();
}
// Check if the job is in the queue and add queue information
if (!result.isComplete() && jobQueue.isJobQueued(jobId)) {
int position = jobQueue.getJobPosition(jobId);
Map<String, Object> resultWithQueueInfo =
@@ -94,14 +82,16 @@ public class JobController {
return ResponseEntity.ok(result);
}
/**
* Get the result of a job
*
* @param jobId The job ID
* @return The job result
*/
@GetMapping("/job/{jobId}/result")
@Operation(summary = "Get job result")
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
if (peerOwned.isPresent()) {
return peerOwned.get();
}
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job result: {}", jobId);
return ResponseEntity.status(403)
@@ -121,6 +111,7 @@ public class JobController {
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
}
// Handle multiple files - return metadata for client to download individually
if (result.hasMultipleFiles()) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
@@ -134,11 +125,11 @@ public class JobController {
result.getAllResultFiles()));
}
// Handle single file (download directly)
if (result.hasFiles() && !result.hasMultipleFiles()) {
try {
List<ResultFile> files = result.getAllResultFiles();
ResultFile singleFile = files.get(0);
byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId());
return ResponseEntity.ok()
.header("Content-Type", singleFile.getContentType())
@@ -156,22 +147,30 @@ public class JobController {
return ResponseEntity.ok(result.getResult());
}
// Admin-only endpoints have been moved to AdminJobController in the proprietary package
/**
* Cancel a job by its ID
*
* <p>This method should only allow cancellation of jobs that were created by the current user.
* The jobId should be part of the user's session or otherwise linked to their identity.
*
* @param jobId The job ID
* @return Response indicating whether the job was cancelled
*/
@DeleteMapping("/job/{jobId}")
@Operation(summary = "Cancel a job")
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
log.debug("Request to cancel job: {}", jobId);
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
if (peerOwned.isPresent()) {
return peerOwned.get();
}
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to cancel job: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to cancel this job"));
}
// First check if the job is in the queue
boolean cancelled = false;
int queuePosition = -1;
@@ -181,9 +180,11 @@ public class JobController {
log.info("Cancelled queued job: {} (was at position {})", jobId, queuePosition);
}
// If not in queue or couldn't cancel, try to cancel in TaskManager
if (!cancelled) {
JobResult result = taskManager.getJobResult(jobId);
if (result != null && !result.isComplete()) {
// Mark as error with cancellation message
taskManager.setError(jobId, "Job was cancelled by user");
cancelled = true;
log.info("Marked job as cancelled in TaskManager: {}", jobId);
@@ -200,6 +201,7 @@ public class JobController {
"queuePosition",
queuePosition >= 0 ? queuePosition : "n/a"));
} else {
// Job not found or already complete
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@@ -213,14 +215,16 @@ public class JobController {
}
}
/**
* Get the list of files for a job
*
* @param jobId The job ID
* @return List of files for the job
*/
@GetMapping("/job/{jobId}/result/files")
@Operation(summary = "Get job result files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
if (peerOwned.isPresent()) {
return peerOwned.get();
}
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job files: {}", jobId);
return ResponseEntity.status(403)
@@ -248,31 +252,28 @@ public class JobController {
"files", files));
}
/**
* Get metadata for an individual file by its file ID
*
* @param fileId The file ID
* @return The file metadata
*/
@GetMapping("/files/{fileId}/metadata")
@Operation(summary = "Get file metadata")
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
try {
String jobKey;
try {
jobKey = taskManager.findJobKeyByFileId(fileId);
} catch (RuntimeException backplaneEx) {
return backplaneUnavailable(fileId, backplaneEx);
}
String jobKey = taskManager.findJobKeyByFileId(fileId);
if (jobKey == null) {
return ResponseEntity.notFound().build();
}
Optional<ResponseEntity<?>> notOwner = guardNonOwner(jobKey);
if (notOwner.isPresent()) {
return notOwner.get();
}
if (!validateJobAccess(jobKey)) {
log.warn("Unauthorized attempt to access file metadata: {}", fileId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this file"));
}
// Find the file metadata from any job that contains this file
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
if (resultFile != null) {
@@ -280,10 +281,12 @@ public class JobController {
}
if (!isSecurityEnabled()) {
// Backwards compatibility when ownership service is unavailable
if (!fileStorage.fileExists(fileId)) {
return ResponseEntity.notFound().build();
}
// File exists but no metadata found, get basic info efficiently
long fileSize = fileStorage.getFileSize(fileId);
return ResponseEntity.ok(
Map.of(
@@ -305,31 +308,32 @@ public class JobController {
}
}
/**
* Download an individual file by its file ID
*
* @param fileId The file ID
* @return The file content
*/
@GetMapping("/files/{fileId}")
@Operation(summary = "Download a file")
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
try {
String jobKey;
try {
jobKey = taskManager.findJobKeyByFileId(fileId);
} catch (RuntimeException backplaneEx) {
return backplaneUnavailable(fileId, backplaneEx);
}
String jobKey = taskManager.findJobKeyByFileId(fileId);
if (jobKey == null) {
return ResponseEntity.notFound().build();
}
Optional<ResponseEntity<?>> notOwner = guardNonOwner(jobKey);
if (notOwner.isPresent()) {
return notOwner.get();
}
if (!validateJobAccess(jobKey)) {
log.warn("Unauthorized attempt to download file: {}", fileId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this file"));
}
// Retrieve file content
byte[] fileContent = fileStorage.retrieveBytes(fileId);
// Find the file metadata from any job that contains this file
// This is for getting the original filename and content type
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
String fileName = resultFile != null ? resultFile.getFileName() : "download";
@@ -338,8 +342,6 @@ public class JobController {
? resultFile.getContentType()
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
byte[] fileContent = fileStorage.retrieveBytes(fileId);
return ResponseEntity.ok()
.header("Content-Type", contentType)
.header("Content-Disposition", createContentDispositionHeader(fileName))
@@ -355,88 +357,11 @@ public class JobController {
}
/**
* Returns 410 Gone when the job is owned by a peer node, empty otherwise. Uses a short-TTL
* local cache to avoid repeated Valkey lookups on the hot download path. When the backplane is
* unreachable, a locally-held job is still served and anything else gets a retryable 503.
* Create Content-Disposition header with UTF-8 filename support
*
* @param fileName The filename to encode
* @return Content-Disposition header value
*/
private Optional<ResponseEntity<?>> guardNonOwner(String jobId) {
if (clusterBackplane == null || jobStore == null) {
return Optional.empty();
}
Optional<JobStoreEntry> entry;
Optional<Optional<JobStoreEntry>> cached = ownershipCache.get(jobId);
if (cached.isPresent()) {
entry = cached.get();
} else {
try {
entry = jobStore.get(jobId);
} catch (RuntimeException ex) {
// Backplane unreachable: if we hold the job locally serve it, otherwise return a
// retryable 503 (same contract as the file endpoints) instead of a misleading 404.
if (taskManager.getJobResult(jobId) == null) {
return Optional.of(backplaneUnavailable(jobId, ex));
}
log.warn(
"JobStore lookup failed for jobId={}; serving locally-held job: {}",
jobId,
ex.getMessage());
return Optional.empty();
}
ownershipCache.put(jobId, entry);
}
if (entry.isEmpty()) {
return Optional.empty();
}
String owner = entry.get().owningNodeId();
if (owner == null || owner.isBlank()) {
return Optional.empty();
}
String localId = clusterBackplane.localNodeId();
if (owner.equals(localId)) {
return Optional.empty();
}
log.info(
"Sticky-session miss for jobId={} (owner={}, local={}); returning 410 so client"
+ " retries via LB affinity",
jobId,
owner,
localId);
if (stickyMissRecorder != null) {
stickyMissRecorder.recordStickyMiss();
}
return Optional.of(
ResponseEntity.status(410)
.header("Retry-After", "0")
.body(
Map.of(
"message",
"Result lives on another node. Retry to be routed there"
+ " by the load balancer's sticky-session"
+ " affinity, or re-run the job.",
"ownedBy",
owner,
"currentNode",
localId == null ? "" : localId)));
}
/**
* When the backplane is unreachable we cannot resolve ownership or existence, and serving
* without that check would be unsafe - so return a retryable 503 (consistent with the
* sticky-410 retry model) rather than a misleading 404 or a generic 500.
*/
private ResponseEntity<?> backplaneUnavailable(String id, RuntimeException ex) {
log.warn(
"Backplane lookup failed for {}; returning 503 (retryable): {}",
id,
ex.getMessage());
return ResponseEntity.status(503)
.header("Retry-After", "1")
.body(
Map.of(
"message",
"Cluster backplane temporarily unavailable; retry shortly."));
}
private String createContentDispositionHeader(String fileName) {
try {
String encodedFileName =
@@ -446,11 +371,19 @@ public class JobController {
.replaceAll("%20"); // URLEncoder uses + for spaces, but we want %20
return "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + encodedFileName;
} catch (Exception e) {
// Fallback to basic filename if encoding fails
return "attachment; filename=\"" + fileName + "\"";
}
}
/**
* Validate that the current user has access to the given job.
*
* @param jobId the job identifier to validate
* @return true if user has access, false otherwise
*/
private boolean validateJobAccess(String jobId) {
// If JobOwnershipService is available (security enabled), use it
if (jobOwnershipService != null) {
try {
return jobOwnershipService.validateJobAccess(jobId);
@@ -460,6 +393,8 @@ public class JobController {
}
}
// Security disabled - allow all access (backwards compatibility)
// When security is not enabled, any user can access any job by jobId
return true;
}
}
@@ -1,47 +0,0 @@
package stirling.software.common.controller;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import stirling.software.common.cluster.JobStoreEntry;
/**
* Process-local TTL cache for {@link JobStoreEntry} lookups to suppress redundant Valkey HGETALL
* round-trips on the hot result-download path (sticky-410 ownership check).
*
* <p>5 second TTL is short enough that a job's lifecycle transitions (RUNNING -> COMPLETE -> TTL
* expiry) propagate to all nodes within the LB's sticky-session window, and short enough that a
* mistakenly-cached "not found" recovers quickly when an entry actually shows up. Cap the map at
* 2048 entries to bound memory; eviction is best-effort (clear-and-restart) since the cache is
* advisory.
*/
final class JobOwnershipCache {
private static final long TTL_NANOS = 5L * 1_000_000_000L; // 5 s
private static final int MAX_ENTRIES = 2048;
private final ConcurrentMap<String, Entry> entries = new ConcurrentHashMap<>();
Optional<Optional<JobStoreEntry>> get(String jobId) {
Entry e = entries.get(jobId);
if (e == null) {
return Optional.empty();
}
if (System.nanoTime() - e.storedAtNanos > TTL_NANOS) {
entries.remove(jobId, e);
return Optional.empty();
}
return Optional.of(e.value);
}
void put(String jobId, Optional<JobStoreEntry> value) {
if (entries.size() >= MAX_ENTRIES) {
// Best-effort eviction; under burst the cache simply rebuilds.
entries.clear();
}
entries.put(jobId, new Entry(value, System.nanoTime()));
}
private record Entry(Optional<JobStoreEntry> value, long storedAtNanos) {}
}
@@ -33,7 +33,7 @@ spring.security.filter.dispatcher-types=REQUEST,ERROR
# Response compression
server.compression.enabled=true
server.compression.min-response-size=1024
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript,image/svg+xml,application/x-font-ttf,font/opentype,application/vnd.ms-fontobject,font/woff,font/woff2,application/font-woff,application/font-woff2,application/wasm
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
spring.web.error.path=/error
spring.web.error.whitelabel.enabled=false
@@ -24,21 +24,7 @@
{
"moduleName": "com.bucket4j:bucket4j_jdk17-core",
"moduleUrl": "http://github.com/bucket4j/bucket4j/bucket4j_jdk17-core",
"moduleVersion": "8.19.0",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "com.bucket4j:bucket4j_jdk17-lettuce",
"moduleUrl": "http://github.com/bucket4j/bucket4j/bucket4j_jdk17-redis/bucket4j_jdk17-lettuce",
"moduleVersion": "8.19.0",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "com.bucket4j:bucket4j_jdk17-redis-common",
"moduleUrl": "http://github.com/bucket4j/bucket4j/bucket4j_jdk17-redis/bucket4j_jdk17-redis-common",
"moduleVersion": "8.19.0",
"moduleVersion": "8.18.0",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0"
},
@@ -311,48 +297,6 @@
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/mit-license.php"
},
{
"moduleName": "com.stirling:jpdfium",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-darwin-arm64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-darwin-x64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-linux-arm64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-linux-x64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-windows-x64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.sun.activation:jakarta.activation",
"moduleUrl": "https://www.eclipse.org",
@@ -696,13 +640,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.lettuce:lettuce-core",
"moduleUrl": "https://github.com/redis/lettuce",
"moduleVersion": "6.8.2.RELEASE",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/redis/lettuce/blob/main/LICENSE"
},
{
"moduleName": "io.micrometer:micrometer-commons",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
@@ -738,125 +675,6 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.netty:netty-buffer",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec-base",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec-compression",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec-dns",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec-http",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec-http2",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec-marshalling",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-codec-protobuf",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-common",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-handler",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-resolver",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-resolver-dns",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-transport",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-transport-classes-epoll",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.netty:netty-transport-native-unix-common",
"moduleUrl": "https://netty.io/",
"moduleVersion": "4.2.12.Final",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.projectreactor:reactor-core",
"moduleUrl": "https://github.com/reactor/reactor-core",
"moduleVersion": "3.8.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-config",
"moduleVersion": "1.4.3",
@@ -1199,13 +1017,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.httpcomponents:httpclient",
"moduleUrl": "http://hc.apache.org/httpcomponents-client",
"moduleVersion": "4.5.13",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.httpcomponents:httpclient",
"moduleUrl": "http://hc.apache.org/httpcomponents-client-ga",
@@ -1470,21 +1281,21 @@
{
"moduleName": "org.bouncycastle:bcpkix-jdk18on",
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
"moduleVersion": "1.84",
"moduleVersion": "1.83",
"moduleLicense": "Bouncy Castle Licence",
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
},
{
"moduleName": "org.bouncycastle:bcprov-jdk18on",
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
"moduleVersion": "1.84",
"moduleVersion": "1.83",
"moduleLicense": "Bouncy Castle Licence",
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
},
{
"moduleName": "org.bouncycastle:bcutil-jdk18on",
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
"moduleVersion": "1.84",
"moduleVersion": "1.83",
"moduleLicense": "Bouncy Castle Licence",
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
},
@@ -1976,20 +1787,6 @@
"moduleLicense": "BSD-2-Clause",
"moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html"
},
{
"moduleName": "org.postgresql:postgresql",
"moduleUrl": "https://jdbc.postgresql.org/",
"moduleVersion": "42.7.11",
"moduleLicense": "BSD-2-Clause",
"moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html"
},
{
"moduleName": "org.reactivestreams:reactive-streams",
"moduleUrl": "http://www.reactive-streams.org/",
"moduleVersion": "1.0.4",
"moduleLicense": "MIT-0",
"moduleLicenseUrl": "https://spdx.org/licenses/MIT-0.html"
},
{
"moduleName": "org.simplejavamail:core-module",
"moduleVersion": "8.12.6",
@@ -2103,13 +1900,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-data-redis",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-devtools",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2187,13 +1977,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-netty",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-persistence",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2264,13 +2047,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-data-redis",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-jackson",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2383,19 +2159,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.data:spring-data-keyvalue",
"moduleVersion": "4.0.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.data:spring-data-redis",
"moduleUrl": "https://spring.io/projects/spring-data-redis",
"moduleVersion": "4.0.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-config",
"moduleUrl": "https://spring.io/projects/spring-security",
@@ -2522,13 +2285,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-oxm",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "7.0.7",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-tx",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
@@ -2628,226 +2384,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "redis.clients.authentication:redis-authx-core",
"moduleUrl": "https://github.com/redis/redis-authx-core",
"moduleVersion": "0.1.1-beta2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/redis/redis-authx-core/blob/master/LICENSE"
},
{
"moduleName": "software.amazon.awssdk:annotations",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:apache-client",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:arns",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:auth",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:aws-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:aws-query-protocol",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:aws-xml-protocol",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:checksums",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:checksums-spi",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:crt-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:endpoints-spi",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth-aws",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth-aws-eventstream",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth-spi",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-client-spi",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:identity-spi",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:json-utils",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:metrics-spi",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:netty-nio-client",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:profiles",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:protocol-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:regions",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:retries",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:retries-spi",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:s3",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:sdk-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:third-party-jackson-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:url-connection-client",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:utils",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:utils-lite",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.eventstream:eventstream",
"moduleUrl": "https://github.com/awslabs/aws-eventstream-java",
"moduleVersion": "1.0.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "technology.tabula:tabula",
"moduleUrl": "http://github.com/tabulapdf/tabula-java",
@@ -244,52 +244,4 @@ class ConfigControllerTest {
assertNotNull(result);
assertFalse(result.contains("localhost"));
}
@Test
void resolveFrontendUrl_usesActualPortWhenServerPortIsEphemeral() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn(null);
// Loopback host forces the detected-LAN-IP branch, which is where an
// ephemeral server.port=0 would otherwise leak through as ":0".
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("localhost");
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
when(appConfig.getServerPort()).thenReturn("0");
org.springframework.core.env.Environment environment =
mock(org.springframework.core.env.Environment.class);
when(applicationContext.getEnvironment()).thenReturn(environment);
when(environment.getProperty("local.server.port")).thenReturn("54321");
String result = configController.resolveFrontendUrl(req, appConfig);
assertNotNull(result);
assertTrue(result.endsWith(":54321"));
assertFalse(result.contains(":0"));
}
@Test
void resolveEffectiveServerPort_prefersActualBoundPortWhenConfiguredZero() {
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getServerPort()).thenReturn("0");
org.springframework.core.env.Environment environment =
mock(org.springframework.core.env.Environment.class);
when(applicationContext.getEnvironment()).thenReturn(environment);
when(environment.getProperty("local.server.port")).thenReturn("54321");
assertEquals("54321", configController.resolveEffectiveServerPort(appConfig));
}
@Test
void resolveEffectiveServerPort_keepsConfiguredNonZeroPort() {
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getServerPort()).thenReturn("8080");
// Non-zero configured port is authoritative; the runtime env is never consulted.
assertEquals("8080", configController.resolveEffectiveServerPort(appConfig));
}
}
@@ -37,6 +37,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
@@ -76,11 +77,8 @@ class RedactControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private RedactExecuteService redactExecuteService;
private TextRedactionService textRedactionService;
private ManualRedactionService manualRedactionService;
private RedactController redactController;
@InjectMocks private RedactController redactController;
private MockMultipartFile mockPdfFile;
private PDDocument mockDocument;
@@ -203,17 +201,7 @@ class RedactControllerTest {
.save(any(File.class));
doNothing().when(mockDocument).close();
// Build real service instances so tests exercise actual logic
textRedactionService = new TextRedactionService();
manualRedactionService = new ManualRedactionService(tempFileManager);
redactController =
new RedactController(
pdfDocumentFactory,
tempFileManager,
manualRedactionService,
textRedactionService,
redactExecuteService);
// Initialize a real document for unit tests
setupRealDocument();
}
@@ -831,9 +819,9 @@ class RedactControllerTest {
contentStream.newLineAtOffset(50, 750);
contentStream.showText("This is ");
contentStream.newLineAtOffset(-10, 0);
contentStream.newLineAtOffset(-10, 0); // Simulate positioning
contentStream.showText("secret");
contentStream.newLineAtOffset(10, 0);
contentStream.newLineAtOffset(10, 0); // Reset positioning
contentStream.showText(" information");
contentStream.endText();
}
@@ -1017,7 +1005,7 @@ class RedactControllerTest {
contentStream.showText("Original content");
contentStream.endText();
}
return textRedactionService.createTokensWithoutTargetText(
return redactController.createTokensWithoutTargetText(
realDocument, pageForTokenExtraction, Collections.emptySet(), false, false);
}
@@ -1028,28 +1016,28 @@ class RedactControllerTest {
@Test
@DisplayName("Should decode valid hex color with hash")
void decodeValidHexColorWithHash() {
Color result = ManualRedactionService.decodeOrDefault("#FF0000");
Color result = redactController.decodeOrDefault("#FF0000");
assertEquals(Color.RED, result);
}
@Test
@DisplayName("Should decode valid hex color without hash")
void decodeValidHexColorWithoutHash() {
Color result = ManualRedactionService.decodeOrDefault("FF0000");
Color result = redactController.decodeOrDefault("FF0000");
assertEquals(Color.RED, result);
}
@Test
@DisplayName("Should default to black for null color")
void defaultToBlackForNullColor() {
Color result = ManualRedactionService.decodeOrDefault(null);
Color result = redactController.decodeOrDefault(null);
assertEquals(Color.BLACK, result);
}
@Test
@DisplayName("Should default to black for invalid color")
void defaultToBlackForInvalidColor() {
Color result = ManualRedactionService.decodeOrDefault("invalid-color");
Color result = redactController.decodeOrDefault("invalid-color");
assertEquals(Color.BLACK, result);
}
@@ -1061,7 +1049,7 @@ class RedactControllerTest {
})
@DisplayName("Should handle various valid color formats")
void handleVariousValidColorFormats(String colorInput) {
Color result = ManualRedactionService.decodeOrDefault(colorInput);
Color result = redactController.decodeOrDefault(colorInput);
assertNotNull(result);
assertTrue(
result.getRed() >= 0 && result.getRed() <= 255,
@@ -1077,8 +1065,8 @@ class RedactControllerTest {
@Test
@DisplayName("Should handle short hex codes appropriately")
void handleShortHexCodes() {
Color result1 = ManualRedactionService.decodeOrDefault("123");
Color result2 = ManualRedactionService.decodeOrDefault("#12");
Color result1 = redactController.decodeOrDefault("123");
Color result2 = redactController.decodeOrDefault("#12");
assertNotNull(result1);
assertNotNull(result2);
@@ -1106,7 +1094,7 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("confidential");
List<Object> tokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, false);
assertNotNull(tokens);
@@ -1127,7 +1115,7 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("secret");
List<Object> tokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, false);
assertNotNull(tokens);
@@ -1160,7 +1148,7 @@ class RedactControllerTest {
List<Object> originalTokens = getOriginalTokens();
List<Object> filteredTokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, false);
long originalNonTextCount =
@@ -1168,7 +1156,7 @@ class RedactControllerTest {
.filter(
token ->
token instanceof Operator op
&& !textRedactionService.isTextShowingOperator(
&& !redactController.isTextShowingOperator(
op.getName()))
.count();
@@ -1177,7 +1165,7 @@ class RedactControllerTest {
.filter(
token ->
token instanceof Operator op
&& !textRedactionService.isTextShowingOperator(
&& !redactController.isTextShowingOperator(
op.getName()))
.count();
@@ -1196,7 +1184,7 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("\\d{3}-\\d{2}-\\d{4}"); // SSN pattern
List<Object> tokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, true, false);
String reconstructedText = extractTextFromTokens(tokens);
@@ -1212,7 +1200,7 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("test");
List<Object> tokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, true);
String reconstructedText = extractTextFromTokens(tokens);
@@ -1229,7 +1217,7 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("sensitive");
List<Object> tokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, false);
String reconstructedText = extractTextFromTokens(tokens);
@@ -1243,7 +1231,7 @@ class RedactControllerTest {
void shouldWriteTokensToNewContentStream() throws Exception {
List<Object> tokens = createSampleTokenList();
textRedactionService.writeFilteredContentStream(realDocument, realPage, tokens);
redactController.writeFilteredContentStream(realDocument, realPage, tokens);
assertNotNull(realPage.getContents(), "Page should have content stream");
@@ -1261,7 +1249,7 @@ class RedactControllerTest {
assertDoesNotThrow(
() ->
textRedactionService.writeFilteredContentStream(
redactController.writeFilteredContentStream(
realDocument, realPage, emptyTokens));
assertNotNull(realPage.getContents(), "Page should still have content stream");
@@ -1274,7 +1262,7 @@ class RedactControllerTest {
String originalContent = extractTextFromModifiedPage(realPage);
List<Object> newTokens = createSampleTokenList();
textRedactionService.writeFilteredContentStream(realDocument, realPage, newTokens);
redactController.writeFilteredContentStream(realDocument, realPage, newTokens);
String newContent = extractTextFromModifiedPage(realPage);
assertNotEquals(originalContent, newContent, "Content stream should be replaced");
@@ -1285,7 +1273,7 @@ class RedactControllerTest {
void shouldCreateWidthMatchingPlaceholder() {
String originalText = "confidential";
String placeholder =
textRedactionService.createPlaceholderWithFont(
redactController.createPlaceholderWithFont(
originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA));
assertEquals(
@@ -1299,7 +1287,7 @@ class RedactControllerTest {
void shouldHandleSpecialCharactersInPlaceholder() {
String originalText = "café naïve";
String placeholder =
textRedactionService.createPlaceholderWithFont(
redactController.createPlaceholderWithFont(
originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA));
assertEquals(originalText.length(), placeholder.length());
@@ -1315,10 +1303,10 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("secret");
List<Object> filteredTokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, false);
textRedactionService.writeFilteredContentStream(realDocument, realPage, filteredTokens);
redactController.writeFilteredContentStream(realDocument, realPage, filteredTokens);
assertNotNull(realPage.getContents());
String finalText = extractTextFromModifiedPage(realPage);
@@ -1334,7 +1322,7 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("confidential");
List<Object> filteredTokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, false);
long filteredPositioning =
@@ -1389,7 +1377,7 @@ class RedactControllerTest {
Set<String> targetWords = Set.of("confidential");
List<Object> tokens =
textRedactionService.createTokensWithoutTargetText(
redactController.createTokensWithoutTargetText(
realDocument, realPage, targetWords, false, false);
assertNotNull(tokens);
@@ -1416,12 +1404,14 @@ class RedactControllerTest {
@Test
@DisplayName("Should handle documents with multiple text blocks")
void shouldHandleDocumentsWithMultipleTextBlocks() throws Exception {
// Create a document with multiple text blocks
realPage = new PDPage(PDRectangle.A4);
while (realDocument.getNumberOfPages() > 0) {
realDocument.removePage(0);
}
realDocument.addPage(realPage);
// Create resources
PDResources resources = new PDResources();
resources.put(
COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
@@ -1,430 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.SPDF.model.PDFText;
import stirling.software.SPDF.pdf.parser.PageColumnLayout;
/**
* Integration tests for {@link RedactExecuteService#collectRangeBlocks(PDDocument, String, String,
* Map)}. Each test builds a synthetic PDF (single-column or two-column) with text-positioning that
* matches what a real document would produce, then asserts that the redaction range produces blocks
* confined to the expected X/Y region.
*/
class RedactExecuteServiceTest {
private static final float PAGE_WIDTH = PDRectangle.LETTER.getWidth(); // 612
private static final float PAGE_HEIGHT = PDRectangle.LETTER.getHeight(); // 792
private static final float LEFT_X = 72f;
private static final float RIGHT_X = 330f;
private static final float COL_WIDTH = 220f;
private static final float LINE_HEIGHT = 14f;
private static final float TOP_Y = PAGE_HEIGHT - 80f;
private static final float FONT_SIZE = 11f;
private final RedactExecuteService service =
new RedactExecuteService(null, null, new TextRedactionService());
@Nested
@DisplayName("Single-column documents")
class SingleColumn {
@Test
void redactBetweenMarkers_inclusive() throws IOException {
try (PDDocument doc = buildSingleColumnDoc()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks =
service.collectRangeBlocks(doc, "START-HERE", "STOP-HERE", cache);
assertThat(blocks)
.as("blocks should be produced for single-column range")
.isNotEmpty();
// Blocks are in screen coords (top-left, Y down). START-HERE is drawn at the top
// of the page; STOP-HERE four lines below. Screen Y grows downward, so the
// anchors' screen-Y tops sit roughly around screenTop(0) and screenTop(4).
// The end anchor is inclusive, so blocks may extend to the bottom of line 4.
float screenTopOfStart = screenTopOfLine(0);
float screenBottomOfEnd = screenTopOfLine(4) + LINE_HEIGHT;
for (PDFText block : blocks) {
assertThat(block.getY1())
.as("block top must be at or below the start anchor's top")
.isGreaterThanOrEqualTo(screenTopOfStart - 1f);
assertThat(block.getY2())
.as(
"block bottom must not extend past the end anchor's bottom (end is inclusive)")
.isLessThanOrEqualTo(screenBottomOfEnd + 1f);
assertThat(block.getX2())
.as("block should not extend into a hypothetical right column")
.isLessThan(PAGE_WIDTH / 2f + 50f);
}
}
}
@Test
void missingStartString_noBlocks() throws IOException {
try (PDDocument doc = buildSingleColumnDoc()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks =
service.collectRangeBlocks(doc, "MISSING-START", "STOP-HERE", cache);
assertThat(blocks).isEmpty();
}
}
@Test
void cvStyleHeadingPlusRightAlignedDate_stillTreatedAsSingleColumn() throws IOException {
// CV-style page: single-column body, but each section heading shares its row with a
// right-aligned date. The X-gap splitter emits the heading and the date as separate
// line boxes; this must NOT trip 2-column detection (the date is too narrow to be a
// real column), otherwise the cross-page redaction predicate over-includes wrong
// regions.
try (PDDocument doc = buildCvStyleDoc()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks =
service.collectRangeBlocks(doc, "SECTION-A", "SECTION-C", cache);
assertThat(blocks)
.as("CV-style redaction between section headings must produce blocks")
.isNotEmpty();
PageColumnLayout layout = cache.get(0);
assertThat(layout.columnCount())
.as("CV-style page with heading+date rows must remain single-column")
.isEqualTo(1);
}
}
@Test
void punctuationDriftInAnchors_stillMatchesViaTolerantFallback() throws IOException {
// Simulates the LLM paraphrasing the heading by inserting a colon that isn't in the
// source ("#3 Character substitution" → "#3: Character substitution"). The
// punctuation-tolerant regex fallback should still find the line.
try (PDDocument doc = buildHeadingPdf()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks =
service.collectRangeBlocks(
doc, "#3: Character substitution", "#6: Image resolution", cache);
assertThat(blocks)
.as("anchor with extra punctuation should still resolve via fallback")
.isNotEmpty();
}
}
}
@Nested
@DisplayName("Two-column documents")
class TwoColumn {
@Test
void rangeInLeftColumn_redactsOnlyLeftColumn() throws IOException {
try (PDDocument doc = buildTwoColumnDoc()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks = service.collectRangeBlocks(doc, "L-START", "L-END", cache);
assertThat(blocks).as("left-only range must produce blocks").isNotEmpty();
float gutterMid = (LEFT_X + COL_WIDTH + RIGHT_X) / 2f;
for (PDFText block : blocks) {
float midX = (block.getX1() + block.getX2()) / 2f;
assertThat(midX)
.as("every block must sit in the left column, never the right")
.isLessThan(gutterMid);
}
}
}
@Test
void rangeInRightColumn_redactsOnlyRightColumn() throws IOException {
try (PDDocument doc = buildTwoColumnDoc()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks = service.collectRangeBlocks(doc, "R-START", "R-END", cache);
assertThat(blocks).as("right-only range must produce blocks").isNotEmpty();
float gutterMid = (LEFT_X + COL_WIDTH + RIGHT_X) / 2f;
for (PDFText block : blocks) {
float midX = (block.getX1() + block.getX2()) / 2f;
assertThat(midX)
.as("every block must sit in the right column, never the left")
.isGreaterThan(gutterMid);
}
}
}
@Test
void twoColumnWithTocAbove_pairsAcrossColumns() throws IOException {
// Reproduces magic.pdf-style stacked layout: a multi-line TOC near the top, then a
// 2-column body where the start anchor is in left col (lower screen Y) and the end
// anchor is in right col (higher screen Y). Original pairing failed here because
// end.y < start.y in screen coords.
try (PDDocument doc = buildTwoColumnWithTocDoc()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks =
service.collectRangeBlocks(doc, "BODY-L-3", "BODY-R-1", cache);
assertThat(blocks)
.as("cross-column body redaction must produce blocks despite stacked TOC")
.isNotEmpty();
}
}
@Test
void crossColumnReadingOrder_leftBottomToRightTop_producesBothSides() throws IOException {
// This is the case the original code couldn't handle at all: end Y < start Y.
try (PDDocument doc = buildTwoColumnDoc()) {
Map<Integer, PageColumnLayout> cache = new HashMap<>();
List<PDFText> blocks =
service.collectRangeBlocks(doc, "L-MIDDLE", "R-MIDDLE", cache);
assertThat(blocks)
.as("cross-column range must produce blocks, not be silently dropped")
.isNotEmpty();
float gutterMid = (LEFT_X + COL_WIDTH + RIGHT_X) / 2f;
boolean sawLeft = false;
boolean sawRight = false;
for (PDFText block : blocks) {
float midX = (block.getX1() + block.getX2()) / 2f;
if (midX < gutterMid) sawLeft = true;
else sawRight = true;
}
assertThat(sawLeft).as("left column should contain at least one block").isTrue();
assertThat(sawRight).as("right column should contain at least one block").isTrue();
}
}
}
// ── document fixtures ────────────────────────────────────────────────────────────────────────
/**
* Single-column page laid out as one column starting at LEFT_X. Lines: 0: START-HERE (start
* anchor) 1: line one 2: line two 3: line three 4: STOP-HERE (end anchor) 5: line five (must
* NOT be redacted)
*/
private PDDocument buildSingleColumnDoc() throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
String[] lines = {
"START-HERE", "line one", "line two", "line three", "STOP-HERE", "line five"
};
for (int i = 0; i < lines.length; i++) {
cs.beginText();
cs.newLineAtOffset(LEFT_X, yForLine(i));
cs.showText(lines[i]);
cs.endText();
}
}
return doc;
}
/**
* Two-column page. Lines per column, top to bottom: Left: L-TOP, L-START, L-MIDDLE, L-END,
* L-BOTTOM Right: R-TOP, R-MIDDLE, R-START, R-END, R-BOTTOM
*/
private PDDocument buildTwoColumnDoc() throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
// Body lines are padded to make each column genuinely wide enough that column
// detection (which ignores narrow lines) treats both sides as real columns.
String fill = " " + "x".repeat(26);
String[] left = {
"L-TOP" + fill,
"L-START" + fill,
"L-MIDDLE" + fill,
"L-END" + fill,
"L-BOTTOM" + fill
};
String[] right = {
"R-TOP" + fill,
"R-MIDDLE" + fill,
"R-START" + fill,
"R-END" + fill,
"R-BOTTOM" + fill
};
for (int i = 0; i < left.length; i++) {
cs.beginText();
cs.newLineAtOffset(LEFT_X, yForLine(i));
cs.showText(left[i]);
cs.endText();
}
// Aligned baselines per row (IEEE template style) — AllTextLineExtractor must split
// these at the column gap rather than merge same-row left+right glyphs into a wide
// box.
for (int i = 0; i < right.length; i++) {
cs.beginText();
cs.newLineAtOffset(RIGHT_X, yForLine(i));
cs.showText(right[i]);
cs.endText();
}
}
return doc;
}
/**
* Single-column page with feature headings: #1..#7 each followed by body text. The PDF text is
* exactly "#3 Character substitution" (no colon) — the test then queries with a colon to
* exercise the punctuation-tolerant fallback.
*/
private PDDocument buildHeadingPdf() throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
String[] lines = {
"#1 Auto layout",
"Body about auto layout.",
"#2 Smart selection",
"Body about smart selection.",
"#3 Character substitution",
"Body about character substitution.",
"#4 Rounded borders",
"Body about rounded borders.",
"#5 Auto contrast",
"Body about auto contrast.",
"#6 Image resolution",
"Body about image resolution.",
"#7 Columns",
"Body about columns."
};
for (int i = 0; i < lines.length; i++) {
cs.beginText();
cs.newLineAtOffset(LEFT_X, yForLine(i));
cs.showText(lines[i]);
cs.endText();
}
}
return doc;
}
/**
* Two-column page like {@code magic.pdf}: a few full-width header lines, a 2-column TOC stacked
* on top of the 2-column body, where TOC's right half lives inside what would otherwise be the
* body's gutter. Body left column has BODY-L-1..3, right column has BODY-R-1..3.
*/
private PDDocument buildTwoColumnWithTocDoc() throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
// Header — full width, lines 0..1.
for (int i = 0; i < 2; i++) {
cs.beginText();
cs.newLineAtOffset(LEFT_X, yForLine(i));
cs.showText("FULL WIDTH HEADER LINE " + i + " ACROSS BOTH COLUMNS OF THE PAGE");
cs.endText();
}
// TOC, 2 columns of entries. TOC right half sits where the body gutter would be —
// exactly the layout that broke the histogram-based detector on magic.pdf.
float tocLeftX = 101f;
float tocRightX = 230f;
for (int i = 0; i < 5; i++) {
float y = yForLine(3 + i);
cs.beginText();
cs.newLineAtOffset(tocLeftX, y);
cs.showText("TOC entry left " + i);
cs.endText();
cs.beginText();
cs.newLineAtOffset(tocRightX, y);
cs.showText("TOC entry right " + i);
cs.endText();
}
// Body — 2-column with aligned baselines per row (IEEE-style).
String fill = " " + "x".repeat(26);
String[] bodyLeft = {"BODY-L-1" + fill, "BODY-L-2" + fill, "BODY-L-3" + fill};
String[] bodyRight = {"BODY-R-1" + fill, "BODY-R-2" + fill, "BODY-R-3" + fill};
for (int i = 0; i < bodyLeft.length; i++) {
cs.beginText();
cs.newLineAtOffset(LEFT_X, yForLine(10 + i));
cs.showText(bodyLeft[i]);
cs.endText();
cs.beginText();
cs.newLineAtOffset(RIGHT_X, yForLine(10 + i));
cs.showText(bodyRight[i]);
cs.endText();
}
}
return doc;
}
/**
* CV-style page: single-column body with a few section headings, each followed on the same
* baseline by a right-aligned date string. {@link AllTextLineExtractor} will split each
* heading+date row into two line boxes; column detection must reject this as a fake two-column
* layout because the dates are too narrow to be a real column body.
*/
private PDDocument buildCvStyleDoc() throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
float dateX = PAGE_WIDTH - 144f; // right-aligned dates near the right margin
// Section A: heading + date, then 3 body lines.
writeAt(cs, LEFT_X, yForLine(0), "SECTION-A");
writeAt(cs, dateX, yForLine(0), "Jan 2020");
writeAt(cs, LEFT_X, yForLine(1), "Body line A1 with enough width to look like body");
writeAt(cs, LEFT_X, yForLine(2), "Body line A2 with enough width to look like body");
writeAt(cs, LEFT_X, yForLine(3), "Body line A3 with enough width to look like body");
// Section B (in the redact range): heading + date + 3 body lines.
writeAt(cs, LEFT_X, yForLine(5), "SECTION-B");
writeAt(cs, dateX, yForLine(5), "Feb 2021");
writeAt(cs, LEFT_X, yForLine(6), "Body line B1 with enough width to look like body");
writeAt(cs, LEFT_X, yForLine(7), "Body line B2 with enough width to look like body");
writeAt(cs, LEFT_X, yForLine(8), "Body line B3 with enough width to look like body");
// Section C (end anchor): heading + date.
writeAt(cs, LEFT_X, yForLine(10), "SECTION-C");
writeAt(cs, dateX, yForLine(10), "Mar 2022");
}
return doc;
}
private static void writeAt(PDPageContentStream cs, float x, float y, String text)
throws IOException {
cs.beginText();
cs.newLineAtOffset(x, y);
cs.showText(text);
cs.endText();
}
/** PDF user-space Y baseline for line index {@code i} (0-based, top to bottom). */
private static float yForLine(int lineIndex) {
return TOP_Y - lineIndex * LINE_HEIGHT;
}
/** Approximate screen-Y of the top of line {@code i} (top-left origin). */
private static float screenTopOfLine(int lineIndex) {
// baseline_pdf → baseline_screen flips against page height; glyph top ≈ baseline - font
// size.
return PAGE_HEIGHT - yForLine(lineIndex) - FONT_SIZE;
}
}
@@ -1,479 +0,0 @@
package stirling.software.common.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.StickyMissRecorder;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
/**
* Sticky-410 ownership contract for {@link JobController}: peer-owned jobs return 410 Gone with
* ownedBy/currentNode fields; locally-owned and no-entry cases return 200. FileStorage is never
* touched on the 410 path. Manual mock construction so tests can vary backplane/jobstore combos.
*/
class JobControllerOwnershipTest {
private TaskManager taskManager;
private FileStorage fileStorage;
private JobQueue jobQueue;
private HttpServletRequest request;
private JobOwnershipService jobOwnershipService;
private ClusterBackplane clusterBackplane;
private JobStore jobStore;
private StickyMissRecorder stickyMissRecorder;
private static final String JOB_ID = "job-42";
private static final String FILE_ID = "file-abc";
private static final String LOCAL_NODE = "node-self";
private static final String PEER_NODE = "node-peer";
@BeforeEach
void setUp() {
taskManager = mock(TaskManager.class);
fileStorage = mock(FileStorage.class);
jobQueue = mock(JobQueue.class);
request = mock(HttpServletRequest.class);
jobOwnershipService = mock(JobOwnershipService.class);
clusterBackplane = mock(ClusterBackplane.class);
jobStore = mock(JobStore.class);
stickyMissRecorder = mock(StickyMissRecorder.class);
}
private JobController makeController(ClusterBackplane backplane, JobStore store) {
JobController c =
new JobController(taskManager, fileStorage, jobQueue, request, backplane, store);
ReflectionTestUtils.setField(c, "stickyMissRecorder", stickyMissRecorder);
return c;
}
private JobController makeController() {
return makeController(clusterBackplane, jobStore);
}
private JobStoreEntry entryOwnedBy(String ownerNodeId) {
return new JobStoreEntry(
JOB_ID,
JobStoreEntry.JobState.COMPLETE,
ownerNodeId,
Instant.now(),
Instant.now(),
null,
List.of(FILE_ID),
Map.of());
}
private JobResult completedJobWithFile() {
JobResult result = new JobResult();
result.setJobId(JOB_ID);
// completeWithSingleFile populates the resultFiles list, sets complete=true,
// and sets completedAt - all required for the getJobResult single-file branch.
result.completeWithSingleFile(FILE_ID, "out.pdf", "application/pdf", 7L);
return result;
}
@Test
@DisplayName(
"downloadFile peer-owned → full sticky-410 contract"
+ " (status + Retry-After + payload + metric + storage untouched)")
void downloadFile_peerOwned_fullStickyContract() throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
assertEquals(HttpStatus.GONE, response.getStatusCode());
assertEquals("0", response.getHeaders().getFirst("Retry-After"));
assertInstanceOf(Map.class, response.getBody());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertEquals(3, body.size(), "exactly: message, ownedBy, currentNode");
assertEquals(PEER_NODE, body.get("ownedBy"));
assertEquals(LOCAL_NODE, body.get("currentNode"));
assertNotNull(body.get("message"));
assertTrue(((String) body.get("message")).toLowerCase().contains("retry"));
assertNull(body.get("internalSecret"));
assertNull(body.get("filePath"));
verify(stickyMissRecorder).recordStickyMiss();
verify(fileStorage, never()).retrieveBytes(FILE_ID);
}
private static Stream<Arguments> downloadHappyPathScenarios() {
return Stream.of(
Arguments.of("locallyOwned", LOCAL_NODE, true),
Arguments.of("noJobStoreEntry", null, false),
Arguments.of("blankOwningNodeId", "", true));
}
@ParameterizedTest(name = "downloadFile {0} -> 200, no sticky-miss")
@MethodSource("downloadHappyPathScenarios")
void downloadFile_happyPath_returnsOkAndNoMetric(
String scenario, String ownerNodeId, boolean entryPresent) throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID))
.thenReturn(
entryPresent ? Optional.of(entryOwnedBy(ownerNodeId)) : Optional.empty());
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
assertEquals(HttpStatus.OK, response.getStatusCode(), scenario);
verify(fileStorage).retrieveBytes(FILE_ID);
verify(stickyMissRecorder, never()).recordStickyMiss();
}
@Test
@DisplayName("getJobResult: locally-owned single-file result → reads from FileStorage, 200 OK")
void getJobResult_singleFile_locallyOwned_readsFromStorage() throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(taskManager.getJobResult(JOB_ID)).thenReturn(completedJobWithFile());
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(LOCAL_NODE)));
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
ResponseEntity<?> response = makeController().getJobResult(JOB_ID);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
private enum Endpoint {
DOWNLOAD_FILE,
GET_JOB_RESULT,
GET_JOB_STATUS,
GET_JOB_FILES,
GET_FILE_METADATA,
CANCEL_JOB
}
private static Stream<Arguments> peerOwned410Scenarios() {
return Stream.of(
Arguments.of(Endpoint.DOWNLOAD_FILE),
Arguments.of(Endpoint.GET_JOB_RESULT),
Arguments.of(Endpoint.GET_JOB_STATUS),
Arguments.of(Endpoint.GET_JOB_FILES),
Arguments.of(Endpoint.GET_FILE_METADATA),
Arguments.of(Endpoint.CANCEL_JOB));
}
@ParameterizedTest(name = "{0} peer-owned -> 410, ownedBy=peer, metric++")
@MethodSource("peerOwned410Scenarios")
void endpoint_peerOwned_returns410(Endpoint endpoint) throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
switch (endpoint) {
case DOWNLOAD_FILE, GET_FILE_METADATA ->
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
case GET_JOB_RESULT ->
when(taskManager.getJobResult(JOB_ID)).thenReturn(completedJobWithFile());
case GET_JOB_STATUS, GET_JOB_FILES ->
when(taskManager.getJobResult(JOB_ID)).thenReturn(null);
case CANCEL_JOB -> {
when(jobQueue.isJobQueued(JOB_ID)).thenReturn(false);
when(taskManager.getJobResult(JOB_ID)).thenReturn(null);
}
}
ResponseEntity<?> response =
switch (endpoint) {
case DOWNLOAD_FILE -> makeController().downloadFile(FILE_ID);
case GET_JOB_RESULT -> makeController().getJobResult(JOB_ID);
case GET_JOB_STATUS -> makeController().getJobStatus(JOB_ID);
case GET_JOB_FILES -> makeController().getJobFiles(JOB_ID);
case GET_FILE_METADATA -> makeController().getFileMetadata(FILE_ID);
case CANCEL_JOB -> makeController().cancelJob(JOB_ID);
};
assertEquals(HttpStatus.GONE, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertEquals(PEER_NODE, body.get("ownedBy"));
assertEquals(LOCAL_NODE, body.get("currentNode"));
verify(stickyMissRecorder).recordStickyMiss();
verify(fileStorage, never()).retrieveBytes(FILE_ID);
if (endpoint == Endpoint.CANCEL_JOB) {
verify(taskManager, never()).setError(JOB_ID, "Job was cancelled by user");
}
}
private static Stream<Arguments> unknownJob404Scenarios() {
return Stream.of(Arguments.of(Endpoint.GET_JOB_STATUS), Arguments.of(Endpoint.CANCEL_JOB));
}
@ParameterizedTest(name = "{0} unknown jobId -> 404 (not 410), no metric")
@MethodSource("unknownJob404Scenarios")
void endpoint_unknownJob_returns404(Endpoint endpoint) {
when(taskManager.getJobResult(JOB_ID)).thenReturn(null);
when(jobStore.get(JOB_ID)).thenReturn(Optional.empty());
if (endpoint == Endpoint.CANCEL_JOB) {
when(jobQueue.isJobQueued(JOB_ID)).thenReturn(false);
}
ResponseEntity<?> response =
switch (endpoint) {
case GET_JOB_STATUS -> makeController().getJobStatus(JOB_ID);
case CANCEL_JOB -> makeController().cancelJob(JOB_ID);
default -> throw new IllegalArgumentException(endpoint.name());
};
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
verify(stickyMissRecorder, never()).recordStickyMiss();
}
@Test
@DisplayName("Single-instance install (no ClusterBackplane bean): no 410, no NPE")
void singleInstance_noClusterBackplane_noGoneResponse() throws Exception {
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
ResponseEntity<?> response = makeController(null, jobStore).downloadFile(FILE_ID);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(fileStorage).retrieveBytes(FILE_ID);
}
@Test
@DisplayName("Single-instance install (no JobStore bean): no 410, no NPE")
void singleInstance_noJobStore_noGoneResponse() throws Exception {
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
ResponseEntity<?> response = makeController(clusterBackplane, null).downloadFile(FILE_ID);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(fileStorage).retrieveBytes(FILE_ID);
}
@Test
@DisplayName("Single-instance (no StickyMissRecorder bean) → no NPE, still 200 OK")
void noStickyMissRecorder_works() throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(LOCAL_NODE)));
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
JobController c = makeController();
ReflectionTestUtils.setField(c, "stickyMissRecorder", null);
ResponseEntity<?> response = c.downloadFile(FILE_ID);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName(
"cluster-mode but localNodeId is null → no NPE; 410 because owner is set and"
+ " differs from blank")
void clusterBackplanePresent_butLocalNodeIdNull_falsBackGracefully() throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(null);
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
// We still 410: owner is "node-peer", local is null → they don't match. Rather than
// silently 200-from-wrong-disk (which would serve garbage), we surface the mismatch.
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
assertEquals(HttpStatus.GONE, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertEquals("", body.get("currentNode"), "blank when localNodeId is null");
assertEquals(PEER_NODE, body.get("ownedBy"));
}
@Test
@DisplayName("Owner returns 410 even when JobOwnershipService allows access (orthogonal)")
void ownershipService_passes_butStickyStillReturns410() throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(true);
JobController c = makeController();
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
ResponseEntity<?> response = c.downloadFile(FILE_ID);
assertEquals(HttpStatus.GONE, response.getStatusCode());
}
@Test
@DisplayName(
"downloadFile: peer-owned + ownership-denied → 410 (NOT 403) so we don't leak"
+ " file existence")
void downloadFile_peerOwned_ownershipDenied_returns410NotForbidden() throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(false);
JobController c = makeController();
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
ResponseEntity<?> response = c.downloadFile(FILE_ID);
assertEquals(HttpStatus.GONE, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertEquals(PEER_NODE, body.get("ownedBy"));
verify(fileStorage, never()).retrieveBytes(FILE_ID);
}
@Test
@DisplayName(
"getJobStatus: peer-owned + ownership-denied → 410 (NOT 403) so we don't leak"
+ " job existence")
void getJobStatus_peerOwned_ownershipDenied_returns410NotForbidden() {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(false);
JobController c = makeController();
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
ResponseEntity<?> response = c.getJobStatus(JOB_ID);
assertEquals(HttpStatus.GONE, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertEquals(PEER_NODE, body.get("ownedBy"));
}
@Test
@DisplayName(
"cancelJob: peer-owned + ownership-denied → 410 (NOT 403) so we don't leak job"
+ " existence")
void cancelJob_peerOwned_ownershipDenied_returns410NotForbidden() {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(jobQueue.isJobQueued(JOB_ID)).thenReturn(false);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(false);
JobController c = makeController();
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
ResponseEntity<?> response = c.cancelJob(JOB_ID);
assertEquals(HttpStatus.GONE, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertEquals(PEER_NODE, body.get("ownedBy"));
verify(taskManager, never()).setError(JOB_ID, "Job was cancelled by user");
}
@Test
@DisplayName(
"guardNonOwner caches JobStore.get within TTL window: second call same jobId hits"
+ " cache, not Valkey")
void guardNonOwner_cachesJobStoreLookupWithinTtl() throws Exception {
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(LOCAL_NODE)));
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
JobController c = makeController();
c.downloadFile(FILE_ID);
c.downloadFile(FILE_ID);
c.downloadFile(FILE_ID);
verify(jobStore, times(1)).get(JOB_ID);
}
@Test
@DisplayName(
"guardNonOwner: JobStore.get throws (Valkey timeout) → falls through to local-disk"
+ " path, no 500 leaks to caller")
void guardNonOwner_jobStoreException_fallsThroughToLocalPath() throws Exception {
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
when(jobStore.get(JOB_ID)).thenThrow(new RuntimeException("Valkey command timeout"));
when(taskManager.getJobResult(JOB_ID)).thenReturn(completedJobWithFile());
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(fileStorage).retrieveBytes(FILE_ID);
verify(stickyMissRecorder, never()).recordStickyMiss();
}
@Test
@DisplayName("backplane down + job NOT held locally → 503 retryable (not a misleading 404)")
void jobEndpoint_backplaneDown_notLocal_returns503() {
when(jobStore.get(JOB_ID)).thenThrow(new RuntimeException("Valkey command timeout"));
when(taskManager.getJobResult(JOB_ID)).thenReturn(null);
ResponseEntity<?> response = makeController().getJobStatus(JOB_ID);
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode());
assertEquals("1", response.getHeaders().getFirst("Retry-After"));
verify(stickyMissRecorder, never()).recordStickyMiss();
}
@Test
@DisplayName("backplane down but job held locally → owner still serves (not 503)")
void jobEndpoint_backplaneDown_local_servesLocally() {
when(jobStore.get(JOB_ID)).thenThrow(new RuntimeException("Valkey command timeout"));
when(taskManager.getJobResult(JOB_ID)).thenReturn(completedJobWithFile());
ResponseEntity<?> response = makeController().getJobStatus(JOB_ID);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName(
"downloadFile: findJobKeyByFileId throws (backplane down) → 503 + Retry-After,"
+ " not 404/500, storage untouched")
void downloadFile_findJobKeyThrows_returns503Retryable() throws Exception {
when(taskManager.findJobKeyByFileId(FILE_ID))
.thenThrow(new RuntimeException("Valkey command timeout"));
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode());
assertEquals("1", response.getHeaders().getFirst("Retry-After"));
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertTrue(((String) body.get("message")).toLowerCase().contains("unavailable"));
verify(fileStorage, never()).retrieveBytes(FILE_ID);
}
@Test
@DisplayName(
"getFileMetadata: findJobKeyByFileId throws (backplane down) → 503 + Retry-After,"
+ " not 404/500")
void getFileMetadata_findJobKeyThrows_returns503Retryable() throws Exception {
when(taskManager.findJobKeyByFileId(FILE_ID))
.thenThrow(new RuntimeException("Valkey command timeout"));
ResponseEntity<?> response = makeController().getFileMetadata(FILE_ID);
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode());
assertEquals("1", response.getHeaders().getFirst("Retry-After"));
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertTrue(((String) body.get("message")).toLowerCase().contains("unavailable"));
verify(fileStorage, never()).retrieveBytes(FILE_ID);
}
}
@@ -19,8 +19,6 @@ import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobOwnershipService;
@@ -39,10 +37,6 @@ class JobControllerTest {
@Mock private JobOwnershipService jobOwnershipService;
@Mock private ClusterBackplane clusterBackplane;
@Mock private JobStore jobStore;
private MockHttpSession session;
@InjectMocks private JobController controller;
+2 -10
View File
@@ -55,13 +55,8 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-mail'
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
// Lettuce-backed Bucket4j ProxyManager used by ValkeyRateLimitStore for cluster-wide
// token-bucket rate limiting (parity with in-process Bucket4j semantics; no fixed-window
// boundary doubling).
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.19.0'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.18.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
@@ -82,12 +77,9 @@ dependencies {
implementation "software.amazon.awssdk:s3:$awsSdkVersion"
implementation "software.amazon.awssdk:url-connection-client:$awsSdkVersion"
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
// manually-started instances. Tests skip cleanly when Docker is unavailable.
testImplementation "org.testcontainers:testcontainers:$testcontainersMinioVersion"
testImplementation "org.testcontainers:minio:$testcontainersMinioVersion"
testImplementation "org.testcontainers:localstack:$testcontainersMinioVersion"
testImplementation "org.testcontainers:junit-jupiter:$testcontainersMinioVersion"
testImplementation "org.testcontainers:localstack:$testcontainersMinioVersion"
}
tasks.register('prepareKotlinBuildScriptModel') {}
@@ -1,40 +0,0 @@
package stirling.software.proprietary.cluster;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
/**
* Runtime license gate for cluster mode. Cluster mode requires a SERVER or ENTERPRISE license; the
* SaaS flavor bypasses (no {@code runningProOrHigher} bean is published). The Valkey connection
* config {@code @DependsOn} this bean, so it runs before any Valkey bean is constructed.
*/
@Configuration
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
@Slf4j
public class ClusterLicenseGate {
@Autowired(required = false)
@Qualifier("runningProOrHigher")
private Boolean runningProOrHigher;
@PostConstruct
void verifyLicense() {
if (runningProOrHigher == null) {
return; // saas flavor - licensed via Stripe elsewhere
}
if (!runningProOrHigher) {
throw new IllegalStateException(
"Cluster mode (cluster.enabled=true) requires a SERVER or"
+ " ENTERPRISE license. Configure stirling.premium.key with a valid"
+ " license key (contact sales@stirlingpdf.com to obtain one), or set"
+ " cluster.enabled=false.");
}
log.info("Cluster license gate: SERVER/ENTERPRISE license verified, cluster mode allowed.");
}
}
@@ -1,113 +0,0 @@
package stirling.software.proprietary.cluster;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import stirling.software.common.cluster.StickyMissRecorder;
import stirling.software.common.model.ApplicationProperties;
/**
* Cluster operation metrics exposed via {@code /actuator/prometheus}. Registered only when cluster
* mode is on.
*/
@Component
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
public class ClusterMetrics implements StickyMissRecorder {
private final MeterRegistry registry;
private final ApplicationProperties applicationProperties;
private final Counter stickyMissTotal;
private final Counter rateLimitRejected;
private final Timer backplaneLatency;
private final Timer jobWaitSeconds;
// Per-lane queue depth gauges. Lanes are a fixed enum (FAST, SLOW, AI), so we register all
// three eagerly so dashboards never have a missing series.
private static final List<String> KNOWN_LANES = List.of("FAST", "SLOW", "AI");
private final ConcurrentHashMap<String, AtomicLong> queueDepth = new ConcurrentHashMap<>();
private final AtomicLong jobsInflight = new AtomicLong();
public ClusterMetrics(MeterRegistry registry, ApplicationProperties applicationProperties) {
this.registry = registry;
this.applicationProperties = applicationProperties;
this.stickyMissTotal =
Counter.builder("stirling_cluster_sticky_miss_total")
.description(
"Sticky-session misses: a download for a job whose result lives on"
+ " a peer node landed on this node. High sustained value means"
+ " LB affinity is broken.")
.register(registry);
this.rateLimitRejected =
Counter.builder("stirling_cluster_ratelimit_rejected_total")
.description("Cluster-wide rate limit rejections")
.register(registry);
this.backplaneLatency =
Timer.builder("stirling_cluster_backplane_latency_seconds")
.description("Backplane round-trip latency")
.register(registry);
this.jobWaitSeconds =
Timer.builder("stirling_cluster_job_wait_seconds")
.description("Time jobs spend queued before execution")
.register(registry);
Gauge.builder("stirling_cluster_jobs_inflight", jobsInflight, AtomicLong::doubleValue)
.description("Jobs currently in flight on this node")
.tag("node", applicationProperties.getCluster().resolvedNodeId())
.register(registry);
for (String lane : KNOWN_LANES) {
ensureLaneGauge(lane);
}
}
@Override
public void recordStickyMiss() {
stickyMissTotal.increment();
}
public void recordRateLimitReject() {
rateLimitRejected.increment();
}
public Timer backplaneLatency() {
return backplaneLatency;
}
public Timer jobWaitSeconds() {
return jobWaitSeconds;
}
public void incrementInflight() {
jobsInflight.incrementAndGet();
}
public void decrementInflight() {
jobsInflight.decrementAndGet();
}
public void setQueueDepth(String lane, long depth) {
ensureLaneGauge(lane).set(depth);
}
private AtomicLong ensureLaneGauge(String lane) {
return queueDepth.computeIfAbsent(
lane,
l -> {
AtomicLong holder = new AtomicLong();
Gauge.builder("stirling_cluster_queue_depth", holder, AtomicLong::doubleValue)
.description("Pending items in a job queue lane")
.tag("lane", l)
.register(registry);
return holder;
});
}
}
@@ -1,201 +0,0 @@
package stirling.software.proprietary.cluster;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterNode;
import stirling.software.common.cluster.InstanceRegistry;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
/**
* Registers the local node with {@link InstanceRegistry} on startup, refreshes the entry at 1/3 of
* the TTL, and deregisters cleanly on shutdown.
*
* <p>Implements {@link SmartLifecycle} with {@code getPhase() == Integer.MAX_VALUE} so Spring tears
* this bean down before {@code LettuceConnectionFactory} - deregister therefore runs while the
* Valkey connection is still alive.
*/
@Component
@Slf4j
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
public class ClusterNodeBootstrap implements SmartLifecycle {
private final Duration heartbeatTtl;
private final ApplicationProperties applicationProperties;
private final InstanceRegistry instanceRegistry;
@Value("${server.port:8080}")
private int serverPort;
private volatile String nodeId;
private volatile String internalAddress;
private volatile boolean running = false;
public ClusterNodeBootstrap(
ApplicationProperties applicationProperties, InstanceRegistry instanceRegistry) {
this.applicationProperties = applicationProperties;
this.instanceRegistry = instanceRegistry;
Cluster cluster = applicationProperties.getCluster();
// Default must match the @Scheduled fallback below AND the model default
// (ApplicationProperties.Cluster.Node.heartbeatIntervalMs = 5000); otherwise the TTL is
// computed from a different interval than the scheduler runs at and the 3x margin breaks.
long heartbeatMs =
cluster.getNode() == null ? 5000L : cluster.getNode().getHeartbeatIntervalMs();
// TTL = 3x heartbeat: tolerate two missed ticks before the node drops out of the registry.
this.heartbeatTtl = Duration.ofMillis(heartbeatMs * 3);
}
@EventListener(ApplicationReadyEvent.class)
public void registerOnStartup() {
nodeId = applicationProperties.getCluster().resolvedNodeId();
internalAddress = resolveInternalAddress();
registerSelf("register");
}
@Scheduled(fixedDelayString = "${cluster.node.heartbeat-interval-ms:5000}")
public void heartbeat() {
// Heartbeat-after-stop race: SmartLifecycle.stop() deregisters, but the @Scheduled
// tick keeps firing during a slow drain. Without this guard, the next tick re-registers
// the dead node and the entry resurfaces in the registry until TTL expiry.
if (!running) {
return;
}
if (nodeId == null) {
return; // not yet registered (startup race)
}
// Self-healing: register() is idempotent and re-populates every field, so a wiped
// Valkey (FLUSHALL, hash eviction) recovers on the next tick without operator action.
registerSelf("heartbeat");
}
private void registerSelf(String reason) {
try {
instanceRegistry.register(
new ClusterNode(nodeId, internalAddress, Instant.now(), role()), heartbeatTtl);
if ("register".equals(reason)) {
log.info(
"Cluster node registered: nodeId={}, internalAddress={}, role={}, ttl={}s",
nodeId,
internalAddress,
role(),
heartbeatTtl.toSeconds());
}
} catch (RuntimeException e) {
log.debug("Cluster {} failed for {}", reason, nodeId, e);
}
}
@Override
public void start() {
running = true;
}
@Override
public void stop() {
running = false;
if (nodeId == null) {
return;
}
try {
instanceRegistry.deregister(nodeId);
log.info("Cluster node deregistered: {}", nodeId);
} catch (RuntimeException e) {
// Registry entry will TTL-expire within heartbeatTtl anyway.
log.warn(
"Cluster deregister failed for {} (will TTL-expire within {}s): {}",
nodeId,
heartbeatTtl.toSeconds(),
e.getMessage());
}
}
@Override
public boolean isRunning() {
return running;
}
@Override
public int getPhase() {
return Integer.MAX_VALUE;
}
@Override
public boolean isAutoStartup() {
return true;
}
/**
* Resolve the address peers should hit. Order: explicit config -> {@code POD_IP} env (K8s
* downward API) -> JDK hostname -> fail loud (never silently fall back to a loopback).
*
* <p>Scheme is taken from {@code cluster.node.scheme} (default {@code http}). Set to {@code
* https} when nodes terminate TLS themselves; leave as {@code http} when an upstream LB
* terminates TLS and intra-cluster traffic is plain HTTP.
*/
private String resolveInternalAddress() {
Cluster cluster = applicationProperties.getCluster();
String configured =
cluster.getNode() == null ? null : cluster.getNode().getInternalAddress();
if (configured != null && !configured.isBlank()) {
return ensurePort(configured);
}
String podIp = System.getenv("POD_IP");
if (podIp != null && !podIp.isBlank()) {
return scheme() + "://" + podIp + ":" + serverPort;
}
try {
return scheme()
+ "://"
+ InetAddress.getLocalHost().getHostAddress()
+ ":"
+ serverPort;
} catch (UnknownHostException e) {
throw new IllegalStateException(
"Could not resolve this host's address for cluster registration; set"
+ " cluster.node.internal-address explicitly (or set POD_IP"
+ " in the Kubernetes downward API).",
e);
}
}
private String ensurePort(String addr) {
if (addr.startsWith("http://") || addr.startsWith("https://")) {
return addr;
}
if (addr.contains(":")) {
return scheme() + "://" + addr;
}
return scheme() + "://" + addr + ":" + serverPort;
}
private String scheme() {
Cluster cluster = applicationProperties.getCluster();
if (cluster.getNode() == null
|| cluster.getNode().getScheme() == null
|| cluster.getNode().getScheme().isBlank()) {
return "http";
}
String s = cluster.getNode().getScheme().trim().toLowerCase(Locale.ROOT);
return "https".equals(s) ? "https" : "http";
}
private String role() {
Cluster.NodeRole r = applicationProperties.getCluster().resolvedRole();
return r == null ? "BOTH" : r.name();
}
}
@@ -1,19 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
/**
* Composite condition: matches only when cluster.enabled=true AND cluster.backplane=valkey. Both
* checks are required (enabled alone may select the in-process backplane, which must not load
* Valkey beans); a single {@code @ConditionalOnExpression} keeps the guard in one place.
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ConditionalOnExpression(
"${cluster.enabled:false} and '${cluster.backplane:inprocess}'.equals('valkey')")
public @interface ConditionalOnValkeyBackplane {}
@@ -1,55 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
public class ValkeyClusterBackplane implements ClusterBackplane {
private final ApplicationProperties applicationProperties;
private final StringRedisTemplate template;
@Override
public boolean isHealthy() {
try {
// template.execute() borrows from the pool and returns the connection in a finally
// block - critical because isHealthy() is hit on every k8s liveness/readiness probe
// tick. Calling getConnectionFactory().getConnection() directly leaks the connection
// and exhausts the pool under monitoring load.
String pong = template.execute((RedisCallback<String>) connection -> connection.ping());
return "PONG".equalsIgnoreCase(pong);
} catch (RuntimeException ex) {
log.warn("Valkey backplane health check failed: {}", ex.getMessage());
return false;
}
}
@Override
public String backplaneType() {
return "valkey";
}
@Override
public String localNodeId() {
return applicationProperties.getCluster().resolvedNodeId();
}
/**
* Valkey TTL evicts job entries; local cleanup loop is redundant and would race with cluster
* state.
*/
@Override
public boolean shouldRunLocalCleanup() {
return false;
}
}
@@ -1,265 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import io.lettuce.core.RedisCommandExecutionException;
import io.lettuce.core.SslVerifyMode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
@Slf4j
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
@DependsOn("clusterLicenseGate")
public class ValkeyConnectionConfiguration {
private final ApplicationProperties applicationProperties;
@Bean(destroyMethod = "destroy")
@ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
public LettuceConnectionFactory valkeyConnectionFactory() {
Cluster cluster = applicationProperties.getCluster();
Endpoint endpoint = parseUrl(cluster.getValkey().getUrl());
RedisStandaloneConfiguration cfg =
new RedisStandaloneConfiguration(endpoint.host(), endpoint.port());
if (endpoint.username() != null) {
cfg.setUsername(endpoint.username());
}
if (endpoint.password() != null) {
cfg.setPassword(RedisPassword.of(endpoint.password()));
}
boolean skipCertVerification =
cluster.getValkey().getTls() != null
&& cluster.getValkey().getTls().isSkipCertVerification();
LettuceClientConfiguration clientConfig =
buildClientConfiguration(endpoint.tls(), skipCertVerification);
LettuceConnectionFactory factory = new LettuceConnectionFactory(cfg, clientConfig);
factory.afterPropertiesSet();
// Eager handshake with retry tolerates docker-compose DNS races; fails boot loudly
// if Valkey is genuinely unreachable.
eagerHandshake(factory, endpoint.host(), endpoint.port(), endpoint.tls());
log.info(
"Valkey connection configured: {}:{} tls={} verifyPeer={}",
endpoint.host(),
endpoint.port(),
endpoint.tls(),
endpoint.tls() ? clientConfig.getVerifyMode() : "n/a");
return factory;
}
/** Parsed connection endpoint; username/password are null when absent. */
record Endpoint(String host, int port, boolean tls, String username, String password) {}
/**
* Parses {@code redis://[user:password@]host[:port]} (or {@code rediss://} for TLS) into an
* {@link Endpoint}. Package-private and side-effect-free so URL handling is unit-testable.
*
* <ul>
* <li>Missing port defaults to 6379.
* <li>{@code rediss} scheme selects TLS.
* <li>Userinfo {@code :password@} (empty user) is treated as password-only auth against the
* default user, not a login with an empty username.
* <li>Reserved characters in the password ({@code @ : / # ?}) must be percent-encoded; {@link
* URI} parses them structurally otherwise (e.g. {@code #} starts the fragment).
* </ul>
*
* @throws IllegalStateException if the URL is blank, syntactically invalid, or has no host
*/
static Endpoint parseUrl(String url) {
if (url == null || url.isBlank()) {
throw new IllegalStateException("cluster.valkey.url must be set when backplane=valkey");
}
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException ex) {
throw new IllegalStateException(
"cluster.valkey.url is not a valid URI: " + url + " (" + ex.getMessage() + ")",
ex);
}
String host = uri.getHost();
if (host == null || host.isBlank()) {
throw new IllegalStateException(
"cluster.valkey.url has no host: "
+ url
+ " (expected redis://[user:password@]host[:port])");
}
boolean tls = "rediss".equalsIgnoreCase(uri.getScheme());
int port = uri.getPort() <= 0 ? 6379 : uri.getPort();
String username = null;
String password = null;
String userInfo = uri.getUserInfo();
if (userInfo != null) {
String[] parts = userInfo.split(":", 2);
if (parts.length == 2) {
username = parts[0].isEmpty() ? null : parts[0];
password = parts[1];
} else if (!parts[0].isBlank()) {
password = parts[0];
}
}
return new Endpoint(host, port, tls, username, password);
}
/**
* Package-private for testing. verifyPeer(FULL) is pinned explicitly so a Spring Data Redis
* default change cannot silently weaken our TLS handshake. skipCertVerification is dev-only.
*/
static LettuceClientConfiguration buildClientConfiguration(
boolean tls, boolean skipCertVerification) {
LettuceClientConfiguration.LettuceClientConfigurationBuilder clientBuilder =
LettuceClientConfiguration.builder();
// Bound every backplane command. Lettuce defaults to 60s; without this a partitioned or
// slow Valkey would stall hot-path calls (e.g. JobController.guardNonOwner -> jobStore.get
// on each request) for up to a minute, exhausting request threads. All backplane ops are
// non-blocking single commands, so a short timeout is safe.
clientBuilder.commandTimeout(Duration.ofSeconds(2));
if (tls) {
clientBuilder
.useSsl()
.verifyPeer(skipCertVerification ? SslVerifyMode.NONE : SslVerifyMode.FULL);
if (skipCertVerification) {
log.warn(
"Valkey TLS hostname/chain verification DISABLED via"
+ " cluster.valkey.tls.skip-cert-verification=true"
+ " - insecure, dev-only");
}
}
return clientBuilder.build();
}
/**
* 10 x 3s = 30s boot-time retry. Auth failures (WRONGPASS/NOAUTH/NOPERM) short-circuit
* immediately; only transport errors get the loop. Package-private for testing.
*/
static void eagerHandshake(
LettuceConnectionFactory factory, String host, int port, boolean tls) {
RuntimeException last = null;
for (int attempt = 1; attempt <= 10; attempt++) {
try {
String pong;
RedisConnection conn = factory.getConnection();
try {
pong = conn.ping();
} finally {
conn.close();
}
if (!"PONG".equalsIgnoreCase(pong)) {
throw new IllegalStateException(
"Valkey PING returned '" + pong + "' (expected PONG)");
}
if (attempt > 1) {
log.info("Valkey reachable after {} attempts", attempt);
}
return;
} catch (RuntimeException ex) {
if (isAuthFailure(ex)) {
factory.destroy();
throw new IllegalStateException(
"Valkey authentication failed for "
+ host
+ ":"
+ port
+ " (tls="
+ tls
+ "): "
+ rootAuthMessage(ex)
+ ". Check cluster.valkey.url credentials"
+ " (user/password and ACL permissions).",
ex);
}
last = ex;
log.warn(
"Valkey PING attempt {}/10 failed ({}:{}, tls={}): {}",
attempt,
host,
port,
tls,
ex.getMessage());
try {
Thread.sleep(3000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
factory.destroy();
throw new IllegalStateException(
"Valkey unreachable at boot after 10 attempts ("
+ host
+ ":"
+ port
+ ", tls="
+ tls
+ "): "
+ (last == null ? "no detail" : last.getMessage()),
last);
}
/**
* Walks the cause chain for WRONGPASS/NOAUTH/NOPERM replies. Spring Data Redis wraps Lettuce's
* RedisCommandExecutionException in RedisSystemException, so the auth signal may be one level
* down. No typed auth exception exists in spring-data-redis 4.0.5 / Lettuce 6.8.2.
*/
static boolean isAuthFailure(Throwable t) {
for (Throwable cur = t; cur != null; cur = cur.getCause()) {
if (cur instanceof RedisCommandExecutionException && hasAuthPrefix(cur.getMessage())) {
return true;
}
if (hasAuthPrefix(cur.getMessage())) {
return true;
}
if (cur.getCause() == cur) {
break;
}
}
return false;
}
private static boolean hasAuthPrefix(String message) {
if (message == null) {
return false;
}
String upper = message.toUpperCase(java.util.Locale.ROOT).stripLeading();
return upper.startsWith("WRONGPASS")
|| upper.startsWith("NOAUTH")
|| upper.startsWith("NOPERM");
}
private static String rootAuthMessage(Throwable t) {
for (Throwable cur = t; cur != null; cur = cur.getCause()) {
if (cur instanceof RedisCommandExecutionException && cur.getMessage() != null) {
return cur.getMessage();
}
if (cur.getCause() == cur) {
break;
}
}
return t.getMessage();
}
@Bean
@ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
public StringRedisTemplate valkeyTemplate(LettuceConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
@@ -1,102 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import java.time.Duration;
import java.util.Collections;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.DistributedLock;
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
@Slf4j
public class ValkeyDistributedLock implements DistributedLock {
private static final String PREFIX = "stirling:lock:";
private static final RedisScript<Long> RELEASE_SCRIPT =
new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
Long.class);
private static final RedisScript<Long> RENEW_SCRIPT =
new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end",
Long.class);
private final StringRedisTemplate template;
@Override
public Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime) {
String key = PREFIX + lockKey;
String value = UUID.randomUUID().toString();
Boolean ok = template.opsForValue().setIfAbsent(key, value, leaseTime);
if (Boolean.TRUE.equals(ok)) {
return Optional.of(new ValkeyHandle(template, key, value));
}
return Optional.empty();
}
private static final class ValkeyHandle implements LockHandle {
private final StringRedisTemplate template;
private final String key;
private final String value;
private boolean released;
ValkeyHandle(StringRedisTemplate template, String key, String value) {
this.template = template;
this.key = key;
this.value = value;
}
@Override
public synchronized void release() {
if (released) {
return;
}
released = true;
// Swallow + log: LockHandle is AutoCloseable, so release() runs from close() inside
// try-with-resources. An uncaught Valkey error here would mask the body's exception.
// The lease TTL-expires anyway, so a failed explicit release is safe.
try {
template.execute(RELEASE_SCRIPT, Collections.singletonList(key), value);
} catch (RuntimeException ex) {
log.warn(
"Lock release failed for {} (lease will TTL-expire): {}",
key,
ex.getMessage());
}
}
@Override
public synchronized boolean renew(Duration leaseTime) {
if (released) {
return false;
}
try {
Long result =
template.execute(
RENEW_SCRIPT,
Collections.singletonList(key),
value,
Long.toString(leaseTime.toMillis()));
return result != null && result == 1L;
} catch (RuntimeException ex) {
log.warn(
"Lock renew failed for {} (treated as lost lease): {}",
key,
ex.getMessage());
return false;
}
}
}
}
@@ -1,115 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.cluster.ClusterNode;
import stirling.software.common.cluster.InstanceRegistry;
/**
* Valkey-backed {@link InstanceRegistry}. Each node is stored as a hash with a TTL equal to the
* configured heartbeat TTL; the heartbeat re-arms the TTL.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
public class ValkeyInstanceRegistry implements InstanceRegistry {
private static final String PREFIX = "stirling:nodes:";
private final StringRedisTemplate template;
@Override
public void register(ClusterNode node, Duration heartbeatTtl) {
String key = PREFIX + node.nodeId();
long ttlMs = heartbeatTtl.toMillis();
Map<String, String> fields = new LinkedHashMap<>();
fields.put("nodeId", node.nodeId());
fields.put("internalAddress", node.internalAddress());
fields.put("role", node.role());
fields.put("lastHeartbeat", node.lastHeartbeat().toString());
// MULTI/EXEC so the hash fields and the TTL commit together. Without this, a crash
// between HSET and EXPIRE leaves the hash with no TTL: it never expires, masks the
// dead node as alive, and only a subsequent successful register() would re-arm it.
template.execute(
(RedisCallback<Object>)
connection -> {
connection.multi();
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
Map<byte[], byte[]> hashBytes = new LinkedHashMap<>();
for (Map.Entry<String, String> f : fields.entrySet()) {
hashBytes.put(
f.getKey().getBytes(StandardCharsets.UTF_8),
f.getValue().getBytes(StandardCharsets.UTF_8));
}
connection.hashCommands().hMSet(keyBytes, hashBytes);
connection.keyCommands().pExpire(keyBytes, ttlMs);
connection.exec();
return null;
});
}
@Override
public Optional<ClusterNode> lookup(String nodeId) {
return readNode(PREFIX + nodeId);
}
@Override
public Collection<ClusterNode> activeNodes() {
ScanOptions options = ScanOptions.scanOptions().match(PREFIX + "*").count(256).build();
List<ClusterNode> nodes = new ArrayList<>();
try (Cursor<String> cursor = template.scan(options)) {
while (cursor.hasNext()) {
readNode(cursor.next()).ifPresent(nodes::add);
}
}
return nodes;
}
@Override
public void deregister(String nodeId) {
template.delete(PREFIX + nodeId);
}
private Optional<ClusterNode> readNode(String key) {
Map<Object, Object> entries = template.opsForHash().entries(key);
if (entries == null || entries.isEmpty()) {
return Optional.empty();
}
Object nodeId = entries.get("nodeId");
if (nodeId == null) {
return Optional.empty();
}
Instant heartbeat = Instant.now();
Object hb = entries.get("lastHeartbeat");
if (hb != null) {
try {
heartbeat = Instant.parse(hb.toString());
} catch (RuntimeException ignored) {
// keep default
}
}
return Optional.of(
new ClusterNode(
nodeId.toString(),
String.valueOf(entries.getOrDefault("internalAddress", "")),
heartbeat,
String.valueOf(entries.getOrDefault("role", "BOTH"))));
}
}
@@ -1,297 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
/**
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
*
* <p><b>put() atomicity:</b> the hash fields, the per-job TTL, and the reverse-index entries are
* issued inside a single pipelined Redis transaction (MULTI/EXEC). A partial failure cannot leave
* the hash without a TTL or with half the file→job index entries written.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
@Slf4j
public class ValkeyJobStore implements JobStore {
private static final String JOB_PREFIX = "stirling:job:";
private static final String FILE_INDEX_PREFIX = "stirling:file2job:";
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<List<String>> LIST_STRING = new TypeReference<>() {};
private static final TypeReference<Map<String, String>> MAP_STRING = new TypeReference<>() {};
private final StringRedisTemplate template;
@Override
public void put(JobStoreEntry entry, Duration ttl) {
String key = JOB_PREFIX + entry.jobId();
long ttlMs = ttl.toMillis();
Map<String, String> fields = new LinkedHashMap<>();
fields.put("jobId", entry.jobId());
fields.put("state", entry.state().name());
fields.put("owningNodeId", entry.owningNodeId() == null ? "" : entry.owningNodeId());
if (entry.createdAt() != null) {
fields.put("createdAt", entry.createdAt().toString());
}
if (entry.completedAt() != null) {
fields.put("completedAt", entry.completedAt().toString());
}
if (entry.error() != null) {
fields.put("error", entry.error());
}
fields.put("fileIds", writeJson(entry.fileIds() == null ? List.of() : entry.fileIds()));
fields.put(
"resultMeta",
writeJson(entry.resultMeta() == null ? Map.of() : entry.resultMeta()));
// Build pipelined MULTI/EXEC so the hash, its TTL, and every reverse-index entry
// commit atomically.
template.execute(
(RedisCallback<Object>)
connection -> {
connection.multi();
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
Map<byte[], byte[]> hashBytes = new LinkedHashMap<>();
for (Map.Entry<String, String> f : fields.entrySet()) {
hashBytes.put(
f.getKey().getBytes(StandardCharsets.UTF_8),
f.getValue().getBytes(StandardCharsets.UTF_8));
}
connection.hashCommands().hMSet(keyBytes, hashBytes);
connection.keyCommands().pExpire(keyBytes, ttlMs);
if (entry.fileIds() != null) {
for (String fileId : entry.fileIds()) {
byte[] idxKey =
(FILE_INDEX_PREFIX + fileId)
.getBytes(StandardCharsets.UTF_8);
connection
.stringCommands()
.set(
idxKey,
entry.jobId().getBytes(StandardCharsets.UTF_8));
connection.keyCommands().pExpire(idxKey, ttlMs);
}
}
connection.exec();
return null;
});
}
@Override
public Optional<JobStoreEntry> get(String jobId) {
return readEntry(JOB_PREFIX + jobId);
}
@Override
public void delete(String jobId) {
// WATCH/MULTI/EXEC: read fileIds INSIDE the watched scope so a concurrent put() that
// adds new fileIds between our read and EXEC aborts the transaction. Without this guard,
// an interleaved put() that grows fileIds would leave orphaned reverse-index entries
// pointing at the deleted jobId until their TTL expires. One retry handles the common
// case; further contention falls through to lazy TTL cleanup (acceptable - this is an
// eviction path, not a correctness primitive).
String jobKey = JOB_PREFIX + jobId;
byte[] jobKeyBytes = jobKey.getBytes(StandardCharsets.UTF_8);
for (int attempt = 0; attempt < 2; attempt++) {
Boolean committed =
template.execute(
(RedisCallback<Boolean>)
connection -> {
connection.watch(jobKeyBytes);
// Read the single fileIds field with hGet rather than
// hGetAll + map.get: hGetAll returns a Map<byte[],byte[]>
// whose keys compare by identity, so a fresh
// "fileIds".getBytes() lookup never matches and the reverse
// index would be left orphaned. hGet resolves the field
// server-side.
byte[] fileIdsBytes =
connection
.hashCommands()
.hGet(
jobKeyBytes,
"fileIds"
.getBytes(
StandardCharsets
.UTF_8));
List<byte[]> keysToDelete = new ArrayList<>();
keysToDelete.add(jobKeyBytes);
if (fileIdsBytes != null) {
List<String> fileIds =
readJsonList(
new String(
fileIdsBytes,
StandardCharsets.UTF_8),
jobKey);
for (String fileId : fileIds) {
keysToDelete.add(
(FILE_INDEX_PREFIX + fileId)
.getBytes(StandardCharsets.UTF_8));
}
}
connection.multi();
for (byte[] key : keysToDelete) {
connection.keyCommands().del(key);
}
List<Object> results = connection.exec();
// exec() returns null when WATCH detected a concurrent
// write; spring-data-redis surfaces this as either null
// or empty depending on the driver path.
return results != null && !results.isEmpty();
});
if (Boolean.TRUE.equals(committed)) {
return;
}
}
log.warn(
"JobStore.delete({}) lost two WATCH races to concurrent put(); reverse-index"
+ " entries may linger until TTL expiry",
jobId);
}
@Override
public boolean exists(String jobId) {
Boolean exists = template.hasKey(JOB_PREFIX + jobId);
return Boolean.TRUE.equals(exists);
}
@Override
public Optional<String> findJobIdByFileId(String fileId) {
return Optional.ofNullable(template.opsForValue().get(FILE_INDEX_PREFIX + fileId));
}
@Override
public Collection<JobStoreEntry> all() {
// SCAN, not KEYS - KEYS blocks the Valkey server for the duration of the walk.
ScanOptions options = ScanOptions.scanOptions().match(JOB_PREFIX + "*").count(256).build();
List<JobStoreEntry> result = new ArrayList<>();
try (Cursor<String> cursor = template.scan(options)) {
while (cursor.hasNext()) {
readEntry(cursor.next()).ifPresent(result::add);
}
}
return result;
}
private Optional<JobStoreEntry> readEntry(String key) {
Map<Object, Object> entries = template.opsForHash().entries(key);
if (entries == null || entries.isEmpty()) {
return Optional.empty();
}
Object jobId = entries.get("jobId");
if (jobId == null) {
return Optional.empty();
}
Instant createdAt = parseInstant(entries.get("createdAt"), key, "createdAt");
Instant completedAt = parseInstant(entries.get("completedAt"), key, "completedAt");
List<String> fileIds = parseList(entries.get("fileIds"), key);
Map<String, String> resultMeta = parseMap(entries.get("resultMeta"), key);
String stateName =
String.valueOf(
entries.getOrDefault("state", JobStoreEntry.JobState.PENDING.name()));
JobStoreEntry.JobState state;
try {
state = JobStoreEntry.JobState.valueOf(stateName);
} catch (IllegalArgumentException ex) {
log.warn("Unrecognised job state '{}' in {}, defaulting to PENDING", stateName, key);
state = JobStoreEntry.JobState.PENDING;
}
String owningNodeId = String.valueOf(entries.getOrDefault("owningNodeId", ""));
String error = entries.get("error") == null ? null : entries.get("error").toString();
return Optional.of(
new JobStoreEntry(
jobId.toString(),
state,
owningNodeId,
createdAt,
completedAt,
error,
fileIds,
resultMeta));
}
private Instant parseInstant(Object v, String key, String field) {
if (v == null) {
return null;
}
try {
return Instant.parse(v.toString());
} catch (RuntimeException e) {
log.warn(
"JobStore {} field '{}' has malformed timestamp '{}' - treating as missing",
key,
field,
v);
return null;
}
}
private List<String> parseList(Object v, String key) {
if (v == null) {
return new ArrayList<>();
}
return readJsonList(v.toString(), key);
}
private Map<String, String> parseMap(Object v, String key) {
if (v == null) {
return new HashMap<>();
}
try {
return MAPPER.readValue(v.toString(), MAP_STRING);
} catch (JsonProcessingException e) {
log.warn(
"JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty",
key,
v);
return new HashMap<>();
}
}
private static String writeJson(Object value) {
try {
return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to JSON-serialize JobStore field", e);
}
}
private List<String> readJsonList(String json, String key) {
try {
List<String> parsed = MAPPER.readValue(json, LIST_STRING);
return parsed == null ? new ArrayList<>() : parsed;
} catch (JsonProcessingException e) {
log.warn(
"JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty",
key,
json);
return new ArrayList<>();
}
}
}
@@ -1,61 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.cluster.KeyValueCache;
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
public class ValkeyKeyValueCache implements KeyValueCache {
private static final String PREFIX = "stirling:kv:";
private final StringRedisTemplate template;
@Override
public void put(String namespace, String key, String value, Duration ttl) {
template.opsForValue()
.set(buildKey(namespace, key), value, ttl.toMillis(), TimeUnit.MILLISECONDS);
}
@Override
public Optional<String> get(String namespace, String key) {
return Optional.ofNullable(template.opsForValue().get(buildKey(namespace, key)));
}
@Override
public void evict(String namespace, String key) {
template.delete(buildKey(namespace, key));
}
@Override
public void evictNamespace(String namespace) {
ScanOptions options =
ScanOptions.scanOptions().match(PREFIX + namespace + ":*").count(256).build();
List<String> keys = new ArrayList<>();
try (Cursor<String> cursor = template.scan(options)) {
while (cursor.hasNext()) {
keys.add(cursor.next());
}
}
if (!keys.isEmpty()) {
template.delete(keys);
}
}
private String buildKey(String namespace, String key) {
return PREFIX + namespace + ":" + key;
}
}
@@ -1,83 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.stereotype.Component;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.distributed.BucketProxy;
import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
import io.lettuce.core.AbstractRedisClient;
import io.lettuce.core.RedisClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import stirling.software.common.cluster.RateLimitStore;
/**
* Valkey-backed token-bucket rate limiting via Bucket4j's Lettuce ProxyManager. The token bucket
* refills continuously and enforces one global limit across nodes, with the same semantics as the
* in-process {@code InProcessRateLimitStore} (which also uses Bucket4j).
*/
@Component
@ConditionalOnValkeyBackplane
public class ValkeyRateLimitStore implements RateLimitStore {
private static final String PREFIX = "stirling:rl:";
private final LettuceConnectionFactory connectionFactory;
private ProxyManager<byte[]> proxyManager;
public ValkeyRateLimitStore(LettuceConnectionFactory connectionFactory) {
this.connectionFactory = connectionFactory;
}
@PostConstruct
void initProxyManager() {
AbstractRedisClient client = connectionFactory.getNativeClient();
if (!(client instanceof RedisClient redisClient)) {
throw new IllegalStateException(
"ValkeyRateLimitStore requires a standalone Lettuce RedisClient; got "
+ (client == null ? "null" : client.getClass().getName())
+ " (cluster client not supported by this rate limit impl)");
}
// Expire idle bucket keys so they do not accumulate forever in Valkey (one key per
// user / API-key / IP). TTL tracks the time to refill the bucket from empty, capped at
// 25h to cover the longest (daily) rate-limit window; an idle bucket evicts after that.
this.proxyManager =
Bucket4jLettuce.casBasedBuilder(redisClient)
.expirationAfterWrite(
ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(
Duration.ofHours(25)))
.build();
}
@PreDestroy
void shutdown() {
proxyManager = null;
}
@Override
public RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod) {
byte[] key = (PREFIX + bucketKey).getBytes(StandardCharsets.UTF_8);
BucketConfiguration cfg =
BucketConfiguration.builder()
.addLimit(
stage ->
stage.capacity(capacity)
.refillGreedy(capacity, refillPeriod))
.build();
BucketProxy bucket = proxyManager.builder().build(key, () -> cfg);
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
return new RateLimitDecision(true, probe.getRemainingTokens(), 0L);
}
return new RateLimitDecision(false, 0L, probe.getNanosToWaitForRefill());
}
}
@@ -5,7 +5,6 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
@@ -31,7 +30,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.TaskManager;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
@@ -60,7 +58,6 @@ public class AiEngineController {
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
private final AiEngineEndpointResolver endpointResolver;
private final UserServiceInterface userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
@@ -77,8 +74,7 @@ public class AiEngineController {
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver,
@Autowired(required = false) UserServiceInterface userService) {
AiEngineEndpointResolver endpointResolver) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
this.objectMapper = objectMapper;
@@ -86,11 +82,6 @@ public class AiEngineController {
this.taskManager = taskManager;
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
this.userService = userService;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
@GetMapping("/health")
@@ -98,7 +89,7 @@ public class AiEngineController {
summary = "AI engine health check",
description = "Returns the health status of the AI engine including configured models")
public ResponseEntity<String> health() throws IOException {
String response = aiEngineClient.get("/health", currentUserId());
String response = aiEngineClient.get("/health");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
@@ -252,7 +243,7 @@ public class AiEngineController {
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
}
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody, currentUserId());
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.model.api.ai;
import java.time.Instant;
import java.util.List;
import lombok.AllArgsConstructor;
@@ -11,12 +10,6 @@ import lombok.NoArgsConstructor;
* Body for {@code POST /api/v1/documents} on the AI engine. Sent by Java when the engine reports
* {@code need_ingest} and the requested document's extracted content must be stored before the
* workflow can continue.
*
* <p>{@code ownerId} is the tenant the doc belongs to (a user for personal uploads, an org for
* shared content). {@code readPrincipals} is the explicit list of principals granted read access.
* {@code expiresAt} is when the engine's reaper should delete this doc; {@code null} means
* "persistent until explicit delete" (used for org-shared content). Java picks the value per doc;
* the engine does not default it.
*/
@Data
@NoArgsConstructor
@@ -28,10 +21,4 @@ public class AiDocumentIngestRequest {
private String source;
private List<AiPageText> pageText;
private String ownerId;
private List<String> readPrincipals;
private Instant expiresAt;
}
@@ -6,6 +6,7 @@ import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@@ -13,8 +14,9 @@ import lombok.Data;
@Schema(description = "Run an AI workflow")
public class AiWorkflowRequest {
@NotNull
@Schema(description = "The input PDF files")
private List<AiWorkflowFileInput> fileInputs = new ArrayList<>();
private List<AiWorkflowFileInput> fileInputs;
@NotBlank
@Schema(description = "The user message to orchestrate", example = "Summarise these documents")
@@ -8,8 +8,6 @@ import java.util.List;
import java.util.Locale;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
@@ -38,7 +36,6 @@ import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.service.AiUserDataService;
@Slf4j
@RequiredArgsConstructor
@@ -52,22 +49,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private final JwtServiceInterface jwtService;
private final AiUserDataService aiUserDataService;
private static final AuthenticationTrustResolver TRUST_RESOLVER =
new AuthenticationTrustResolverImpl();
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
String username = resolveUsername(request, authentication);
if (username != null) {
aiUserDataService.purgeUserDocuments(username);
}
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
@@ -101,26 +88,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
}
/**
* Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through
* to whatever Spring handed us only when there's no cookie. Spring's anonymous principal is
* filtered out via {@link AuthenticationTrustResolver} so we don't purge under that
* pseudo-user.
*/
private String resolveUsername(HttpServletRequest request, Authentication authentication) {
if (jwtService != null) {
String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request);
if (fromCookie != null) {
return fromCookie;
}
}
if (authentication == null || TRUST_RESOLVER.isAnonymous(authentication)) {
return null;
}
String name = authentication.getName();
return (name != null && !name.isBlank()) ? name : null;
}
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
HttpServletRequest request,
@@ -93,7 +93,6 @@ public class SecurityConfiguration {
licenseSettingsService;
private final ClientRegistrationRepository clientRegistrationRepository;
private final PasswordEncoder passwordEncoder;
private final stirling.software.proprietary.service.AiUserDataService aiUserDataService;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -116,8 +115,7 @@ public class SecurityConfiguration {
OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService,
PasswordEncoder passwordEncoder,
stirling.software.proprietary.service.AiUserDataService aiUserDataService) {
PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -137,7 +135,6 @@ public class SecurityConfiguration {
this.clientRegistrationRepository = clientRegistrationRepository;
this.licenseSettingsService = licenseSettingsService;
this.passwordEncoder = passwordEncoder;
this.aiUserDataService = aiUserDataService;
}
/**
@@ -325,10 +322,7 @@ public class SecurityConfiguration {
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties,
appConfig,
jwtService,
aiUserDataService))
securityProperties, appConfig, jwtService))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
@@ -44,7 +44,6 @@ import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.service.AiUserDataService;
/** REST API Controller for authentication operations. */
@RestController
@@ -63,7 +62,6 @@ public class AuthController {
private final RefreshRateLimitService refreshRateLimitService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
private final AiUserDataService aiUserDataService;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -283,13 +281,11 @@ public class AuthController {
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
String username = jwtService.extractUsernameFromRequestAllowExpired(request);
SecurityContextHolder.clearContext();
aiUserDataService.purgeUserDocuments(username);
log.debug("User logged out successfully (username={})", username);
log.debug("User logged out successfully");
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
@@ -18,6 +18,7 @@ import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -33,6 +34,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.security.UserSummaryDTO;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.audit.AuditEventType;
@@ -975,4 +977,34 @@ public class UserController {
.body("Failed to complete initial setup");
}
}
/**
* List all enabled users for selection in signing workflows.
*
* @param principal The authenticated user
* @return List of user summaries
*/
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<UserSummaryDTO> users =
userRepository.findAll().stream()
.filter(User::isEnabled)
.map(this::toUserSummaryDTO)
.collect(java.util.stream.Collectors.toList());
return ResponseEntity.ok(users);
}
private UserSummaryDTO toUserSummaryDTO(User user) {
return new UserSummaryDTO(
user.getId(),
user.getUsername(),
user.getUsername(), // Use username as displayName
user.getTeam() != null ? user.getTeam().getName() : null,
user.isEnabled());
}
}
@@ -349,21 +349,6 @@ public class JwtService implements JwtServiceInterface {
return null;
}
@Override
public String extractUsernameFromRequestAllowExpired(HttpServletRequest request) {
try {
String token = extractToken(request);
if (token == null || token.isBlank()) {
return null;
}
String username = extractUsernameAllowExpired(token);
return (username != null && !username.isBlank()) ? username : null;
} catch (Exception e) {
log.debug("Could not extract username from request JWT: {}", e.getMessage());
return null;
}
}
@Override
public boolean isJwtEnabled() {
return v2Enabled;
@@ -93,19 +93,6 @@ public interface JwtServiceInterface {
*/
String extractToken(HttpServletRequest request);
/**
* Read the username off the request's JWT, allowing an expired token. Returns null when no
* token is present, the token can't be parsed, or the resulting username is blank.
*
* <p>Used by flows that need to identify the user without depending on {@code
* SecurityContextHolder} - for example logout, where the security filter chain may have left
* the anonymous principal in place by the time the handler runs.
*
* @param request HTTP servlet request
* @return username from the token, or null when one can't be safely derived
*/
String extractUsernameFromRequestAllowExpired(HttpServletRequest request);
/**
* Check if JWT authentication is enabled
*
@@ -43,23 +43,22 @@ public class AiEngineClient {
this.httpClient = httpClient;
}
public String post(String path, String jsonBody, String userId) throws IOException {
public String post(String path, String jsonBody) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(
path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()), userId);
return postWithTimeout(path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()));
}
/**
* POST with an explicit per-call timeout, for heavy operations (e.g. RAG ingestion of a large
* document) that legitimately take longer than the default timeout.
*/
public String postLongRunning(String path, String jsonBody, String userId) throws IOException {
public String postLongRunning(String path, String jsonBody) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()), userId);
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()));
}
private String postWithTimeout(String path, String jsonBody, Duration timeout, String userId)
private String postWithTimeout(String path, String jsonBody, Duration timeout)
throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
@@ -70,32 +69,22 @@ public class AiEngineClient {
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine request to {} (timeout {}s)", url, timeout.toSeconds());
HttpRequest.Builder builder =
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
addUserHeader(builder, userId);
HttpResponse<String> response = sendRequest(builder.build());
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = sendRequest(request);
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
return response.body();
}
/**
* Attach the X-User-Id header so the engine can scope per-user storage (RAG documents, search
* results) to the caller. Skipped when {@code userId} is blank: the engine treats the request
* as anonymous and refuses any route that requires tenancy.
*/
private static void addUserHeader(HttpRequest.Builder builder, String userId) {
if (userId != null && !userId.isBlank()) {
builder.header("X-User-Id", userId);
}
}
/**
* POST a JSON body and consume the response as a stream of NDJSON lines. Each line is passed to
* {@code lineConsumer} in arrival order; the call returns when the engine closes the stream.
@@ -105,8 +94,7 @@ public class AiEngineClient {
* practice line arrival keeps the connection logically alive: as long as the engine emits
* events, the work is progressing. Genuine engine hangs still hit the total timeout.
*/
public void streamPost(
String path, String jsonBody, String userId, Consumer<String> lineConsumer)
public void streamPost(String path, String jsonBody, Consumer<String> lineConsumer)
throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
@@ -121,15 +109,14 @@ public class AiEngineClient {
url,
timeout.toSeconds());
HttpRequest.Builder builder =
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/x-ndjson")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
addUserHeader(builder, userId);
HttpRequest request = builder.build();
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<Stream<String>> response;
try {
@@ -162,36 +149,7 @@ public class AiEngineClient {
}
}
/**
* DELETE with no body. Used for purging the caller's RAG content on logout. Wraps the same
* error envelope as {@link #post} / {@link #get} so callers see a consistent set of {@code
* ResponseStatusException}s.
*/
public String delete(String path, String userId) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
}
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine DELETE request to {}", url);
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.DELETE();
addUserHeader(builder, userId);
HttpResponse<String> response = sendRequest(builder.build());
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
return response.body();
}
public String get(String path, String userId) throws IOException {
public String get(String path) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
throw new ResponseStatusException(
@@ -201,14 +159,15 @@ public class AiEngineClient {
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine GET request to {}", url);
HttpRequest.Builder builder =
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.GET();
addUserHeader(builder, userId);
HttpResponse<String> response = sendRequest(builder.build());
.GET()
.build();
HttpResponse<String> response = sendRequest(request);
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
@@ -1,50 +0,0 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Lifecycle hooks for a user's AI document data on the Python engine.
*
* <p>Today: cleanup on logout. The engine also runs a TTL reaper that catches sessions ended
* without a clean logout (tab close, JWT expiry, engine restart), so this service is the happy-path
* purge, not a hard guarantee. Calls are fire-and-forget on a background thread (Spring's default
* {@code @Async} executor) so an unavailable engine never delays the caller's response.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiUserDataService {
private static final String PURGE_PATH = "/api/v1/documents/by-owner";
private final AiEngineClient aiEngineClient;
/**
* Tell the engine to delete every collection owned by {@code userId}: vector chunks, page text,
* ACL rows, and the owner row itself. Runs asynchronously so the calling thread (typically a
* logout handler) returns immediately; engine errors are logged on the worker thread and never
* propagated. The engine's TTL reaper backstops any miss within ~24h.
*/
@Async
public void purgeUserDocuments(String userId) {
if (userId == null || userId.isBlank()) {
log.debug("Skipping user document purge: no user id");
return;
}
try {
aiEngineClient.delete(PURGE_PATH, userId);
log.debug("Requested document purge for user {}", userId);
} catch (ResponseStatusException e) {
log.warn("AI engine refused document purge for {}: {}", userId, e.getReason());
} catch (IOException e) {
log.warn("Failed to purge documents for {}: {}", userId, e.getMessage());
}
}
}
@@ -1,8 +1,6 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -11,7 +9,6 @@ import java.util.stream.Collectors;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
@@ -27,15 +24,14 @@ import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -53,7 +49,6 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
@@ -64,6 +59,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiWorkflowService {
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
@@ -78,56 +74,6 @@ public class AiWorkflowService {
private final TempFileManager tempFileManager;
private final FileIdStrategy fileIdStrategy;
private final AiEngineEndpointResolver endpointResolver;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
public AiWorkflowService(
CustomPDFDocumentFactory pdfDocumentFactory,
AiEngineClient aiEngineClient,
PdfContentExtractor pdfContentExtractor,
ObjectMapper objectMapper,
InternalApiClient internalApiClient,
FileStorage fileStorage,
ToolMetadataService toolMetadataService,
TempFileManager tempFileManager,
FileIdStrategy fileIdStrategy,
AiEngineEndpointResolver endpointResolver,
@Autowired(required = false) UserServiceInterface userService,
ApplicationProperties applicationProperties) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.aiEngineClient = aiEngineClient;
this.pdfContentExtractor = pdfContentExtractor;
this.objectMapper = objectMapper;
this.internalApiClient = internalApiClient;
this.fileStorage = fileStorage;
this.toolMetadataService = toolMetadataService;
this.tempFileManager = tempFileManager;
this.fileIdStrategy = fileIdStrategy;
this.endpointResolver = endpointResolver;
this.userService = userService;
this.applicationProperties = applicationProperties;
}
/**
* How long an AI-workflow-ingested personal doc lives on the engine before the reaper deletes
* it. Mirrors the configured web JWT lifetime, so a stale cookie can never see data the user
* has lost their session to. Org-shared content (when we add it) bypasses this and sends a null
* {@code expiresAt} so it's persistent.
*/
private Duration personalDocTtl() {
int minutes = DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
return Duration.ofMinutes(minutes);
}
/**
* Resolve the currently-authenticated user's id for X-User-Id propagation to the AI engine.
* Returns null when security is disabled (no UserServiceInterface bean) or no one is logged in.
* The engine rejects per-user routes (ingest, search) when this is null; non-tenant routes
* (health, orchestrate without RAG) still work.
*/
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
@FunctionalInterface
public interface ProgressListener {
@@ -186,7 +132,10 @@ public class AiWorkflowService {
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
initialRequest.setUserMessage(request.getUserMessage().trim());
initialRequest.setFiles(files);
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
initialRequest.setConversationHistory(
request.getConversationHistory() == null
? new ArrayList<>()
: new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
@@ -229,12 +178,6 @@ public class AiWorkflowService {
WorkflowTurnRequest request,
ProgressListener listener)
throws IOException {
if (filesById.isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue(
"No files were uploaded. Please add a PDF to the workbench first."));
}
if (!request.getArtifacts().isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue("AI engine requested content extraction more than once."));
@@ -355,21 +298,10 @@ public class AiWorkflowService {
}
}
}
// Personal-doc semantics for AI workflows today: caller owns the doc and is its only
// grantee, with a session-bounded expiry so the reaper cleans up if logout misses.
// When org / shared-doc ingestion lands, the caller chooses owner, grantees, and
// expiry (null = persistent) explicitly.
String callerId = currentUserId();
AiDocumentIngestRequest ingestRequest =
new AiDocumentIngestRequest(
file.getId(),
file.getName(),
pages,
callerId,
callerId == null ? List.of() : List.of(callerId),
Instant.now().plus(personalDocTtl()));
new AiDocumentIngestRequest(file.getId(), file.getName(), pages);
String body = objectMapper.writeValueAsString(ingestRequest);
aiEngineClient.postLongRunning(DOCUMENTS_ENDPOINT, body, callerId);
aiEngineClient.postLongRunning(DOCUMENTS_ENDPOINT, body);
log.debug(
"Ingested document: id={}, name={}, pages={}",
file.getId(),
@@ -767,7 +699,6 @@ public class AiWorkflowService {
aiEngineClient.streamPost(
"/api/v1/orchestrator",
requestBody,
currentUserId(),
line -> handleStreamLine(line, listener, resultHolder, errorHolder));
if (errorHolder[0] != null) {
@@ -8,14 +8,13 @@ import java.util.List;
import java.util.UUID;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.Evidence;
import stirling.software.proprietary.model.api.ai.Folio;
import stirling.software.proprietary.model.api.ai.FolioManifest;
@@ -41,6 +40,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MathAuditorOrchestrator {
private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine";
@@ -50,24 +50,6 @@ public class MathAuditorOrchestrator {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final PdfContentExtractor pdfContentExtractor;
private final ObjectMapper objectMapper;
private final UserServiceInterface userService;
public MathAuditorOrchestrator(
AiEngineClient aiEngineClient,
CustomPDFDocumentFactory pdfDocumentFactory,
PdfContentExtractor pdfContentExtractor,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.pdfDocumentFactory = pdfDocumentFactory;
this.pdfContentExtractor = pdfContentExtractor;
this.objectMapper = objectMapper;
this.userService = userService;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
/**
* Run a full math audit against the supplied PDF file.
@@ -128,7 +110,7 @@ public class MathAuditorOrchestrator {
EXAMINE_PATH,
manifest.sessionId(),
manifest.round());
String responseBody = aiEngineClient.post(EXAMINE_PATH, requestBody, currentUserId());
String responseBody = aiEngineClient.post(EXAMINE_PATH, requestBody);
return objectMapper.readValue(responseBody, Requisition.class);
}
@@ -141,7 +123,7 @@ public class MathAuditorOrchestrator {
evidence.sessionId(),
evidence.round(),
evidence.finalRound());
String responseBody = aiEngineClient.post(path, requestBody, currentUserId());
String responseBody = aiEngineClient.post(path, requestBody);
return objectMapper.readValue(responseBody, Verdict.class);
}
@@ -10,19 +10,18 @@ import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.comments.AnnotationLocation;
import stirling.software.common.model.api.comments.StickyNoteSpec;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfAnnotationService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineRequest;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
@@ -51,6 +50,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PdfCommentAgentOrchestrator {
private static final String GENERATE_PATH = "/api/v1/ai/pdf-comment-agent/generate";
@@ -80,26 +80,6 @@ public class PdfCommentAgentOrchestrator {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final PdfAnnotationService pdfAnnotationService;
private final UserServiceInterface userService;
public PdfCommentAgentOrchestrator(
AiEngineClient aiEngineClient,
PdfTextChunkExtractor pdfTextChunkExtractor,
CustomPDFDocumentFactory pdfDocumentFactory,
ObjectMapper objectMapper,
PdfAnnotationService pdfAnnotationService,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.pdfTextChunkExtractor = pdfTextChunkExtractor;
this.pdfDocumentFactory = pdfDocumentFactory;
this.objectMapper = objectMapper;
this.pdfAnnotationService = pdfAnnotationService;
this.userService = userService;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
/**
* Run the full PDF comment generation flow.
@@ -184,7 +164,7 @@ public class PdfCommentAgentOrchestrator {
PdfCommentEngineRequest engineRequest =
new PdfCommentEngineRequest(sessionId, prompt, chunks);
String requestBody = objectMapper.writeValueAsString(engineRequest);
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody, currentUserId());
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody);
PdfCommentEngineResponse engineResponse =
objectMapper.readValue(responseBody, PdfCommentEngineResponse.class);
@@ -8,18 +8,13 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.apache.commons.csv.QuoteMode;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -29,7 +24,6 @@ import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.pdf.parser.PageImageLocator;
import stirling.software.SPDF.pdf.parser.PdfIngester;
import stirling.software.SPDF.pdf.parser.PdfModels.ParsedPage;
import stirling.software.SPDF.pdf.parser.PdfModels.RawLine;
@@ -38,7 +32,6 @@ import stirling.software.SPDF.pdf.parser.PdfModels.TextFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection;
@@ -129,7 +122,6 @@ public class PdfContentExtractor {
}
csvStrings.add(sw.toString());
}
return csvStrings;
}
@@ -303,6 +295,7 @@ public class PdfContentExtractor {
private List<AiWorkflowTextSelection> extractPageText(
PDDocument document, List<Integer> selectedPages, int maxCharacters)
throws IOException {
PDFTextStripper textStripper = new PDFTextStripper();
List<AiWorkflowTextSelection> pages = new ArrayList<>();
int remainingCharacters = maxCharacters;
@@ -311,29 +304,10 @@ public class PdfContentExtractor {
break;
}
PDFTextStripper textStripper = new PDFTextStripper();
textStripper.setSortByPosition(true);
textStripper.setStartPage(pageNumber);
textStripper.setEndPage(pageNumber);
String pageText = textStripper.getText(document).trim();
// Prepend page dimensions so the AI agent can reason about absolute coordinates.
PDPage page = document.getPage(pageNumber - 1);
PDRectangle bbox = page.getBBox();
String dimensionHeader =
String.format(
"--- Page dimensions: %.0fx%.0f pts"
+ " (PDF user-space: origin bottom-left, Y up) ---\n",
bbox.getWidth(), bbox.getHeight());
pageText = dimensionHeader + pageText;
// Append image metadata so the AI agent can reason about images spatially.
String imageAnnotation = buildImageAnnotation(document, pageNumber - 1);
if (!imageAnnotation.isEmpty()) {
pageText = pageText + imageAnnotation;
}
if (pageText.isBlank()) {
continue;
}
@@ -353,56 +327,6 @@ public class PdfContentExtractor {
return pages;
}
/**
* Builds a human-readable description of all images on a page to append to page text. Uses PDF
* user-space coordinates (origin bottom-left, Y up) so the AI can reference exact bounding
* boxes when requesting image redaction.
*/
private String buildImageAnnotation(PDDocument document, int pageIndex) {
try {
List<ImageBlock> images = extractImagePositions(document, pageIndex);
if (images.isEmpty()) {
return "";
}
PDPage page = document.getPage(pageIndex);
PDRectangle bbox = page.getBBox();
float pageWidth = bbox.getWidth();
float pageHeight = bbox.getHeight();
StringBuilder sb = new StringBuilder("\n\n--- Images on this page ---");
for (int i = 0; i < images.size(); i++) {
ImageBlock img = images.get(i);
String position = spatialLabel(img, pageWidth, pageHeight);
float w = img.x2() - img.x1();
float h = img.y2() - img.y1();
sb.append(
String.format(
"\nImage %d: position=%s, size=%.0fx%.0f pts,"
+ " bounds=(x1=%.0f, y1=%.0f, x2=%.0f, y2=%.0f)",
i + 1, position, w, h, img.x1(), img.y1(), img.x2(), img.y2()));
}
return sb.toString();
} catch (Exception e) {
log.debug(
"Failed to extract image positions for page {}: {}", pageIndex, e.getMessage());
return "";
}
}
/**
* Returns a human-readable spatial label (e.g. "top-left", "center") for an image based on its
* centre relative to the page dimensions. Coordinates are in PDF user-space (Y up).
*/
private static String spatialLabel(ImageBlock img, float pageWidth, float pageHeight) {
float cx = (img.x1() + img.x2()) / 2f;
float cy = (img.y1() + img.y2()) / 2f;
String horiz = cx < pageWidth / 3f ? "left" : cx < 2 * pageWidth / 3f ? "center" : "right";
// PDF Y increases upward, so higher Y = higher on the page = "top"
String vert = cy > 2 * pageHeight / 3f ? "top" : cy > pageHeight / 3f ? "middle" : "bottom";
return vert + "-" + horiz;
}
private ExtractedFileText buildExtractedFileText(
String fileName, List<AiWorkflowTextSelection> pages) {
ExtractedFileText fileText = new ExtractedFileText();
@@ -423,130 +347,6 @@ public class PdfContentExtractor {
return text.substring(0, end);
}
// -----------------------------------------------------------------------
// Text position finding
// -----------------------------------------------------------------------
/**
* A located text match inside a PDF: 0-based page index and bounding box in PDFBox coordinates
* (origin bottom-left).
*/
public record TextBlock(int pageIndex, float x1, float y1, float x2, float y2) {}
/**
* An image found on a PDF page: 0-based page index and bounding box in PDF user-space
* coordinates (origin bottom-left, Y increases upward).
*/
public record ImageBlock(int pageIndex, float x1, float y1, float x2, float y2) {}
/**
* Extract the bounding boxes of all raster/vector images on the given (0-based) page.
*
* @param document the open PDF
* @param pageIndex 0-based page index
* @return list of located images in document order
*/
public List<ImageBlock> extractImagePositions(PDDocument document, int pageIndex)
throws IOException {
PDPage page = document.getPage(pageIndex);
PageImageLocator locator = new PageImageLocator(page, pageIndex);
locator.processPage(page);
return locator.getImageBoxes().stream()
.map(b -> new ImageBlock(b.pageIndex(), b.x1(), b.y1(), b.x2(), b.y2()))
.toList();
}
/**
* Find all occurrences of {@code pattern} in {@code document} and return their bounding boxes.
*
* @param document the open PDF
* @param pattern the search string or regex
* @param useRegex {@code true} to treat {@code pattern} as a regular expression
* @return list of located matches, in page order
*/
public List<TextBlock> findTextPositions(PDDocument document, String pattern, boolean useRegex)
throws IOException {
LocalTextFinder finder = new LocalTextFinder(pattern, useRegex);
finder.getText(document);
return finder.found;
}
private static final class LocalTextFinder extends PDFTextStripper {
private final String searchTerm;
private final boolean useRegex;
final List<TextBlock> found = new ArrayList<>();
private final List<TextPosition> pagePositions = new ArrayList<>();
private final StringBuilder pageText = new StringBuilder();
LocalTextFinder(String searchTerm, boolean useRegex) throws IOException {
this.searchTerm = searchTerm;
this.useRegex = useRegex;
setWordSeparator(" ");
setLineSeparator("\n");
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
pagePositions.clear();
pageText.setLength(0);
}
@Override
protected void writeString(String text, List<TextPosition> positions) {
pageText.append(text);
pagePositions.addAll(positions);
}
@Override
protected void writeWordSeparator() {
pageText.append(getWordSeparator());
pagePositions.add(null);
}
@Override
protected void writeLineSeparator() {
pageText.append(getLineSeparator());
pagePositions.add(null);
}
@Override
protected void endPage(PDPage page) throws IOException {
String text = pageText.toString();
if (!text.isEmpty() && searchTerm != null && !searchTerm.isBlank()) {
String term = searchTerm.trim();
String regex = useRegex ? term : "\\Q" + term + "\\E";
Pattern pat = RegexPatternUtils.getInstance().createSearchPattern(regex, true);
Matcher matcher = pat.matcher(text);
while (matcher.find()) {
float minX = Float.MAX_VALUE;
float minY = Float.MAX_VALUE;
float maxX = -Float.MAX_VALUE;
float maxY = -Float.MAX_VALUE;
boolean hit = false;
for (int i = matcher.start(); i < matcher.end(); i++) {
if (i < pagePositions.size()) {
TextPosition tp = pagePositions.get(i);
if (tp != null) {
hit = true;
minX = Math.min(minX, tp.getX());
maxX = Math.max(maxX, tp.getX() + tp.getWidth());
minY = Math.min(minY, tp.getY() - tp.getHeight());
maxY = Math.max(maxY, tp.getY());
}
}
}
if (hit) {
found.add(new TextBlock(getCurrentPageNo() - 1, minX, minY, maxX, maxY));
}
}
}
super.endPage(page);
}
}
// --- Types shared with AiWorkflowService (package-private) ---
interface PdfContentResult {
@@ -3,8 +3,6 @@ package stirling.software.proprietary.workflow.controller;
import java.io.IOException;
import java.security.Principal;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -33,13 +31,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.model.api.security.UserSummaryDTO;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
@@ -61,55 +55,10 @@ public class SigningSessionController {
private final WorkflowSessionService workflowSessionService;
private final UserService userService;
private final UserRepository userRepository;
private final SigningFinalizationService signingFinalizationService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
private final ObjectMapper objectMapper = new ObjectMapper();
@Operation(
summary = "List users the caller can invite as cert-sign participants",
description =
"Returns enabled users in the caller's team, excluding the caller and the"
+ " Internal team. Scoped to the caller's team to avoid leaking the"
+ " user directory (GHSA-h2rx-xrhc-5q72).")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/eligible-participants")
public ResponseEntity<List<UserSummaryDTO>> listEligibleParticipants(Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (callerOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
User caller = callerOpt.get();
Team callerTeam = caller.getTeam();
if (callerTeam == null
|| TeamService.INTERNAL_TEAM_NAME.equalsIgnoreCase(callerTeam.getName())) {
return ResponseEntity.ok(List.of());
}
List<UserSummaryDTO> users =
userRepository.findAllByTeam(callerTeam).stream()
.filter(User::isEnabled)
.filter(u -> !u.getId().equals(caller.getId()))
.map(this::toUserSummaryDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(users);
}
private UserSummaryDTO toUserSummaryDTO(User user) {
return new UserSummaryDTO(
user.getId(),
user.getUsername(),
user.getUsername(),
user.getTeam() != null ? user.getTeam().getName() : null,
user.isEnabled());
}
@Operation(summary = "List all signing sessions for current user")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/sessions")
@@ -1,62 +0,0 @@
package stirling.software.proprietary.cluster;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
class ClusterLicenseGateTest {
private void injectRunningProOrHigher(ClusterLicenseGate gate, Boolean value) throws Exception {
Field f = ClusterLicenseGate.class.getDeclaredField("runningProOrHigher");
f.setAccessible(true);
f.set(gate, value);
}
private void invokeVerify(ClusterLicenseGate gate) throws Throwable {
Method m = ClusterLicenseGate.class.getDeclaredMethod("verifyLicense");
m.setAccessible(true);
try {
m.invoke(gate);
} catch (InvocationTargetException e) {
throw e.getCause();
}
}
@Test
void serverOrEnterpriseLicense_allowsClusterMode() throws Throwable {
ClusterLicenseGate gate = new ClusterLicenseGate();
injectRunningProOrHigher(gate, Boolean.TRUE);
assertDoesNotThrow(() -> invokeVerify(gate));
}
@Test
void normalLicense_refusesClusterMode_withActionableMessage() throws Exception {
ClusterLicenseGate gate = new ClusterLicenseGate();
injectRunningProOrHigher(gate, Boolean.FALSE);
IllegalStateException ex =
assertThrows(IllegalStateException.class, () -> invokeVerify(gate));
String msg = ex.getMessage();
// The error message must tell the operator exactly what to do.
assertTrue(msg.contains("SERVER"), "message must mention SERVER license tier: " + msg);
assertTrue(msg.contains("ENTERPRISE"), "message must mention ENTERPRISE tier: " + msg);
assertTrue(
msg.contains("stirling.premium.key") || msg.contains("license key"),
"message must explain how to set the license: " + msg);
assertTrue(
msg.contains("cluster.enabled=false"),
"message must offer the opt-out (disable cluster): " + msg);
}
@Test
void saasFlavor_bypassesGate_whenRunningProOrHigherBeanAbsent() throws Throwable {
// In saas builds the runningProOrHigher bean is absent (@Autowired required=false -> null).
ClusterLicenseGate gate = new ClusterLicenseGate();
assertDoesNotThrow(() -> invokeVerify(gate));
}
}
@@ -1,122 +0,0 @@
package stirling.software.proprietary.cluster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.common.model.ApplicationProperties;
/** Verifies every cluster metric is registered and recorder methods write to them. */
class ClusterMetricsTest {
private SimpleMeterRegistry registry;
private ClusterMetrics metrics;
private static final String NODE = "test-node";
@BeforeEach
void setUp() {
registry = new SimpleMeterRegistry();
ApplicationProperties props = new ApplicationProperties();
props.getCluster().getNode().setId(NODE);
metrics = new ClusterMetrics(registry, props);
}
@Test
void registersAllRequiredMeters() {
assertNotNull(registry.find("stirling_cluster_sticky_miss_total").counter());
assertNotNull(registry.find("stirling_cluster_ratelimit_rejected_total").counter());
assertNotNull(registry.find("stirling_cluster_backplane_latency_seconds").timer());
assertNotNull(registry.find("stirling_cluster_job_wait_seconds").timer());
Gauge inflight = registry.find("stirling_cluster_jobs_inflight").tag("node", NODE).gauge();
assertNotNull(inflight, "jobs_inflight gauge with node tag must be registered eagerly");
}
@Test
void registersKnownLaneGaugesEagerly() {
for (String lane : new String[] {"FAST", "SLOW", "AI"}) {
Gauge g = registry.find("stirling_cluster_queue_depth").tag("lane", lane).gauge();
assertNotNull(g, "lane gauge must be eagerly registered for " + lane);
assertEquals(0.0, g.value(), "lane gauge default value must be 0 for " + lane);
}
assertEquals(
3,
registry.find("stirling_cluster_queue_depth").gauges().size(),
"exactly the three known lane gauges should be registered at boot");
}
@Test
void recordStickyMissIncrementsCounter() {
metrics.recordStickyMiss();
metrics.recordStickyMiss();
assertEquals(2.0, registry.find("stirling_cluster_sticky_miss_total").counter().count());
}
@Test
void recordRateLimitRejectIncrementsCounter() {
metrics.recordRateLimitReject();
assertEquals(
1.0, registry.find("stirling_cluster_ratelimit_rejected_total").counter().count());
}
@Test
void incrementAndDecrementInflightUpdatesGauge() {
metrics.incrementInflight();
metrics.incrementInflight();
metrics.incrementInflight();
metrics.decrementInflight();
Gauge gauge = registry.find("stirling_cluster_jobs_inflight").tag("node", NODE).gauge();
assertEquals(2.0, gauge.value(), "expected 2 inflight after 3 inc / 1 dec");
}
@Test
void setQueueDepthUpdatesEagerlyRegisteredLaneGauge() {
metrics.setQueueDepth("FAST", 4);
metrics.setQueueDepth("SLOW", 7);
Gauge fast = registry.find("stirling_cluster_queue_depth").tag("lane", "FAST").gauge();
Gauge slow = registry.find("stirling_cluster_queue_depth").tag("lane", "SLOW").gauge();
assertEquals(4.0, fast.value());
assertEquals(7.0, slow.value());
}
@Test
void setQueueDepthForUnknownLane_lazyRegistersFallbackGauge() {
metrics.setQueueDepth("custom-lane", 5);
Gauge g = registry.find("stirling_cluster_queue_depth").tag("lane", "custom-lane").gauge();
assertNotNull(g);
assertEquals(5.0, g.value());
}
@Test
void setQueueDepthIsIdempotentAcrossCalls() {
metrics.setQueueDepth("FAST", 1);
metrics.setQueueDepth("FAST", 2);
metrics.setQueueDepth("FAST", 9);
assertEquals(
1,
registry.find("stirling_cluster_queue_depth").tag("lane", "FAST").gauges().size());
assertEquals(
9.0,
registry.find("stirling_cluster_queue_depth").tag("lane", "FAST").gauge().value());
}
@Test
void backplaneLatencyTimerAcceptsRecordings() {
metrics.backplaneLatency().record(java.time.Duration.ofMillis(7));
metrics.backplaneLatency().record(java.time.Duration.ofMillis(11));
assertEquals(
2L, registry.find("stirling_cluster_backplane_latency_seconds").timer().count());
}
@Test
void jobWaitTimerAcceptsRecordings() {
metrics.jobWaitSeconds().record(java.time.Duration.ofMillis(50));
assertEquals(1L, registry.find("stirling_cluster_job_wait_seconds").timer().count());
}
}
@@ -1,115 +0,0 @@
package stirling.software.proprietary.cluster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.cluster.ClusterNode;
import stirling.software.common.cluster.InstanceRegistry;
import stirling.software.common.model.ApplicationProperties;
/** Verifies the bootstrap registers / heartbeats / deregisters as expected. */
class ClusterNodeBootstrapTest {
private InstanceRegistry registry;
private ApplicationProperties props;
private ClusterNodeBootstrap bootstrap;
@BeforeEach
void setUp() {
registry = mock(InstanceRegistry.class);
props = new ApplicationProperties();
props.getCluster().setEnabled(true);
props.getCluster().getNode().setId("node-test-1");
props.getCluster().getNode().setRole("worker");
props.getCluster().getNode().setHeartbeatIntervalMs(10_000L);
bootstrap = new ClusterNodeBootstrap(props, registry);
ReflectionTestUtils.setField(bootstrap, "serverPort", 8080);
}
@Test
void registerOnStartupCallsRegistryWithResolvedNodeId() {
bootstrap.registerOnStartup();
ArgumentCaptor<ClusterNode> nodeCaptor = ArgumentCaptor.forClass(ClusterNode.class);
ArgumentCaptor<Duration> ttlCaptor = ArgumentCaptor.forClass(Duration.class);
verify(registry, times(1)).register(nodeCaptor.capture(), ttlCaptor.capture());
ClusterNode captured = nodeCaptor.getValue();
assertEquals("node-test-1", captured.nodeId());
assertTrue(captured.internalAddress().startsWith("http://"));
assertTrue(captured.internalAddress().endsWith(":8080"));
assertEquals("WORKER", captured.role());
assertEquals(30L, ttlCaptor.getValue().toSeconds());
}
@Test
void registerHonoursExplicitInternalAddress() {
props.getCluster().getNode().setInternalAddress("app-1:8080");
bootstrap.registerOnStartup();
ArgumentCaptor<ClusterNode> nodeCaptor = ArgumentCaptor.forClass(ClusterNode.class);
verify(registry).register(nodeCaptor.capture(), any());
assertEquals("http://app-1:8080", nodeCaptor.getValue().internalAddress());
}
@Test
void registerUsesHttpsSchemeWhenConfigured() {
props.getCluster().getNode().setInternalAddress("app-1:8443");
props.getCluster().getNode().setScheme("https");
ClusterNodeBootstrap httpsBootstrap = new ClusterNodeBootstrap(props, registry);
ReflectionTestUtils.setField(httpsBootstrap, "serverPort", 8443);
httpsBootstrap.registerOnStartup();
ArgumentCaptor<ClusterNode> nodeCaptor = ArgumentCaptor.forClass(ClusterNode.class);
verify(registry).register(nodeCaptor.capture(), any());
assertEquals("https://app-1:8443", nodeCaptor.getValue().internalAddress());
}
@Test
void heartbeatAfterStartup_callsRegister_forSelfHealing() {
bootstrap.start();
bootstrap.registerOnStartup();
bootstrap.heartbeat();
verify(registry, times(2))
.register(
any(ClusterNode.class),
org.mockito.ArgumentMatchers.eq(Duration.ofSeconds(30)));
}
@Test
void smartLifecycleStop_deregisters() {
bootstrap.start();
bootstrap.registerOnStartup();
bootstrap.stop();
verify(registry, times(1)).deregister("node-test-1");
}
@Test
void smartLifecycleStop_beforeStartup_isNoop() {
bootstrap.stop();
verify(registry, never()).deregister(any());
}
@Test
void heartbeatAfterStop_doesNotReRegister() {
// Heartbeat-after-stop race: the @Scheduled tick fires during a slow drain. Without
// a guard it would re-register the dead node until TTL expiry.
bootstrap.start();
bootstrap.registerOnStartup();
verify(registry, times(1)).register(any(ClusterNode.class), any(Duration.class));
bootstrap.stop();
verify(registry, times(1)).deregister("node-test-1");
bootstrap.heartbeat();
verify(registry, times(1)).register(any(ClusterNode.class), any(Duration.class));
}
}
@@ -1,133 +0,0 @@
package stirling.software.proprietary.cluster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.KeyValueCache;
import stirling.software.common.cluster.RateLimitStore;
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
import stirling.software.common.cluster.inprocess.InProcessJobStore;
import stirling.software.common.cluster.inprocess.InProcessKeyValueCache;
import stirling.software.common.cluster.inprocess.InProcessRateLimitStore;
/**
* Multi-node contract test using in-process impls shared across two "nodes". Verifies cross-node
* visibility, global rate-limit counters, and cache propagation without requiring Docker.
* Valkey-specific behavior (MULTI/EXEC atomicity, WATCH races, TTL) is in
* LiveValkeyIntegrationTest.
*/
class MultiNodeClusterScenarioTest {
private JobStore sharedJobStore;
private RateLimitStore sharedRateLimit;
private KeyValueCache sharedCache;
private ClusterBackplane backplaneA;
private ClusterBackplane backplaneB;
@BeforeEach
void setUp() {
sharedJobStore = new InProcessJobStore();
sharedRateLimit = new InProcessRateLimitStore();
sharedCache = new InProcessKeyValueCache();
backplaneA = constBackplane("node-A", "valkey");
backplaneB = constBackplane("node-B", "valkey");
}
@Test
@DisplayName("async job created on node-A is readable from node-B via shared JobStore")
void jobStatusVisibleCrossNode() {
JobStoreEntry entry =
new JobStoreEntry(
"job-1",
JobStoreEntry.JobState.RUNNING,
"node-A",
Instant.now(),
null,
null,
List.of("file-1"),
Map.of());
sharedJobStore.put(entry, Duration.ofMinutes(30));
Optional<JobStoreEntry> seenOnB = sharedJobStore.get("job-1");
assertTrue(seenOnB.isPresent(), "node-B must see node-A's job in shared JobStore");
assertEquals("node-A", seenOnB.get().owningNodeId());
assertEquals(JobStoreEntry.JobState.RUNNING, seenOnB.get().state());
}
@Test
@DisplayName("global rate limit - capacity counted once across both nodes")
void rateLimitGlobalAcrossNodes() {
long capacity = 4L;
RateLimitDecision a1 =
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
RateLimitDecision b1 =
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
RateLimitDecision a2 =
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
RateLimitDecision b2 =
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
RateLimitDecision a3 =
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
assertTrue(a1.allowed());
assertTrue(b1.allowed());
assertTrue(a2.allowed());
assertTrue(b2.allowed());
assertFalse(a3.allowed(), "5th request across both nodes must be rejected (limit=4)");
}
@Test
@DisplayName("KeyValueCache populated on A is observed on B; evict on A propagates")
void apiKeyCacheVisibleCrossNode() {
sharedCache.put("apikey", "hash-bob", "bob", Duration.ofSeconds(60));
assertEquals("bob", sharedCache.get("apikey", "hash-bob").orElse(null));
sharedCache.evict("apikey", "hash-bob");
assertFalse(sharedCache.get("apikey", "hash-bob").isPresent());
}
@Test
@DisplayName("backplaneType reports 'valkey' on every node; localNodeId is distinct")
void backplaneType() {
assertEquals("valkey", backplaneA.backplaneType());
assertEquals("valkey", backplaneB.backplaneType());
assertEquals("node-A", backplaneA.localNodeId());
assertEquals("node-B", backplaneB.localNodeId());
assertNotEquals(backplaneA.localNodeId(), backplaneB.localNodeId());
assertNotNull(backplaneA.localNodeId());
}
private ClusterBackplane constBackplane(String nodeId, String type) {
return new ClusterBackplane() {
@Override
public boolean isHealthy() {
return true;
}
@Override
public String backplaneType() {
return type;
}
@Override
public String localNodeId() {
return nodeId;
}
};
}
}
@@ -1,237 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import stirling.software.common.cluster.ClusterNode;
import stirling.software.common.cluster.DistributedLock;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
import stirling.software.common.model.ApplicationProperties;
/**
* Opt-in live cluster test against an EXTERNAL Valkey/Redis given by {@code
* STIRLING_TEST_VALKEY_URL} (e.g. a managed {@code rediss://} endpoint). Unlike {@link
* LiveValkeyIntegrationTest} (no-auth local container) this drives three independent node stacks
* through the production {@link ValkeyConnectionConfiguration#valkeyConnectionFactory()} bean - so
* a {@code rediss://} URL exercises the real TLS handshake (verifyPeer=FULL) and credential path
* end to end.
*
* <p>Skips unless the env var is set, so it never runs in normal CI. No secrets are committed.
*/
@EnabledIfEnvironmentVariable(named = "STIRLING_TEST_VALKEY_URL", matches = "rediss?://.+")
class LiveExternalClusterTest {
private static final int NODES = 3;
private static final String RUN = UUID.randomUUID().toString().substring(0, 8);
private static final List<LettuceConnectionFactory> factories = new ArrayList<>();
private static final List<StringRedisTemplate> templates = new ArrayList<>();
@BeforeAll
static void connectAllNodes() {
String url = System.getenv("STIRLING_TEST_VALKEY_URL");
for (int i = 0; i < NODES; i++) {
ApplicationProperties p = new ApplicationProperties();
p.getCluster().setEnabled(true);
p.getCluster().setBackplane("valkey");
p.getCluster().getValkey().setUrl(url);
p.getCluster().getNode().setId(nodeId(i));
// Production bean: parse + credentials + TLS + eager PING handshake (proves reachable).
LettuceConnectionFactory f =
new ValkeyConnectionConfiguration(p).valkeyConnectionFactory();
factories.add(f);
templates.add(new StringRedisTemplate(f));
}
}
@AfterAll
static void disconnect() {
for (LettuceConnectionFactory f : factories) {
try {
f.destroy();
} catch (RuntimeException ignored) {
// best effort
}
}
}
private static String nodeId(int i) {
return "ext-" + RUN + "-node-" + (i + 1);
}
private ApplicationProperties propsForNode(int i) {
ApplicationProperties p = new ApplicationProperties();
p.getCluster().setEnabled(true);
p.getCluster().setBackplane("valkey");
p.getCluster().getNode().setId(nodeId(i));
return p;
}
@Test
@DisplayName("TLS reachability: every node's backplane reports healthy over the external URL")
void allNodesHealthyOverTls() {
for (int i = 0; i < NODES; i++) {
ValkeyClusterBackplane bp =
new ValkeyClusterBackplane(propsForNode(i), templates.get(i));
assertTrue(bp.isHealthy(), nodeId(i) + " must reach the external Valkey (PING)");
assertEquals(nodeId(i), bp.localNodeId());
}
}
@Test
@DisplayName("3-node registry: each node sees all peers; deregister drops one cluster-wide")
void threeNodeRegistryConverges() {
List<ValkeyInstanceRegistry> regs = new ArrayList<>();
for (int i = 0; i < NODES; i++) {
regs.add(new ValkeyInstanceRegistry(templates.get(i)));
}
for (int i = 0; i < NODES; i++) {
regs.get(i)
.register(
new ClusterNode(
nodeId(i), "10.0.0." + i + ":8080", Instant.now(), "BOTH"),
Duration.ofSeconds(30));
}
try {
// Node 0's view must include all three registrations made on three connections.
for (int i = 0; i < NODES; i++) {
final String id = nodeId(i);
boolean seenByNode0 =
regs.get(0).activeNodes().stream().anyMatch(n -> id.equals(n.nodeId()));
assertTrue(seenByNode0, "node-0 must see " + id + " registered by another node");
}
regs.get(1).deregister(nodeId(2));
assertFalse(
regs.get(0).lookup(nodeId(2)).isPresent(),
"deregister on node-1 must be visible from node-0");
} finally {
regs.get(0).deregister(nodeId(0));
regs.get(1).deregister(nodeId(1));
}
}
@Test
@DisplayName(
"Cross-node JobStore: put on node-0 visible on node-1/2; delete on node-2 clears it")
void jobStoreVisibleAcrossNodes() {
ValkeyJobStore s0 = new ValkeyJobStore(templates.get(0));
ValkeyJobStore s1 = new ValkeyJobStore(templates.get(1));
ValkeyJobStore s2 = new ValkeyJobStore(templates.get(2));
String jobId = "ext-job-" + RUN;
String fileId = "ext-file-" + RUN;
s0.put(
new JobStoreEntry(
jobId,
JobStoreEntry.JobState.RUNNING,
nodeId(0),
Instant.now(),
null,
null,
List.of(fileId),
Map.of("k", "v")),
Duration.ofSeconds(60));
Optional<JobStoreEntry> onNode1 = s1.get(jobId);
assertTrue(onNode1.isPresent(), "node-1 must see node-0's job write");
assertEquals(nodeId(0), onNode1.get().owningNodeId());
assertEquals(jobId, s2.findJobIdByFileId(fileId).orElse(null), "reverse index visible too");
s2.delete(jobId);
assertFalse(s0.exists(jobId), "delete on node-2 must clear the hash for node-0");
assertFalse(
s1.findJobIdByFileId(fileId).isPresent(),
"delete on node-2 must clear the reverse index for node-1");
}
@Test
@DisplayName("Global rate limit: ONE shared budget enforced across all three nodes")
void rateLimitGlobalAcrossThreeNodes() {
List<ValkeyRateLimitStore> stores = new ArrayList<>();
for (int i = 0; i < NODES; i++) {
ValkeyRateLimitStore s = new ValkeyRateLimitStore(factories.get(i));
s.initProxyManager();
stores.add(s);
}
String key = "ext-rl-" + RUN;
long capacity = 6;
AtomicInteger allowed = new AtomicInteger();
for (int i = 0; i < 12; i++) {
RateLimitDecision d =
stores.get(i % NODES).tryConsume(key, capacity, Duration.ofSeconds(60));
if (d.allowed()) {
allowed.incrementAndGet();
}
}
assertEquals(
capacity,
allowed.get(),
"exactly the global capacity must be allowed across all three nodes");
}
@Test
@DisplayName("Distributed lock: held by node-0 excludes node-1/2; stale release cannot steal")
void distributedLockAcrossNodes() throws InterruptedException {
ValkeyDistributedLock l0 = new ValkeyDistributedLock(templates.get(0));
ValkeyDistributedLock l1 = new ValkeyDistributedLock(templates.get(1));
ValkeyDistributedLock l2 = new ValkeyDistributedLock(templates.get(2));
String key = "ext-lock-" + RUN;
Optional<DistributedLock.LockHandle> a = l0.tryAcquire(key, Duration.ofSeconds(30));
assertTrue(a.isPresent());
assertFalse(l1.tryAcquire(key, Duration.ofSeconds(30)).isPresent(), "node-1 excluded");
assertFalse(l2.tryAcquire(key, Duration.ofSeconds(30)).isPresent(), "node-2 excluded");
a.get().release();
Optional<DistributedLock.LockHandle> b = l1.tryAcquire(key, Duration.ofSeconds(30));
assertTrue(b.isPresent(), "node-1 acquires after node-0 releases");
// Short lease that expires, then node-2 takes it; node-1's stale release must not steal.
String key2 = "ext-lock2-" + RUN;
Optional<DistributedLock.LockHandle> shortHeld =
l1.tryAcquire(key2, Duration.ofMillis(500));
assertTrue(shortHeld.isPresent());
Thread.sleep(900);
Optional<DistributedLock.LockHandle> stolen = l2.tryAcquire(key2, Duration.ofSeconds(30));
assertTrue(stolen.isPresent(), "node-2 acquires after node-1's lease expires");
shortHeld.get().release(); // value-checked no-op
assertFalse(
l0.tryAcquire(key2, Duration.ofMillis(200)).isPresent(),
"node-2 still holds; node-1's stale release must not have stolen it");
b.get().release();
stolen.get().release();
}
@Test
@DisplayName("KeyValueCache: put on node-0 readable on node-1; evict visible on node-2")
void keyValueCacheAcrossNodes() {
ValkeyKeyValueCache c0 = new ValkeyKeyValueCache(templates.get(0));
ValkeyKeyValueCache c1 = new ValkeyKeyValueCache(templates.get(1));
ValkeyKeyValueCache c2 = new ValkeyKeyValueCache(templates.get(2));
String field = "ext-cache-" + RUN;
c0.put("apikey", field, "alice", Duration.ofSeconds(60));
assertEquals("alice", c1.get("apikey", field).orElse(null), "node-1 reads node-0's cache");
c2.evict("apikey", field);
assertFalse(c0.get("apikey", field).isPresent(), "evict on node-2 visible on node-0");
}
}
@@ -1,143 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.Container;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import stirling.software.common.model.ApplicationProperties;
/**
* Live AUTH coverage against a password-protected Valkey. The main {@link
* LiveValkeyIntegrationTest} runs against a no-auth instance, so the credential-bearing URL path
* (parse -> RedisStandaloneConfiguration -> real AUTH handshake) was otherwise unexercised. Drives
* the full production bean method {@code valkeyConnectionFactory()} so the parse, credential
* wiring, and eager-handshake all run exactly as at boot.
*/
@Testcontainers
@EnabledIf("isDockerAvailable")
class LiveValkeyAuthIntegrationTest {
private static final String PASSWORD = "s3cr3tpw";
// @Container is intentionally NOT used: lifecycle is managed manually so the container can be
// shared across the password-only and ACL-user cases without a per-method restart.
static final GenericContainer<?> VALKEY =
new GenericContainer<>(DockerImageName.parse("valkey/valkey:8.0-alpine"))
.withExposedPorts(6379)
.withCommand("valkey-server", "--requirepass", PASSWORD);
static boolean isDockerAvailable() {
return DockerClientFactory.instance().isDockerAvailable();
}
@org.junit.jupiter.api.BeforeAll
static void start() {
VALKEY.start();
}
@org.junit.jupiter.api.AfterAll
static void stop() {
VALKEY.stop();
}
private static String host() {
return VALKEY.getHost();
}
private static int port() {
return VALKEY.getMappedPort(6379);
}
private ApplicationProperties propsWithUrl(String url) {
ApplicationProperties p = new ApplicationProperties();
p.getCluster().setEnabled(true);
p.getCluster().setBackplane("valkey");
p.getCluster().getValkey().setUrl(url);
p.getCluster().getNode().setId("auth-test");
return p;
}
@Test
@DisplayName("password-only URL (redis://:pw@host) authenticates and round-trips a key")
void passwordOnlyAuthWorks() {
String url = "redis://:" + PASSWORD + "@" + host() + ":" + port();
LettuceConnectionFactory factory =
new ValkeyConnectionConfiguration(propsWithUrl(url)).valkeyConnectionFactory();
try {
StringRedisTemplate t = new StringRedisTemplate(factory);
t.opsForValue().set("auth:pwonly", "v1");
assertEquals(
"v1",
t.opsForValue().get("auth:pwonly"),
"password-only AUTH must succeed and the key must round-trip");
} finally {
factory.destroy();
}
}
@Test
@DisplayName("user:password URL (ACL named user) authenticates and round-trips a key")
void namedUserAuthWorks() throws Exception {
// Default user is password-protected; create a named ACL user to exercise two-arg AUTH.
Container.ExecResult res =
VALKEY.execInContainer(
"valkey-cli",
"-a",
PASSWORD,
"ACL",
"SETUSER",
"alice",
"on",
">alicepass",
"~*",
"+@all");
assertEquals(0, res.getExitCode(), "ACL SETUSER failed: " + res.getStderr());
String url = "redis://alice:alicepass@" + host() + ":" + port();
LettuceConnectionFactory factory =
new ValkeyConnectionConfiguration(propsWithUrl(url)).valkeyConnectionFactory();
try {
StringRedisTemplate t = new StringRedisTemplate(factory);
t.opsForValue().set("auth:named", "v2");
assertEquals(
"v2",
t.opsForValue().get("auth:named"),
"named-user AUTH must succeed and the key must round-trip");
} finally {
factory.destroy();
}
}
@Test
@DisplayName(
"wrong password → fast IllegalStateException (real WRONGPASS short-circuits retries)")
void wrongPasswordFailsFast() {
String url = "redis://:wrong-" + PASSWORD + "@" + host() + ":" + port();
ValkeyConnectionConfiguration cfg = new ValkeyConnectionConfiguration(propsWithUrl(url));
long start = System.nanoTime();
IllegalStateException ex =
assertThrows(IllegalStateException.class, cfg::valkeyConnectionFactory);
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
assertTrue(
ex.getMessage().toLowerCase().contains("authentication failed"),
"real Valkey WRONGPASS must be classified as an auth failure; got: "
+ ex.getMessage());
// The retry loop is 10 x 3s; a recognised auth failure must abort well before that.
assertTrue(
elapsedMs < 10_000,
"auth failure must short-circuit the 30s retry loop; elapsed=" + elapsedMs + " ms");
}
}
@@ -1,118 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import stirling.software.common.model.ApplicationProperties;
/**
* Live failure-injection: a frozen (network-black-holed) Valkey must NOT stall hot-path commands
* for Lettuce's 60s default. {@link ValkeyConnectionConfiguration} pins a 2s command timeout, so a
* paused server must surface an error in seconds, and the connection must recover when it returns.
*
* <p>Uses {@code docker pause}/{@code unpause} (TCP stays ESTABLISHED but the server never replies)
* to reproduce a partition rather than {@code stop} (which would fail fast with
* connection-refused).
*/
@Testcontainers
@EnabledIf("isDockerAvailable")
class LiveValkeyChaosTest {
@Container
static final GenericContainer<?> VALKEY =
new GenericContainer<>(DockerImageName.parse("valkey/valkey:8.0-alpine"))
.withExposedPorts(6379);
static boolean isDockerAvailable() {
return DockerClientFactory.instance().isDockerAvailable();
}
private boolean paused;
private LettuceConnectionFactory productionFactory() {
ApplicationProperties p = new ApplicationProperties();
p.getCluster().setEnabled(true);
p.getCluster().setBackplane("valkey");
p.getCluster()
.getValkey()
.setUrl("redis://" + VALKEY.getHost() + ":" + VALKEY.getMappedPort(6379));
p.getCluster().getNode().setId("chaos");
// Built via the production bean so the real 2s commandTimeout is in effect.
return new ValkeyConnectionConfiguration(p).valkeyConnectionFactory();
}
private void pause() {
DockerClientFactory.lazyClient().pauseContainerCmd(VALKEY.getContainerId()).exec();
paused = true;
}
private void unpause() {
DockerClientFactory.lazyClient().unpauseContainerCmd(VALKEY.getContainerId()).exec();
paused = false;
}
@AfterEach
void ensureUnpaused() {
if (paused) {
try {
unpause();
} catch (RuntimeException ignored) {
// container teardown will handle it
}
}
}
@Test
@DisplayName("frozen Valkey fails a command in seconds (2s timeout), not Lettuce's 60s default")
void commandTimeoutFiresUnderPartition() {
LettuceConnectionFactory factory = productionFactory();
try {
StringRedisTemplate t = new StringRedisTemplate(factory);
t.opsForValue().set("chaos:k", "before");
assertEquals("before", t.opsForValue().get("chaos:k"));
pause();
long start = System.nanoTime();
// DataAccessException is spring-data-redis's wrapper for the Lettuce timeout.
assertThrows(DataAccessException.class, () -> t.opsForValue().get("chaos:k"));
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
assertTrue(
elapsedMs < 15_000,
"command must abort on the ~2s timeout, not hang on the 60s default; elapsed="
+ elapsedMs
+ " ms");
unpause();
// After the partition heals the client must recover (Lettuce reconnects lazily).
String recovered = null;
long deadline = System.currentTimeMillis() + 10_000;
while (System.currentTimeMillis() < deadline) {
try {
recovered = t.opsForValue().get("chaos:k");
break;
} catch (RuntimeException retry) {
Thread.sleep(250);
}
}
assertEquals("before", recovered, "connection must recover after the partition heals");
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
factory.destroy();
}
}
}
@@ -1,519 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import stirling.software.common.cluster.ClusterNode;
import stirling.software.common.cluster.DistributedLock;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
import stirling.software.common.model.ApplicationProperties;
/**
* Live integration tests against a real Valkey instance, started by Testcontainers. The
* {@code @EnabledIf} guard probes the Docker daemon via {@link
* DockerClientFactory#isDockerAvailable()} (non-throwing) so the suite skips cleanly when Docker is
* unavailable - without that guard, {@code @Testcontainers} would throw {@code initializationError}
* (test FAILURE, not skip) on CI runners without Docker.
*/
@Testcontainers
@EnabledIf("isDockerAvailable")
class LiveValkeyIntegrationTest {
@Container
static final GenericContainer<?> VALKEY =
new GenericContainer<>(DockerImageName.parse("valkey/valkey:8.0-alpine"))
.withExposedPorts(6379);
static boolean isDockerAvailable() {
return DockerClientFactory.instance().isDockerAvailable();
}
private static LettuceConnectionFactory factoryA;
private static LettuceConnectionFactory factoryB;
private static StringRedisTemplate templateA;
private static StringRedisTemplate templateB;
@BeforeAll
static void connect() {
String host = VALKEY.getHost();
int port = VALKEY.getMappedPort(6379);
factoryA = new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
factoryA.afterPropertiesSet();
factoryB = new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
factoryB.afterPropertiesSet();
templateA = new StringRedisTemplate(factoryA);
templateB = new StringRedisTemplate(factoryB);
templateA.getConnectionFactory().getConnection().serverCommands().flushAll();
}
@AfterAll
static void disconnect() {
if (factoryA != null) factoryA.destroy();
if (factoryB != null) factoryB.destroy();
}
@Test
@DisplayName("Valkey reachable and isHealthy() = true after PING round-trip")
void backplaneHealthy() {
ApplicationProperties propsA = newProps("node-A");
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(propsA, templateA);
assertEquals("valkey", bp.backplaneType());
assertEquals("node-A", bp.localNodeId());
assertTrue(bp.isHealthy(), "Valkey must be reachable in the Testcontainers instance");
}
@Test
@DisplayName("JobStore put on connection A, get on connection B reads the same entry")
void jobStoreCrossConnectionVisibility() {
ValkeyJobStore storeA = new ValkeyJobStore(templateA);
ValkeyJobStore storeB = new ValkeyJobStore(templateB);
JobStoreEntry entry =
new JobStoreEntry(
"live-job-1",
JobStoreEntry.JobState.RUNNING,
"node-A",
Instant.now(),
null,
null,
List.of("live-file-1"),
Map.of("k", "v"));
storeA.put(entry, Duration.ofSeconds(30));
Optional<JobStoreEntry> seen = storeB.get("live-job-1");
assertTrue(seen.isPresent(), "storeB on different connection must see storeA's write");
assertEquals("node-A", seen.get().owningNodeId());
assertEquals(JobStoreEntry.JobState.RUNNING, seen.get().state());
assertEquals("live-job-1", storeB.findJobIdByFileId("live-file-1").orElse(null));
}
@Test
@DisplayName("JobStore entry expires after the configured duration")
void jobStoreTtlExpires() throws InterruptedException {
ValkeyJobStore store = new ValkeyJobStore(templateA);
store.put(
new JobStoreEntry(
"ttl-job",
JobStoreEntry.JobState.PENDING,
"node-A",
Instant.now(),
null,
null,
List.of(),
Map.of()),
Duration.ofSeconds(2));
assertTrue(store.exists("ttl-job"));
long deadline = System.currentTimeMillis() + 3000;
boolean expired = false;
while (System.currentTimeMillis() < deadline) {
if (!store.exists("ttl-job")) {
expired = true;
break;
}
Thread.sleep(100);
}
assertTrue(expired, "entry should TTL-expire within 3 s of a 2 s TTL");
}
@Test
@DisplayName("KeyValueCache propagates across connections; evict observed cross-connection")
void keyValueCacheCrossConnection() {
ValkeyKeyValueCache cacheA = new ValkeyKeyValueCache(templateA);
ValkeyKeyValueCache cacheB = new ValkeyKeyValueCache(templateB);
cacheA.put("apikey", "hash-bob", "bob", Duration.ofSeconds(30));
assertEquals("bob", cacheB.get("apikey", "hash-bob").orElse(null));
cacheA.evict("apikey", "hash-bob");
assertFalse(cacheB.get("apikey", "hash-bob").isPresent());
}
@Test
@DisplayName("RateLimitStore enforces ONE global budget across two instances")
void rateLimitGlobalAcrossInstances() {
ValkeyRateLimitStore storeA = newRateLimitStore(factoryA);
ValkeyRateLimitStore storeB = newRateLimitStore(factoryB);
String key = "live-user:alice";
long capacity = 4;
AtomicInteger allowed = new AtomicInteger();
for (int i = 0; i < 8; i++) {
// alternate consumers
var store = (i % 2 == 0) ? storeA : storeB;
RateLimitDecision d = store.tryConsume(key, capacity, Duration.ofSeconds(30));
if (d.allowed()) allowed.incrementAndGet();
}
assertEquals(
4,
allowed.get(),
"exactly 4 (the global capacity) must be allowed across both instances");
}
@Test
@DisplayName("DistributedLock excludes a second acquirer on a different connection")
void distributedLockMutualExclusion() {
ValkeyDistributedLock lockA = new ValkeyDistributedLock(templateA);
ValkeyDistributedLock lockB = new ValkeyDistributedLock(templateB);
Optional<DistributedLock.LockHandle> heldByA =
lockA.tryAcquire("election-X", Duration.ofSeconds(30));
assertTrue(heldByA.isPresent());
Optional<DistributedLock.LockHandle> heldByB =
lockB.tryAcquire("election-X", Duration.ofSeconds(30));
assertFalse(heldByB.isPresent(), "second acquirer must fail while A holds the lock");
heldByA.get().release();
// After release, B can acquire
Optional<DistributedLock.LockHandle> retry =
lockB.tryAcquire("election-X", Duration.ofSeconds(30));
assertTrue(retry.isPresent());
retry.get().release();
}
@Test
@DisplayName("renew() extends the lease TTL well beyond the original")
void distributedLockRenewExtendsLease() {
ValkeyDistributedLock lock = new ValkeyDistributedLock(templateA);
String key = "renew-" + java.util.UUID.randomUUID();
Optional<DistributedLock.LockHandle> held = lock.tryAcquire(key, Duration.ofSeconds(1));
assertTrue(held.isPresent());
assertTrue(held.get().renew(Duration.ofSeconds(30)), "renew on a held lock must succeed");
Long ttlMs =
templateA.getExpire(
"stirling:lock:" + key, java.util.concurrent.TimeUnit.MILLISECONDS);
assertNotNull(ttlMs);
assertTrue(
ttlMs > 1500 && ttlMs <= 30_000,
"renew must reset TTL to the new 30s lease, got " + ttlMs + " ms");
held.get().release();
}
@Test
@DisplayName("value-check prevents a stale owner from releasing/renewing a re-acquired lock")
void distributedLockStealPrevention() throws InterruptedException {
ValkeyDistributedLock lockA = new ValkeyDistributedLock(templateA);
ValkeyDistributedLock lockB = new ValkeyDistributedLock(templateB);
String key = "steal-" + java.util.UUID.randomUUID();
Optional<DistributedLock.LockHandle> a = lockA.tryAcquire(key, Duration.ofMillis(500));
assertTrue(a.isPresent());
// Let A's 500ms lease TTL-expire so Valkey drops the key, then B takes a fresh lock.
Thread.sleep(800);
Optional<DistributedLock.LockHandle> b = lockB.tryAcquire(key, Duration.ofSeconds(30));
assertTrue(b.isPresent(), "B must acquire after A's lease expired");
// A's stale handle (different UUID value) must touch neither B's renew nor B's key.
assertFalse(
a.get().renew(Duration.ofSeconds(30)),
"stale owner must not be able to renew a lock now owned by B");
a.get().release(); // value-checked DEL: must be a no-op, must NOT delete B's key
assertFalse(
lockA.tryAcquire(key, Duration.ofMillis(100)).isPresent(),
"B must still hold the lock; A's stale release must not have stolen it");
b.get().release();
}
@Test
@DisplayName("register is atomic (hash + TTL committed together, no orphan keys on crash)")
void registryRegisterIsAtomic() {
ValkeyInstanceRegistry reg = new ValkeyInstanceRegistry(templateA);
ClusterNode node =
new ClusterNode(
"atomic-node-" + java.util.UUID.randomUUID(),
"10.0.0.99:8080",
Instant.now(),
"BOTH");
reg.register(node, Duration.ofSeconds(30));
// TTL must be positive; -1 would mean EXPIRE did not commit inside MULTI/EXEC.
Long ttlMs =
templateA.getExpire(
"stirling:nodes:" + node.nodeId(),
java.util.concurrent.TimeUnit.MILLISECONDS);
assertNotNull(ttlMs);
assertTrue(
ttlMs > 0 && ttlMs <= 30_000,
"register() must atomically arm TTL; expected (0, 30000] ms, got " + ttlMs);
Optional<ClusterNode> seen = reg.lookup(node.nodeId());
assertTrue(seen.isPresent(), "hash fields must be visible after atomic register()");
assertEquals("10.0.0.99:8080", seen.get().internalAddress());
reg.deregister(node.nodeId());
}
@Test
@DisplayName("register on connection A is visible from connection B")
void registryCrossConnection() {
ValkeyInstanceRegistry regA = new ValkeyInstanceRegistry(templateA);
ValkeyInstanceRegistry regB = new ValkeyInstanceRegistry(templateB);
ClusterNode node = new ClusterNode("live-node-7", "10.0.0.7:8080", Instant.now(), "BOTH");
regA.register(node, Duration.ofSeconds(30));
Optional<ClusterNode> seen = regB.lookup("live-node-7");
assertTrue(seen.isPresent());
assertEquals("10.0.0.7:8080", seen.get().internalAddress());
boolean inActive =
regB.activeNodes().stream().anyMatch(n -> "live-node-7".equals(n.nodeId()));
assertTrue(inActive);
regA.deregister("live-node-7");
assertFalse(regB.lookup("live-node-7").isPresent());
}
@Test
@DisplayName("Bucket4j: no fixed-window boundary doubling (parity with in-process semantics)")
void rateLimitNoBoundaryDoubling() throws InterruptedException {
ValkeyRateLimitStore store = newRateLimitStore(factoryA);
String key = "boundary-" + java.util.UUID.randomUUID();
long capacity = 5;
Duration window = Duration.ofMillis(500);
int firstAllowed = 0;
for (int i = 0; i < 10; i++) {
if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++;
}
assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially");
Thread.sleep(window.toMillis() + 50);
int secondAllowed = 0;
long start = System.nanoTime();
for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) {
if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++;
}
assertTrue(
secondAllowed <= capacity,
"token-bucket must not let a fresh full capacity be consumed instantly across"
+ " the boundary; got "
+ secondAllowed);
}
private ValkeyRateLimitStore newRateLimitStore(LettuceConnectionFactory factory) {
ValkeyRateLimitStore store = new ValkeyRateLimitStore(factory);
store.initProxyManager();
return store;
}
@Test
@DisplayName("JobStore put is atomic (hash + TTL + reverse index visible together)")
void jobStorePutIsAtomic() {
ValkeyJobStore store = new ValkeyJobStore(templateA);
String jobId = "atomic-job-" + java.util.UUID.randomUUID();
String fileId = "atomic-file-" + java.util.UUID.randomUUID();
store.put(
new JobStoreEntry(
jobId,
JobStoreEntry.JobState.PENDING,
"node-A",
Instant.now(),
null,
null,
List.of(fileId),
Map.of("k", "v")),
Duration.ofSeconds(30));
assertTrue(store.exists(jobId), "hash must be visible after put");
Long jobTtl =
templateA.getExpire(
"stirling:job:" + jobId, java.util.concurrent.TimeUnit.MILLISECONDS);
assertNotNull(jobTtl);
assertTrue(jobTtl > 0, "hash must have TTL armed inside the same transaction");
assertEquals(jobId, store.findJobIdByFileId(fileId).orElse(null));
Long indexTtl =
templateA.getExpire(
"stirling:file2job:" + fileId, java.util.concurrent.TimeUnit.MILLISECONDS);
assertNotNull(indexTtl);
assertTrue(indexTtl > 0, "reverse index must also have TTL armed");
}
@Test
@DisplayName(
"JobStore.delete(): WATCH aborts when put() races between read and EXEC, no orphaned"
+ " reverse-index entries")
void jobStoreDeleteWatchRaceRetriesAndCleansUp() {
ValkeyJobStore store = new ValkeyJobStore(templateA);
String jobId = "watch-race-job-" + java.util.UUID.randomUUID();
String originalFile = "orig-file-" + java.util.UUID.randomUUID();
String newFile = "new-file-" + java.util.UUID.randomUUID();
store.put(
new JobStoreEntry(
jobId,
JobStoreEntry.JobState.RUNNING,
"node-A",
Instant.now(),
null,
null,
List.of(originalFile),
Map.of()),
Duration.ofSeconds(30));
// Simulate the race: between delete()'s WATCH read and EXEC, add a new fileId.
// The first EXEC aborts; the retry catches the new fileId and deletes both entries.
Thread mutator =
new Thread(
() -> {
try {
Thread.sleep(20);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
store.put(
new JobStoreEntry(
jobId,
JobStoreEntry.JobState.RUNNING,
"node-A",
Instant.now(),
null,
null,
List.of(originalFile, newFile),
Map.of()),
Duration.ofSeconds(30));
});
mutator.start();
store.delete(jobId);
try {
mutator.join(2000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
boolean hashGone = !store.exists(jobId);
boolean origIndexGone = !store.findJobIdByFileId(originalFile).isPresent();
boolean newIndexGone = !store.findJobIdByFileId(newFile).isPresent();
if (hashGone) {
assertTrue(
origIndexGone,
"if hash is deleted, original reverse-index entry must also be gone");
assertTrue(
newIndexGone,
"if hash is deleted after the racing put(), the WATCH retry must catch the"
+ " new fileId and delete its reverse-index entry too");
} else {
assertEquals(jobId, store.findJobIdByFileId(originalFile).orElse(null));
assertEquals(jobId, store.findJobIdByFileId(newFile).orElse(null));
}
}
@Test
@DisplayName("JobStore.delete() removes hash AND every reverse-index entry atomically")
void jobStoreDeleteRemovesReverseIndexEntries() {
ValkeyJobStore store = new ValkeyJobStore(templateA);
String jobId = "del-atomic-job-" + java.util.UUID.randomUUID();
String fileA = "del-atomic-fileA-" + java.util.UUID.randomUUID();
String fileB = "del-atomic-fileB-" + java.util.UUID.randomUUID();
store.put(
new JobStoreEntry(
jobId,
JobStoreEntry.JobState.COMPLETE,
"node-A",
Instant.now(),
Instant.now(),
null,
List.of(fileA, fileB),
Map.of()),
Duration.ofSeconds(30));
assertTrue(store.exists(jobId));
assertEquals(jobId, store.findJobIdByFileId(fileA).orElse(null));
assertEquals(jobId, store.findJobIdByFileId(fileB).orElse(null));
store.delete(jobId);
// Both the main hash AND every reverse-index entry must be gone; dangling reverse-index
// entries would cause findJobIdByFileId() to return a deleted jobId.
assertFalse(store.exists(jobId), "main hash must be deleted");
assertFalse(
store.findJobIdByFileId(fileA).isPresent(),
"reverse-index entry for fileA must not survive delete()");
assertFalse(
store.findJobIdByFileId(fileB).isPresent(),
"reverse-index entry for fileB must not survive delete()");
assertFalse(
Boolean.TRUE.equals(templateA.hasKey("stirling:file2job:" + fileA)),
"raw reverse-index key for fileA must not survive delete()");
assertFalse(
Boolean.TRUE.equals(templateA.hasKey("stirling:file2job:" + fileB)),
"raw reverse-index key for fileB must not survive delete()");
}
@Test
@DisplayName("JobStore.all() walks the keyspace via SCAN, not KEYS")
void jobStoreAllUsesScanNonBlocking() {
ValkeyJobStore store = new ValkeyJobStore(templateA);
for (int i = 0; i < 15; i++) {
store.put(
new JobStoreEntry(
"scan-job-" + i,
JobStoreEntry.JobState.PENDING,
"node-A",
Instant.now(),
null,
null,
List.of(),
Map.of()),
Duration.ofSeconds(30));
}
long observed = store.all().stream().filter(e -> e.jobId().startsWith("scan-job-")).count();
assertTrue(
observed >= 15,
"SCAN-based all() must surface every inserted job, saw " + observed);
}
@Test
@DisplayName("Valkey unreachable yields isHealthy() = false")
void unreachableBackplaneReportsUnhealthy() {
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration("localhost", 16400);
LettuceConnectionFactory dead = new LettuceConnectionFactory(cfg);
dead.afterPropertiesSet();
try {
StringRedisTemplate t = new StringRedisTemplate(dead);
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(newProps("orphan"), t);
assertFalse(bp.isHealthy(), "isHealthy must be false when Valkey is unreachable");
} finally {
dead.destroy();
}
}
private ApplicationProperties newProps(String nodeId) {
ApplicationProperties p = new ApplicationProperties();
p.getCluster().setEnabled(true);
p.getCluster().setBackplane("valkey");
p.getCluster()
.getValkey()
.setUrl("redis://" + VALKEY.getHost() + ":" + VALKEY.getMappedPort(6379));
p.getCluster().getNode().setId(nodeId);
return p;
}
}
@@ -1,61 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import stirling.software.common.model.ApplicationProperties;
/**
* S3 regression: {@link ValkeyClusterBackplane#isHealthy()} must route through {@code
* template.execute(...)} so the borrowed connection is always returned to the pool. Calling {@code
* getConnectionFactory().getConnection()} directly would leak the connection on every k8s liveness
* probe tick and exhaust the pool under monitoring load.
*/
class ValkeyClusterBackplaneTest {
@Test
void isHealthy_routesThroughTemplateExecute_andDoesNotTouchConnectionFactoryDirectly() {
StringRedisTemplate template = mock(StringRedisTemplate.class);
when(template.execute(any(RedisCallback.class))).thenReturn("PONG");
ApplicationProperties props = new ApplicationProperties();
props.getCluster().getNode().setId("n-1");
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(props, template);
assertTrue(bp.isHealthy());
verify(template, times(1)).execute(any(RedisCallback.class));
// Critical: never bypass the template's connection management.
verify(template, never()).getConnectionFactory();
}
@Test
void isHealthy_returnsFalseWhenExecuteThrows() {
StringRedisTemplate template = mock(StringRedisTemplate.class);
when(template.execute(any(RedisCallback.class))).thenThrow(new RuntimeException("boom"));
ApplicationProperties props = new ApplicationProperties();
props.getCluster().getNode().setId("n-1");
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(props, template);
assertFalse(bp.isHealthy());
}
@Test
void shouldRunLocalCleanup_returnsFalse_valkeyOwnsTtlEviction() {
StringRedisTemplate template = mock(StringRedisTemplate.class);
ApplicationProperties props = new ApplicationProperties();
props.getCluster().getNode().setId("n-1");
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(props, template);
assertFalse(bp.shouldRunLocalCleanup());
}
}
@@ -1,298 +0,0 @@
package stirling.software.proprietary.cluster.valkey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.atMost;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.RedisSystemException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import io.lettuce.core.RedisCommandExecutionException;
import io.lettuce.core.SslVerifyMode;
import stirling.software.proprietary.cluster.valkey.ValkeyConnectionConfiguration.Endpoint;
/**
* Verifies auth-fast-fail on WRONGPASS/NOAUTH/NOPERM: these are unrecoverable so the handshake must
* short-circuit after one attempt, not burn the 30 s retry loop.
*/
class ValkeyConnectionConfigurationTest {
@Test
@DisplayName("WRONGPASS surfaces in one attempt (no 30s retry loop)")
void wrongpass_failsImmediately_withoutRetries() throws Exception {
LettuceConnectionFactory factory = mock(LettuceConnectionFactory.class);
RedisConnection conn = mock(RedisConnection.class);
when(factory.getConnection()).thenReturn(conn);
RedisCommandExecutionException auth =
new RedisCommandExecutionException("WRONGPASS invalid username-password pair");
when(conn.ping()).thenThrow(new RedisSystemException("Error in execution", auth));
long start = System.nanoTime();
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() ->
ValkeyConnectionConfiguration.eagerHandshake(
factory, "valkey", 6379, false));
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
verify(factory, times(1)).getConnection();
verify(conn, times(1)).ping();
assertTrue(
elapsedMs < 1500,
"Auth failure must short-circuit retries; elapsed=" + elapsedMs + " ms");
assertTrue(
ex.getMessage().contains("authentication failed"),
"Error message must explain the auth failure; got: " + ex.getMessage());
verify(factory, atMost(1)).destroy();
}
@Test
@DisplayName("NOAUTH surfaces in one attempt")
void noauth_failsImmediately() {
LettuceConnectionFactory factory = mock(LettuceConnectionFactory.class);
RedisConnection conn = mock(RedisConnection.class);
when(factory.getConnection()).thenReturn(conn);
when(conn.ping())
.thenThrow(
new RedisSystemException(
"Error in execution",
new RedisCommandExecutionException(
"NOAUTH Authentication required.")));
assertThrows(
IllegalStateException.class,
() -> ValkeyConnectionConfiguration.eagerHandshake(factory, "v", 6379, false));
verify(conn, times(1)).ping();
}
@Test
@DisplayName("NOPERM surfaces in one attempt")
void noperm_failsImmediately() {
LettuceConnectionFactory factory = mock(LettuceConnectionFactory.class);
RedisConnection conn = mock(RedisConnection.class);
when(factory.getConnection()).thenReturn(conn);
when(conn.ping())
.thenThrow(
new RedisSystemException(
"Error in execution",
new RedisCommandExecutionException(
"NOPERM this user has no permissions to run the 'ping'"
+ " command")));
assertThrows(
IllegalStateException.class,
() -> ValkeyConnectionConfiguration.eagerHandshake(factory, "v", 6379, false));
verify(conn, times(1)).ping();
}
@Test
@DisplayName("isAuthFailure - direct RedisCommandExecutionException with auth prefix")
void isAuthFailure_directRedisCommandExecutionException() {
assertTrue(
ValkeyConnectionConfiguration.isAuthFailure(
new RedisCommandExecutionException("WRONGPASS bad password")));
assertTrue(
ValkeyConnectionConfiguration.isAuthFailure(
new RedisCommandExecutionException("NOAUTH required")));
assertTrue(
ValkeyConnectionConfiguration.isAuthFailure(
new RedisCommandExecutionException("NOPERM denied")));
}
@Test
@DisplayName("isAuthFailure - wrapped inside RedisSystemException (production path)")
void isAuthFailure_wrappedBySpring() {
assertTrue(
ValkeyConnectionConfiguration.isAuthFailure(
new RedisSystemException(
"Error in execution",
new RedisCommandExecutionException("WRONGPASS bad password"))));
}
@Test
@DisplayName("isAuthFailure - connection errors do NOT count as auth failures")
void isAuthFailure_connectionErrorReturnsFalse() {
assertFalse(
ValkeyConnectionConfiguration.isAuthFailure(
new RedisSystemException(
"Redis connection failed",
new io.lettuce.core.RedisConnectionException(
"Connection refused"))));
assertFalse(
ValkeyConnectionConfiguration.isAuthFailure(
new IllegalStateException("Valkey PING returned 'foo' (expected PONG)")));
}
@Test
@DisplayName("bad PONG (protocol error) is not an auth failure")
void unexpectedPong_isNotAuthFailure() {
assertFalse(
ValkeyConnectionConfiguration.isAuthFailure(
new IllegalStateException("Valkey PING returned 'bar' (expected PONG)")));
}
@Test
@DisplayName("TLS on, skipCertVerification=false → useSsl + verifyPeer=FULL (default)")
void tls_defaultEnforcesFullPeerVerification() {
LettuceClientConfiguration cfg =
ValkeyConnectionConfiguration.buildClientConfiguration(true, false);
assertTrue(cfg.isUseSsl(), "TLS must be enabled");
assertSame(SslVerifyMode.FULL, cfg.getVerifyMode());
assertTrue(cfg.isVerifyPeer());
}
@Test
@DisplayName("TLS on, skipCertVerification=true → verifyPeer=NONE (dev override)")
void tls_skipCertVerificationOptOut() {
LettuceClientConfiguration cfg =
ValkeyConnectionConfiguration.buildClientConfiguration(true, true);
assertTrue(cfg.isUseSsl());
assertSame(SslVerifyMode.NONE, cfg.getVerifyMode());
}
@Test
@DisplayName("TLS off → no SSL, verify flag default (skipCertVerification ignored)")
void noTls_ignoresSkipFlag() {
LettuceClientConfiguration cfg =
ValkeyConnectionConfiguration.buildClientConfiguration(false, true);
assertFalse(cfg.isUseSsl());
}
@Nested
@DisplayName("parseUrl()")
class ParseUrl {
@Test
@DisplayName("host + explicit port, no auth, no TLS")
void hostAndPort() {
Endpoint e = ValkeyConnectionConfiguration.parseUrl("redis://valkey.internal:6380");
assertEquals("valkey.internal", e.host());
assertEquals(6380, e.port());
assertFalse(e.tls());
assertNull(e.username());
assertNull(e.password());
}
@Test
@DisplayName("missing port defaults to 6379")
void defaultPort() {
assertEquals(6379, ValkeyConnectionConfiguration.parseUrl("redis://host").port());
}
@Test
@DisplayName("rediss:// scheme selects TLS")
void redissSelectsTls() {
assertTrue(ValkeyConnectionConfiguration.parseUrl("rediss://host:6379").tls());
}
@Test
@DisplayName("user:password@ sets both credentials")
void userAndPassword() {
Endpoint e = ValkeyConnectionConfiguration.parseUrl("redis://alice:s3cret@host");
assertEquals("alice", e.username());
assertEquals("s3cret", e.password());
}
@Test
@DisplayName("empty user (:pw@) is password-only auth, username stays null")
void passwordOnlyEmptyUser() {
Endpoint e = ValkeyConnectionConfiguration.parseUrl("redis://:s3cret@host");
assertNull(e.username(), "empty user must NOT become an empty-string username");
assertEquals("s3cret", e.password());
}
@Test
@DisplayName("single userinfo token (no colon) is treated as the password")
void passwordOnlyNoColon() {
Endpoint e = ValkeyConnectionConfiguration.parseUrl("redis://s3cret@host");
assertNull(e.username());
assertEquals("s3cret", e.password());
}
@Test
@DisplayName("only the first colon splits user/password (colons allowed in password)")
void colonInPassword() {
Endpoint e = ValkeyConnectionConfiguration.parseUrl("redis://user:pa:ss:word@host");
assertEquals("user", e.username());
assertEquals("pa:ss:word", e.password());
}
@Test
@DisplayName("percent-encoded reserved chars are decoded in the password")
void percentEncodedPassword() {
// %40 -> '@', %23 -> '#': both must be encoded or URI parses them structurally.
Endpoint e = ValkeyConnectionConfiguration.parseUrl("redis://:p%40ss%23word@host");
assertNull(e.username());
assertEquals("p@ss#word", e.password());
}
@Test
@DisplayName("blank url throws with a backplane-config message")
void blankUrlThrows() {
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> ValkeyConnectionConfiguration.parseUrl(" "));
assertTrue(ex.getMessage().contains("cluster.valkey.url must be set"));
}
@Test
@DisplayName("null url throws")
void nullUrlThrows() {
assertThrows(
IllegalStateException.class,
() -> ValkeyConnectionConfiguration.parseUrl(null));
}
@Test
@DisplayName("url with no host throws a clear error (scheme-less host:port pitfall)")
void noHostThrows() {
// "localhost:6379" parses 'localhost' as the scheme, leaving no authority/host.
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> ValkeyConnectionConfiguration.parseUrl("localhost:6379"));
assertTrue(
ex.getMessage().contains("has no host"),
"message must name the missing host; got: " + ex.getMessage());
}
@Test
@DisplayName("non-numeric port yields no host (URI registry-authority fallback)")
void nonNumericPortHasNoHost() {
// java.net.URI does not throw on a bad port; it falls back to registry authority and
// reports host=null, so this must surface as the clear no-host error, not a NPE later.
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> ValkeyConnectionConfiguration.parseUrl("redis://host:notaport"));
assertTrue(ex.getMessage().contains("has no host"));
}
@Test
@DisplayName("syntactically invalid uri throws with the offending url")
void invalidUriThrows() {
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> ValkeyConnectionConfiguration.parseUrl("redis://ho st:6379"));
assertTrue(ex.getMessage().contains("not a valid URI"));
assertTrue(ex.getMessage().contains("redis://ho st:6379"));
}
}
}
@@ -79,8 +79,7 @@ class AuthControllerLoginTest {
totpService,
refreshRateLimitService,
securityProperties,
applicationProperties,
new stirling.software.proprietary.service.AiUserDataService(null));
applicationProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -251,35 +251,6 @@ class JwtServiceTest {
assertNull(jwtService.extractToken(request));
}
@Test
void testExtractUsernameFromRequestAllowExpiredReturnsUsername() throws Exception {
String username = "alice";
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
String token = jwtService.generateToken(username, Collections.emptyMap());
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
assertEquals(username, jwtService.extractUsernameFromRequestAllowExpired(request));
}
@Test
void testExtractUsernameFromRequestAllowExpiredReturnsNullWhenNoToken() {
when(request.getHeader("Authorization")).thenReturn(null);
assertNull(jwtService.extractUsernameFromRequestAllowExpired(request));
}
@Test
void testExtractUsernameFromRequestAllowExpiredReturnsNullOnGarbageToken() {
// The logout path depends on this helper returning null - never throwing - so a
// malformed JWT never blocks a user from logging out. Any parse/validation problem
// collapses to "no resolvable user".
when(request.getHeader("Authorization")).thenReturn("Bearer not-a-real-jwt");
assertNull(jwtService.extractUsernameFromRequestAllowExpired(request));
}
@Test
void testGenerateTokenWithKeyId() throws Exception {
String username = "testuser";
@@ -46,7 +46,7 @@ class AiEngineClientTest {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
assertSame(cause, ex.getCause(), "Original cause should be preserved for diagnostics");
@@ -58,7 +58,7 @@ class AiEngineClientTest {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.GATEWAY_TIMEOUT, ex.getStatusCode());
assertSame(cause, ex.getCause());
@@ -70,7 +70,7 @@ class AiEngineClientTest {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.get("/x", null));
assertThrows(ResponseStatusException.class, () -> client.get("/x"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@@ -80,7 +80,7 @@ class AiEngineClientTest {
applicationProperties.getAiEngine().setEnabled(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@@ -1,76 +0,0 @@
package stirling.software.proprietary.service;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.io.IOException;
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.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Tests the wiring of {@link AiUserDataService}: the path/userId combination forwarded to the
* engine, plus the swallowing-not-throwing behaviour for engine failures. Doesn't try to assert
* {@code @Async} dispatch - that's Spring infrastructure and only fires through the proxy, which a
* unit-level test bypasses by design.
*/
@ExtendWith(MockitoExtension.class)
class AiUserDataServiceTest {
private static final String PURGE_PATH = "/api/v1/documents/by-owner";
@Mock private AiEngineClient aiEngineClient;
private AiUserDataService service;
@BeforeEach
void setUp() {
service = new AiUserDataService(aiEngineClient);
}
@Test
void purgesUnderTheGivenUserIdOnTheByOwnerEndpoint() throws IOException {
service.purgeUserDocuments("alice");
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
}
@Test
void noOpWhenUserIdIsNull() throws IOException {
service.purgeUserDocuments(null);
verify(aiEngineClient, never()).delete(eq(PURGE_PATH), eq(null));
}
@Test
void noOpWhenUserIdIsBlank() throws IOException {
service.purgeUserDocuments(" ");
verify(aiEngineClient, never()).delete(eq(PURGE_PATH), eq(" "));
}
@Test
void swallowsIoExceptionFromEngine() throws IOException {
doThrow(new IOException("connection refused"))
.when(aiEngineClient)
.delete(eq(PURGE_PATH), eq("alice"));
// Must not throw - the logout path depends on this swallowing failures so the user
// can still log out when the engine is unreachable.
service.purgeUserDocuments("alice");
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
}
@Test
void swallowsResponseStatusExceptionFromEngine() throws IOException {
doThrow(new ResponseStatusException(HttpStatus.BAD_GATEWAY, "engine returned 502"))
.when(aiEngineClient)
.delete(eq(PURGE_PATH), eq("alice"));
service.purgeUserDocuments("alice");
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
}
}
@@ -8,7 +8,6 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
@@ -118,9 +117,7 @@ class AiWorkflowServiceTest {
toolMetadataService,
tempFileManager,
fileIdStrategy,
endpointResolver,
null,
new ApplicationProperties());
endpointResolver);
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
}
@@ -482,20 +479,18 @@ class AiWorkflowServiceTest {
{"outcome":"answer","answer":"done","evidence":[]}
""";
}
Consumer<String> consumer = inv.getArgument(3);
Consumer<String> consumer = inv.getArgument(2);
consumer.accept(wrapAsResultEvent(responseJson));
return null;
})
.when(aiEngineClient)
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
.streamPost(eq("/api/v1/orchestrator"), anyString(), any());
AiWorkflowResponse result = service.orchestrate(requestFor(input, "summarise this"));
assertEquals(AiWorkflowOutcome.ANSWER, result.getOutcome());
verify(aiEngineClient, times(1))
.postLongRunning(eq("/api/v1/documents"), anyString(), nullable(String.class));
verify(aiEngineClient, times(2))
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
verify(aiEngineClient, times(1)).postLongRunning(eq("/api/v1/documents"), anyString());
verify(aiEngineClient, times(2)).streamPost(eq("/api/v1/orchestrator"), anyString(), any());
}
// --- helpers ---
@@ -503,12 +498,12 @@ class AiWorkflowServiceTest {
private void stubOrchestrator(String responseJson) throws IOException {
doAnswer(
inv -> {
Consumer<String> consumer = inv.getArgument(3);
Consumer<String> consumer = inv.getArgument(2);
consumer.accept(wrapAsResultEvent(responseJson));
return null;
})
.when(aiEngineClient)
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
.streamPost(eq("/api/v1/orchestrator"), anyString(), any());
}
/**
@@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -73,8 +72,7 @@ class PdfCommentAgentOrchestratorTest {
pdfTextChunkExtractor,
pdfDocumentFactory,
objectMapper,
pdfAnnotationService,
null);
pdfAnnotationService);
}
@Test
@@ -98,7 +96,7 @@ class PdfCommentAgentOrchestratorTest {
new PdfCommentInstruction(
"p1-c0", "Comment on page 1", null, null)),
"reviewed");
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "please comment");
@@ -132,7 +130,7 @@ class PdfCommentAgentOrchestratorTest {
new PdfCommentInstruction("p0-c0", "Valid", null, null),
new PdfCommentInstruction("p999-c999", "Bogus", null, null)),
"mixed");
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
@@ -158,7 +156,7 @@ class PdfCommentAgentOrchestratorTest {
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse("s", List.of(), "no comments worth making");
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
@@ -186,7 +184,7 @@ class PdfCommentAgentOrchestratorTest {
ResponseStatusException.class,
() -> orchestrator.applyComments(input, "whatever"));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
verify(aiEngineClient, never()).post(anyString(), anyString());
}
@Test
@@ -199,7 +197,7 @@ class PdfCommentAgentOrchestratorTest {
ResponseStatusException.class,
() -> orchestrator.applyComments(input, tooLong));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
verify(aiEngineClient, never()).post(anyString(), anyString());
}
@Test
@@ -211,7 +209,7 @@ class PdfCommentAgentOrchestratorTest {
ResponseStatusException.class,
() -> orchestrator.applyComments(input, " "));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
verify(aiEngineClient, never()).post(anyString(), anyString());
}
// ---------------------------------------------------------------------
@@ -1,26 +0,0 @@
package stirling.software.saas.payg.charge;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
/**
* Per-call context for {@link JobChargeService#openProcess}. Carries the caller's identity and what
* kind of process this is. Does NOT carry policy fields — the charge service resolves the effective
* policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from
* the live policy.
*/
public record ChargeContext(
Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) {
public ChargeContext {
if (ownerUserId == null) {
throw new IllegalArgumentException("ownerUserId is required");
}
if (source == null) {
throw new IllegalArgumentException("source is required");
}
if (processType == null) {
throw new IllegalArgumentException("processType is required");
}
}
}

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