Compare commits

..
Author SHA1 Message Date
Anthony Stirling 60c81e20d0 Add cluster deployment artifacts and Helm chart 2026-05-27 12:21:04 +01:00
743 changed files with 9075 additions and 71209 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/
+9 -9
View File
@@ -1,20 +1,20 @@
###############################################################################
# Stirling-PDF SaaS environment defaults.
# Stirling-PDF SaaS local environment template.
#
# This file is committed and provides non-secret defaults loaded by
# `task backend:dev:saas`. Put real values for secrets (passwords, project
# refs, edge function secrets) in `.env.saas.local` - any variable set there
# takes precedence over what's defined here.
# Copy this file to `.env.saas.local` (gitignored) and fill in real values.
# Loaded by `task backend:dev:saas` via Taskfile's `dotenv:` directive, then
# read by Spring Boot's `${...}` placeholders in application-saas.properties
# and application-dev.properties.
#
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
# DO NOT commit `.env.saas.local`. Only `.env.saas.example` is checked in.
###############################################################################
# ---------- Supabase project ----------
# Project reference (the subdomain part of <ref>.supabase.co). Required.
# Set in .env.saas.local.
# Example dev project:
SAAS_DB_PROJECT_REF=
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
# Edge function secret used by billing/license rollup calls.
SUPABASE_EDGE_FUNCTION_SECRET=
# ---------- Database (saas profile) ----------
@@ -28,7 +28,7 @@ SAAS_DB_PASSWORD=
# ---------- Database (dev profile overrides) ----------
# Used when `--spring.profiles.include=dev` is active. The dev profile
# defaults the URL/username to the shared dev Supabase project, but the
# password must still be provided in .env.saas.local.
# password must still be provided here.
SAAS_DEV_DB_URL=
SAAS_DEV_DB_USERNAME=postgres
SAAS_DEV_DB_PASSWORD=
+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')
-2
View File
@@ -38,8 +38,6 @@ project: &project
- frontend/**
- docker/**
- scripts/RestartHelper.java
- scripts/db-migration/**
- .github/workflows/db-migration-test.yml
frontend: &frontend
- frontend/**
+3 -4
View File
@@ -13,7 +13,7 @@ Usage:
"""
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-GB/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import argparse
import glob
@@ -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)
@@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-GB/translation.toml)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -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)
+1 -2
View File
@@ -287,7 +287,6 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
@@ -310,7 +309,7 @@ jobs:
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
# Create V2 PR-specific directories
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage}
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
# Move docker-compose file to correct location
mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml
+27 -124
View File
@@ -1,9 +1,8 @@
name: AI Engine CI
# Validates the Python AI engine: regenerates tool models and runs the
# engine quality gate (lint, type-check, format-check, tests). Called from
# build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net.
# Validates the Python AI engine: regenerates tool models, runs fixers,
# lint, type-check, and tests. Called from build.yml on PRs and merge_group;
# also runs directly on push to main as a post-merge safety net.
on:
workflow_call:
push:
@@ -52,95 +51,27 @@ jobs:
run: task engine:tool-models
- name: Verify tool models are up to date
id: tool-models-check
continue-on-error: true
run: git diff --exit-code engine/src/stirling/models/tool_models.py
- name: Comment on tool models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const body = [
marker,
'### Tool Models Check Failed',
'',
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
'',
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if tool models check failed
if: steps.tool-models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Tool Models Check Failed"
echo "============================================"
echo ""
echo "The generated engine/src/stirling/models/tool_models.py"
echo "is out of date with the Java OpenAPI spec and will"
echo "need to be regenerated before it can be merged in."
echo ""
echo "Run 'task engine:tool-models' to regenerate, then"
echo "commit the updated file."
echo "============================================"
exit 1
if ! git diff --exit-code engine/src/stirling/models/tool_models.py; then
echo "tool_models.py is out of date."
echo "Run 'task engine:tool-models' locally and commit the updated file."
exit 1
fi
- name: Remove tool models check comment on success
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Run fixers
run: task engine:fix
- name: Quality-check engine
id: engine-check
run: task engine:check
continue-on-error: true
- name: Verify fixes are committed
id: fixer_changes
run: |
if ! git diff --quiet; then
git --no-pager diff --stat
echo "::error::There are issues with your Python code that will need to be fixed before they can be merged in. Run 'task engine:fix' to auto-fix what can be fixed automatically, then run 'task engine:check' to see what still needs fixing manually."
exit 1
fi
- name: Comment on engine check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.engine-check.outcome == 'failure' && github.event_name == 'pull_request'
- name: Comment on fixer failures
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
@@ -176,39 +107,11 @@ jobs:
});
}
- name: Fail if engine check failed
if: steps.engine-check.outcome == 'failure'
run: |
echo "============================================"
echo " Engine Check Failed"
echo "============================================"
echo ""
echo "There are issues with your Python code that"
echo "will need to be fixed before they can be merged in."
echo ""
echo "Run 'task engine:fix' to auto-fix what can be"
echo "fixed automatically, then run 'task engine:check'"
echo "to see what still needs fixing manually."
echo "============================================"
exit 1
- name: Run linting
run: task engine:lint
- name: Remove engine check comment on success
if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- engine-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Run type checking
run: task engine:typecheck
- name: Run tests
run: task engine:test
+22 -98
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
@@ -76,7 +67,7 @@ jobs:
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Comment on backend format check failure
- name: Comment on Java formatting failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.spotless-check.outcome == 'failure' && github.event_name == 'pull_request'
@@ -87,11 +78,15 @@ jobs:
const marker = '<!-- java-formatting-check -->';
const body = [
marker,
'### Backend Format Check Failed',
'### Java Formatting Check Failed',
'',
'There are formatting issues in your Java code that will need to be fixed before they can be merged in.',
'Your code has formatting issues. Run the following command to fix them:',
'',
'Run `task backend:format` to auto-fix, then commit and push the changes.',
'```bash',
'task backend:format',
'```',
'',
'Then commit and push the changes.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
@@ -115,61 +110,33 @@ jobs:
});
}
- name: Fail if backend format check failed
- name: Fail if Java formatting issues found
if: steps.spotless-check.outcome == 'failure'
run: |
echo "============================================"
echo " Backend Format Check Failed"
echo " Java Formatting Check Failed"
echo "============================================"
echo ""
echo "There are formatting issues in your Java code"
echo "that will need to be fixed before they can be"
echo "merged in."
echo "Your code has formatting issues."
echo "Run the following command to fix them:"
echo ""
echo "Run 'task backend:format' to auto-fix, then"
echo "commit and push the changes."
echo " task backend:format"
echo ""
echo "Then commit and push the changes."
echo "============================================"
exit 1
- name: Remove backend format check comment on success
if: steps.spotless-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- java-formatting-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Build with Gradle (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 +145,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 +156,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 +166,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'
-32
View File
@@ -68,18 +68,6 @@ jobs:
uses: ./.github/workflows/backend-build.yml
secrets: inherit
db-migration-test:
# Boots the current bootJar against H2 fixtures captured from past
# releases (v2.0.0 / v2.5.0 / v2.10.0) and verifies admin login still
# works after Hibernate's ddl-auto=update migrates the schema. Gated on
# the `project` filter so doc-only PRs skip this ~5-minute job.
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
permissions:
contents: read
uses: ./.github/workflows/db-migration-test.yml
secrets: inherit
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
@@ -185,24 +173,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`
@@ -214,7 +184,6 @@ jobs:
needs:
- files-changed
- build
- db-migration-test
- check-generateOpenApiDocs
- frontend-validation
- playwright-e2e
@@ -239,7 +208,6 @@ jobs:
RESULTS: |
files-changed=${{ needs.files-changed.result }}
build=${{ needs.build.result }}
db-migration-test=${{ needs.db-migration-test.result }}
check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }}
frontend-validation=${{ needs.frontend-validation.result }}
playwright-e2e=${{ needs.playwright-e2e.result }}
-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
-93
View File
@@ -1,93 +0,0 @@
name: DB migration smoke test
# Boots the current Stirling-PDF JAR against H2 fixtures captured from past
# releases (v2.0.0 / v2.5.0 / v2.10.0) and verifies admin login still works.
# Catches schema changes that would break existing user databases under
# Hibernate's `ddl-auto=update` upgrade path.
on:
workflow_call:
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
migration-test:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
timeout-minutes: 30
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: 25
distribution: temurin
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
# backend build's; reuse keeps cold-cache cost identical.
- name: Build Stirling-PDF JAR
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon
- name: Locate built JAR
id: jar
run: |
jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
| grep -vE '(-plain|-sources)\.jar$' | head -n 1)
if [[ -z "$jar" ]]; then
echo "::error::No JAR under app/core/build/libs"
ls -lah app/core/build/libs || true
exit 1
fi
# Absolute path - the migration script pushd's into a temp workdir
# before invoking java, which would dangle a relative path.
jar=$(realpath "$jar")
echo "path=$jar" >> "$GITHUB_OUTPUT"
echo "Built JAR: $jar"
- name: Run migration smoke test
env:
STIRLING_JAR: ${{ steps.jar.outputs.path }}
run: bash scripts/db-migration/run-migration-test.sh
- name: Upload app logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: db-migration-app-logs
# Path matches the preserved workdir in run-migration-test.sh -
# only failing fixtures leave a directory behind.
path: /tmp/stirling-migration-failed-*/app.log
retention-days: 7
if-no-files-found: warn
@@ -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:
+16 -167
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')
@@ -583,37 +586,21 @@ jobs:
if: always() && steps.digicert-setup.conclusion != 'failure'
shell: bash
run: |
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$DIST"
mkdir -p ./dist
cd ./frontend/editor/src-tauri/target
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
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
# 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" \;
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 "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
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" \;
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.rpm" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.rpm" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Upload build artifacts
@@ -624,7 +611,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 +623,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 +650,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
+10 -11
View File
@@ -157,9 +157,6 @@ jobs:
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
run: task desktop:prepare
- name: Run Tauri/Cargo tests
run: task desktop:test
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
@@ -271,6 +268,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: |
@@ -416,22 +417,20 @@ jobs:
- name: Rename artifacts
shell: bash
run: |
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$DIST"
mkdir -p ./dist
cd ./frontend/editor/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
# Only ship the MSI installer. The loose exe and WiX toolset exes
# are not the user-facing installer - the MSI contains the signed inner exe.
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
else
find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \;
find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \;
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.rpm" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.rpm" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
# Verify the MSI AND the inner exe extracted from it are signed.
+3 -11
View File
@@ -23,10 +23,6 @@ customFiles/
configs/
watchedFolders/
clientWebUI/
# Scratch dir used by local fixture-regeneration runs (see
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
# Holds downloaded JARs and disposable workdirs. Never committed.
.alpha-local/
!cucumber/
!cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip
@@ -57,8 +53,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
@@ -180,6 +174,7 @@ venv.bak/
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
.env*
!.env.saas.example
# VS Code
/.vscode/**/*
@@ -214,7 +209,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 +251,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/
@@ -279,6 +274,3 @@ docs/type3/signatures/
# Playwright MCP screenshots / traces
.playwright-mcp/
*.playwright-mcp.png
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/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
+5 -6
View File
@@ -22,13 +22,12 @@ tasks:
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -41,10 +40,10 @@ tasks:
platforms: [linux, darwin]
dev:saas:
desc: "Start backend in SaaS flavor against Supabase"
desc: "Start backend in SaaS flavor against Supabase (loads .env.saas.local)"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`.
dotenv: ['app/.env.saas.local', 'app/.env.saas']
# subtaskfile is included with `dir: .`. Drop the file at the repo root.
dotenv: ['.env.saas.local']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
+1 -29
View File
@@ -78,13 +78,6 @@ tasks:
cmds:
- npx tauri build --bundles appimage
test:
desc: "Run Tauri/Cargo tests"
deps: [prepare]
dir: editor/src-tauri
cmds:
- cargo test
clean:
desc: "Clean Tauri/Cargo build artifacts"
dir: editor
@@ -133,29 +126,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.
+1 -18
View File
@@ -58,23 +58,6 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev:saas
vars:
PORT: '{{.BACKEND_PORT}}'
- task: frontend:dev:saas
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
vars:
@@ -91,7 +74,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: frontend:dev
- task: frontend:dev:prototypes
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
-3
View File
@@ -1,3 +0,0 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
-4
View File
@@ -44,10 +44,6 @@
"moduleName": ".*",
"moduleLicense": "The MIT License"
},
{
"moduleName": ".*",
"moduleLicense": "MIT-0"
},
{
"moduleName": "com.github.jai-imageio:jai-imageio-core",
"moduleLicense": "LICENSE.txt"
+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) {}
}
@@ -77,10 +77,6 @@ public @interface AutoJobPostMapping {
/**
* Relative resource weight (1-100). See {@link
* stirling.software.common.enumeration.ResourceWeight} for the standard tiers.
*
* <p>The default is a sentinel ({@link Integer#MIN_VALUE}); {@code
* AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime
* readers clamp the value into {@code [1, 100]}.
*/
int resourceWeight() default Integer.MIN_VALUE;
int resourceWeight() default 1;
}
@@ -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 {
/**
@@ -80,7 +80,6 @@ public class ConfigInitializer {
YamlHelper settingsFile = new YamlHelper(settingTempPath);
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile);
boolean changesMade =
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
@@ -117,52 +116,31 @@ public class ConfigInitializer {
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin") != null) {
template.updateValue(
List.of("premium", "proFeatures", "ssoAutoLogin"),
List.of("premium", "proFeatures", "SSOAutoLogin"),
yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "autoUpdateMetadata")
!= null) {
template.updateValue(
List.of("premium", "proFeatures", "customMetadata", "autoUpdateMetadata"),
List.of("premium", "proFeatures", "CustomMetadata", "autoUpdateMetadata"),
yaml.getValueByExactKeyPath(
"enterpriseEdition", "CustomMetadata", "autoUpdateMetadata"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author") != null) {
template.updateValue(
List.of("premium", "proFeatures", "customMetadata", "author"),
List.of("premium", "proFeatures", "CustomMetadata", "author"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator") != null) {
template.updateValue(
List.of("premium", "proFeatures", "customMetadata", "creator"),
List.of("premium", "proFeatures", "CustomMetadata", "creator"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer")
!= null) {
template.updateValue(
List.of("premium", "proFeatures", "customMetadata", "producer"),
List.of("premium", "proFeatures", "CustomMetadata", "producer"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer"));
}
}
// TODO: Remove post migration
// settings.yml.template renamed the two non-camelCase proFeatures keys
// ("SSOAutoLogin" -> "ssoAutoLogin", "CustomMetadata" -> "customMetadata") so the whole
// settings pipeline is consistent camelCase. The save path (YamlHelper.updateValue) matches
// keys case-sensitively, so without this carry-forward an existing install's values written
// under the old PascalCase keys would be dropped on upgrade and reset to template defaults.
void migrateProFeaturesKeyCasing(YamlHelper yaml, YamlHelper template) {
Object ssoAutoLogin = yaml.getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin");
if (ssoAutoLogin != null) {
template.updateValue(List.of("premium", "proFeatures", "ssoAutoLogin"), ssoAutoLogin);
}
for (String field : List.of("autoUpdateMetadata", "author", "creator", "producer")) {
Object value =
yaml.getValueByExactKeyPath("premium", "proFeatures", "CustomMetadata", field);
if (value != null) {
template.updateValue(
List.of("premium", "proFeatures", "customMetadata", field), value);
}
}
}
}
@@ -528,16 +528,6 @@ public class ApplicationProperties {
private String provider;
private Client client = new Client();
/**
* When true, the OAuth2/OIDC login flow logs the full set of ID token and UserInfo
* claims at INFO level (and again at ERROR level if the username attribute cannot be
* resolved). Used to diagnose provider misconfiguration (for example ADFS not returning
* an {@code email} claim). WARNING: writes PII (sub, email, name) to application logs.
* Leave disabled in production; enable only while actively troubleshooting and disable
* again afterwards.
*/
private Boolean debugLogging = false;
public void setScopes(String scopes) {
List<String> scopesList =
Arrays.stream(scopes.split(",")).map(String::trim).toList();
@@ -788,7 +778,6 @@ public class ApplicationProperties {
private boolean enabled = false;
private String provider = "local";
private Local local = new Local();
private S3 s3 = new S3();
private Quotas quotas = new Quotas();
private Sharing sharing = new Sharing();
private Signing signing = new Signing();
@@ -798,57 +787,6 @@ public class ApplicationProperties {
private String basePath = InstallationPathConfig.getPath() + "storage";
}
@Data
public static class S3 {
/**
* Optional custom endpoint (e.g. {@code https://<account>.r2.cloudflarestorage.com},
* {@code https://<project>.supabase.co/storage/v1/s3}, or {@code http://localhost:9000}
* for MinIO). Blank = use AWS regional default.
*/
private String endpoint = "";
private String bucket = "";
private String region = "us-east-1";
private String accessKey = "";
private String secretKey = "";
/**
* When {@code true} use path-style URLs ({@code <endpoint>/<bucket>/<key>}) instead of
* virtual-hosted ({@code <bucket>.<endpoint>/<key>}). MinIO and most S3-compatible
* gateways require path-style; AWS S3 prefers virtual-hosted.
*/
private boolean pathStyleAccess = false;
/**
* When {@code false} (default), {@code endpoint} hostnames that resolve to private,
* loopback, or link-local addresses are rejected at startup to block SSRF attacks via
* the cloud metadata service (e.g. {@code http://169.254.169.254/}). Set to {@code
* true} to opt in for MinIO / in-cluster S3 endpoints on private networks.
*/
private boolean allowPrivateEndpoints = false;
/**
* Controls when the SDK adds an {@code x-amz-checksum-*} header on PUT/UploadPart.
* Default {@code WHEN_SUPPORTED} (the SDK default since 2.30) makes the SDK send a
* CRC32 checksum on every upload - this works on AWS S3, MinIO, current Supabase,
* Backblaze B2 (post-July-2025), and modern R2. Set to {@code WHEN_REQUIRED} to
* suppress the auto-checksum on vendors that reject unknown {@code x-amz-checksum-*}
* headers (older Backblaze B2, some R2 corner cases, GCS S3 endpoint). Invalid values
* fall back to {@code WHEN_SUPPORTED}.
*/
private String requestChecksumCalculation = "WHEN_SUPPORTED";
/**
* Controls when the SDK validates returned {@code x-amz-checksum-*} headers on GET
* responses. Default {@code WHEN_SUPPORTED}. Set to {@code WHEN_REQUIRED} if your
* vendor never returns these headers and you see false-positive checksum-mismatch
* errors. Invalid values fall back to {@code WHEN_SUPPORTED}.
*/
private String responseChecksumValidation = "WHEN_SUPPORTED";
}
@Data
public static class Sharing {
private boolean enabled = false;
@@ -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;
}
}
@@ -83,16 +83,7 @@ public class RequestUriUtils {
return false;
}
// Blocklist of backend/non-frontend paths that should still go through filters.
//
// `/files` was historically a backend route; it is now a frontend route
// owned by HomePage / FileManagerView. Direct-nav or refresh on /files
// (or /files/<folder-uuid>) was returning the Spring auth filter's 401
// JSON instead of serving index.html, so the SPA never got a chance to
// mount and the user saw a raw error response. There are no `/files`
// backend mappings at the servlet root - the real storage endpoints
// live under `/api/v1/storage/files`, which is filtered out a few lines
// up by the `startsWith("/api/")` guard.
// Blocklist of backend/non-frontend paths that should still go through filters
String[] backendOnlyPrefixes = {
"/register",
"/pipeline",
@@ -100,6 +91,7 @@ public class RequestUriUtils {
"/pdfjs-legacy",
"/fonts",
"/images",
"/files",
"/css",
"/js",
"/swagger",
@@ -189,7 +181,7 @@ public class RequestUriUtils {
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
// Workflow participant endpoints access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
// Share-link SPA bootstrap; data APIs remain protected
|| trimmedUri.matches("^/share/[^/]+/?$");
@@ -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));
@@ -1,95 +0,0 @@
package stirling.software.common.configuration;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.snakeyaml.engine.v2.api.LoadSettings;
import stirling.software.common.util.YamlHelper;
class ConfigInitializerTest {
private static final LoadSettings LOAD_SETTINGS =
LoadSettings.builder()
.setUseMarks(true)
.setMaxAliasesForCollections(Integer.MAX_VALUE)
.setAllowRecursiveKeys(true)
.setParseComments(true)
.build();
// Mirrors the proFeatures block of settings.yml.template after the camelCase rename.
private static final String CAMEL_CASE_TEMPLATE =
"""
premium:
proFeatures:
ssoAutoLogin: false
customMetadata:
autoUpdateMetadata: false
author: username
creator: Stirling-PDF
producer: Stirling-PDF
""";
@Test
void migrateProFeaturesKeyCasing_carriesForwardLegacyPascalCaseValues() {
// An existing install whose settings.yml still uses the old PascalCase keys.
String legacy =
"""
premium:
proFeatures:
SSOAutoLogin: true
CustomMetadata:
autoUpdateMetadata: true
author: alice
creator: bob
producer: carol
""";
YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE);
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template);
assertEquals(
"true", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
assertEquals(
"true",
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "autoUpdateMetadata"));
assertEquals(
"alice",
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "author"));
assertEquals(
"bob",
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "creator"));
assertEquals(
"carol",
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "producer"));
}
@Test
void migrateProFeaturesKeyCasing_withoutLegacyKeys_keepsTemplateDefaults() {
// No PascalCase keys present -> this migration step must be a no-op.
String alreadyCamel =
"""
premium:
proFeatures:
ssoAutoLogin: true
customMetadata:
author: dave
""";
YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE);
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, alreadyCamel);
new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template);
assertEquals(
"false", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
assertEquals(
"username",
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "author"));
}
}
@@ -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;
}
};
@@ -2,8 +2,6 @@ package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -14,47 +12,9 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import stirling.software.common.configuration.InstallationPathConfig;
public class GeneralUtilsTest {
// Regression guard for the SSO auto-login persistence bug: the admin UI writes camelCase
// proFeatures keys, so saveKeyToSettings must match (and persist) them against the camelCase
// settings.yml.template. A case mismatch makes YamlHelper.updateValue silently no-op.
@Test
void saveKeyToSettings_persistsCamelCaseProFeatureKeys(@TempDir Path tempDir) throws Exception {
Path settings = tempDir.resolve("settings.yml");
Files.writeString(
settings,
"""
premium:
proFeatures:
ssoAutoLogin: false
customMetadata:
author: username
""");
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true);
GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "alice");
}
YamlHelper reloaded = new YamlHelper(settings);
assertEquals(
"true", reloaded.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
assertEquals(
"alice",
reloaded.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "author"));
}
@Test
void testParsePageListWithAll() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"all"}, 5, false);
@@ -98,17 +98,6 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
}
@Test
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
// /files and /files/<folder-uuid> are FileManagerView routes - they
// must fall through to the SPA index.html, not get blocked by the
// backend auth filter. Regression test for direct-nav/refresh on
// the file manager returning a 401 JSON.
assertTrue(RequestUriUtils.isFrontendRoute("", "/files"));
assertTrue(
RequestUriUtils.isFrontendRoute("", "/files/3331910a-4155-4f71-8111-e38c896bc458"));
}
@Test
void testIsFrontendRoute_pathWithExtension() {
assertFalse(RequestUriUtils.isFrontendRoute("", "/some/file.pdf"));
@@ -194,7 +183,7 @@ class RequestUriUtilsTest {
@Test
void testIsPublicAuthEndpoint_shareRootNotPublic() {
// Avoid matching bare "/share" or "/share/" - must have a token segment
// Avoid matching bare "/share" or "/share/" must have a token segment
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/", ""));
}
@@ -208,7 +197,7 @@ class RequestUriUtilsTest {
@Test
void testIsPublicAuthEndpoint_shareApiStillProtected() {
// Share-link data APIs must NOT be public - they enforce auth + access checks
// Share-link data APIs must NOT be public they enforce auth + access checks
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/storage/share-links/abc123", ""));
assertFalse(
RequestUriUtils.isPublicAuthEndpoint(
@@ -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")
@@ -32,16 +32,13 @@ import stirling.software.SPDF.model.json.PdfJsonTextElement;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.general.EditTextOperation;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
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,13 +72,10 @@ public class EditTextController {
binder.registerCustomEditor(
List.class,
"edits",
new JsonListPropertyEditor<>(new TypeReference<List<EditTextOperation>>() {}));
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
}
@AutoJobPostMapping(
consumes = "multipart/form-data",
value = "/edit-text",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text")
@StandardPdfResponse
@Operation(
summary = "Edit text in a PDF via find and replace",
@@ -40,8 +40,7 @@ public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private static PDRectangle getTargetSize(
String targetPDRectangle, String orientation, PDDocument sourceDocument) {
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
if ("KEEP".equals(targetPDRectangle)) {
if (sourceDocument.getNumberOfPages() == 0) {
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
@@ -58,19 +57,18 @@ public class ScalePagesController {
}
Map<String, PDRectangle> sizeMap = getSizeMap();
PDRectangle base = sizeMap.get(targetPDRectangle);
if (base == null) {
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
if (sizeMap.containsKey(targetPDRectangle)) {
return sizeMap.get(targetPDRectangle);
}
if ("LANDSCAPE".equalsIgnoreCase(orientation)) {
return new PDRectangle(base.getHeight(), base.getWidth());
}
return base;
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
}
private static Map<String, PDRectangle> getSizeMap() {
Map<String, PDRectangle> sizeMap = new HashMap<>();
// Portrait sizes (A0-A6)
sizeMap.put("A0", PDRectangle.A0);
sizeMap.put("A1", PDRectangle.A1);
sizeMap.put("A2", PDRectangle.A2);
@@ -78,8 +76,42 @@ public class ScalePagesController {
sizeMap.put("A4", PDRectangle.A4);
sizeMap.put("A5", PDRectangle.A5);
sizeMap.put("A6", PDRectangle.A6);
// Landscape sizes (A0-A6)
sizeMap.put(
"A0_LANDSCAPE",
new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth()));
sizeMap.put(
"A1_LANDSCAPE",
new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth()));
sizeMap.put(
"A2_LANDSCAPE",
new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth()));
sizeMap.put(
"A3_LANDSCAPE",
new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
sizeMap.put(
"A4_LANDSCAPE",
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
sizeMap.put(
"A5_LANDSCAPE",
new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth()));
sizeMap.put(
"A6_LANDSCAPE",
new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth()));
// Portrait US sizes
sizeMap.put("LETTER", PDRectangle.LETTER);
sizeMap.put("LEGAL", PDRectangle.LEGAL);
// Landscape US sizes
sizeMap.put(
"LETTER_LANDSCAPE",
new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth()));
sizeMap.put(
"LEGAL_LANDSCAPE",
new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth()));
return sizeMap;
}
@@ -96,14 +128,13 @@ public class ScalePagesController {
throws IOException {
MultipartFile file = request.getFileInput();
String targetPDRectangle = request.getPageSize();
String orientation = request.getOrientation();
float scaleFactor = request.getScaleFactor();
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
PDRectangle targetSize = getTargetSize(targetPDRectangle, orientation, sourceDocument);
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
// Create LayerUtility once outside the loop for better performance
LayerUtility layerUtility = new LayerUtility(outputDocument);
@@ -275,10 +275,7 @@ public class ConvertImgPDFController {
GeneralUtils.generateFilename(file[0].getOriginalFilename(), "_converted.pdf"));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/cbz/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf")
@Operation(
summary = "Convert CBZ comic book archive to PDF",
description =
@@ -304,10 +301,7 @@ public class ConvertImgPDFController {
return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/cbz",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz")
@Operation(
summary = "Convert PDF to CBZ comic book archive",
description =
@@ -330,10 +324,7 @@ public class ConvertImgPDFController {
return WebResponseUtils.zipFileToWebResponse(cbzFile, filename);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/cbr/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
@Operation(
summary = "Convert CBR comic book archive to PDF",
description =
@@ -359,10 +350,7 @@ public class ConvertImgPDFController {
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/cbr",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr")
@Operation(
summary = "Convert PDF to CBR comic book archive",
description =
@@ -141,8 +141,7 @@ public class AttachmentController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-attachments",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/extract-attachments")
@Operation(
summary = "Extract attachments from PDF",
description =
@@ -177,10 +176,7 @@ public class AttachmentController {
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/list-attachments",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
@Operation(
summary = "List attachments in PDF",
description =
@@ -197,8 +193,7 @@ public class AttachmentController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rename-attachment",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/rename-attachment")
@StandardPdfResponse
@Operation(
summary = "Rename attachment in PDF",
@@ -233,8 +228,7 @@ public class AttachmentController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/delete-attachment",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/delete-attachment")
@StandardPdfResponse
@Operation(
summary = "Delete attachment from PDF",
@@ -12,8 +12,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
@@ -93,63 +91,6 @@ public class ConfigController {
return null;
}
/**
* Resolve the frontend URL the client should advertise to phones / share-link recipients.
* Priority: explicit system.frontendUrl, then the Host the user is already using to reach this
* server (works for Docker, reverse proxies, and bare-metal LANs), then a detected site-local
* IPv4, then empty.
*/
// visible for testing
String resolveFrontendUrl(HttpServletRequest request, AppConfig appConfig) {
String configured = applicationProperties.getSystem().getFrontendUrl();
if (configured != null && !configured.isBlank()) {
return configured;
}
if (request != null) {
String host = request.getServerName();
if (host != null && !host.isBlank() && !isLoopbackHost(host)) {
String scheme = request.getScheme();
int port = request.getServerPort();
boolean defaultPort =
("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
}
}
String localIp = GeneralUtils.getLocalNetworkIp();
if (localIp != null) {
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
}
return "";
}
/**
* The port the embedded server is actually listening on. With {@code server.port=0} (an
* ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value
* stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port}
* once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the
* real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT.
*/
// visible for testing
String resolveEffectiveServerPort(AppConfig appConfig) {
String configured = appConfig.getServerPort();
if (configured == null || "0".equals(configured.trim())) {
String actual = applicationContext.getEnvironment().getProperty("local.server.port");
if (actual != null && !actual.isBlank()) {
return actual;
}
}
return configured;
}
private static boolean isLoopbackHost(String host) {
return "localhost".equalsIgnoreCase(host)
|| "127.0.0.1".equals(host)
|| "::1".equals(host)
|| "0:0:0:0:0:0:0:1".equals(host);
}
/** Check if running Enterprise edition dynamically. */
private Boolean isRunningEE() {
// Use LicenseService for fresh license status if available
@@ -166,7 +107,7 @@ public class ConfigController {
}
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest request) {
public ResponseEntity<Map<String, Object>> getAppConfig() {
Map<String, Object> configData = new HashMap<>();
try {
@@ -180,10 +121,20 @@ 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));
if ((frontendUrl == null || frontendUrl.isBlank())
&& Boolean.parseBoolean(
System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
String localIp = GeneralUtils.getLocalNetworkIp();
if (localIp != null) {
String scheme =
appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
frontendUrl = scheme + "://" + localIp + ":" + appConfig.getServerPort();
}
}
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
// Add mobile scanner settings
configData.put(
@@ -326,9 +277,6 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// AI Engine settings
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
applicationProperties.getSecurity().getTimestamp();
@@ -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 PDFscreen 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());
}
}
@@ -170,19 +160,14 @@ public class ReactRoutingController {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml);
}
// `files` was historically a backend static-asset directory and was therefore
// in the exclusion list - removing it lets /files and /files/<folder-uuid>
// forward to the SPA index.html, which is what FileManagerView expects.
// (Real storage endpoints live under /api/v1/storage/files, already
// excluded by the leading `api` token in the same regex.)
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.NoHandlerFoundException;
import jakarta.servlet.http.HttpServletRequest;
@@ -197,12 +196,12 @@ public class GlobalExceptionHandler {
/**
* Checks whether the given IOException indicates that the client disconnected before the
* response could be written (broken pipe, connection reset, etc.). When this happens there is
* no point in serialising a {@link ProblemDetail} body because the socket is already closed -
* no point in serialising a {@link ProblemDetail} body because the socket is already closed
* and attempting to do so may trigger a secondary {@code HttpMessageNotWritableException} if
* the response Content-Type was already committed as a non-JSON type (e.g. image/png).
*/
private static boolean isClientDisconnectException(IOException ex) {
// Walk the causal chain - Jetty/Tomcat may wrap the low-level SocketException
// Walk the causal chain Jetty/Tomcat may wrap the low-level SocketException
Throwable current = ex;
while (current != null) {
String msg = current.getMessage();
@@ -1041,43 +1040,6 @@ public class GlobalExceptionHandler {
* @param request the HTTP servlet request
* @return ProblemDetail with appropriate HTTP status
*/
/**
* Handle ResponseStatusException explicitly so its embedded HTTP status reaches the client
* instead of being swallowed by the {@code RuntimeException} catch-all (which would downgrade
* every controller-thrown 400/404/409 to a generic 500). Folder/file storage controllers and
* any other code that throws {@code ResponseStatusException} relies on this handler taking
* precedence.
*/
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ProblemDetail> handleResponseStatusException(
ResponseStatusException ex, HttpServletRequest request) {
HttpStatus status =
HttpStatus.resolve(ex.getStatusCode().value()) != null
? HttpStatus.valueOf(ex.getStatusCode().value())
: HttpStatus.INTERNAL_SERVER_ERROR;
String reason = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase();
ProblemDetail problemDetail = createBaseProblemDetail(status, reason, request);
problemDetail.setType(URI.create("/errors/" + status.value()));
problemDetail.setTitle(status.getReasonPhrase());
problemDetail.setProperty("title", status.getReasonPhrase());
// 5xx is operator-relevant; 4xx is a normal client-rejection - log at the right level.
if (status.is5xxServerError()) {
log.error(
"ResponseStatusException {} at {}: {}",
status.value(),
request.getRequestURI(),
reason,
ex);
} else {
log.debug(
"ResponseStatusException {} at {}: {}",
status.value(),
request.getRequestURI(),
reason);
}
return ResponseEntity.status(status).contentType(PROBLEM_JSON).body(problemDetail);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ProblemDetail> handleRuntimeException(
RuntimeException ex, HttpServletRequest request) {
@@ -18,11 +18,4 @@ public class PDFWithPageSize extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL", "KEEP"})
private String pageSize;
@Schema(
description =
"Orientation to apply to the target page size. Ignored when pageSize is KEEP.",
defaultValue = "PORTRAIT",
allowableValues = {"PORTRAIT", "LANDSCAPE"})
private String orientation = "PORTRAIT";
}
@@ -13,7 +13,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.MarkdownConversionResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
@@ -24,10 +23,7 @@ public class ConvertPDFToMarkdown {
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/markdown",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/markdown")
@MarkdownConversionResponse
@Operation(
summary = "Convert PDF to Markdown",
@@ -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
@@ -20,7 +20,6 @@ security:
password: "" # initial password for the first login
oauth2:
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
debugLogging: false # set to 'true' to log full ID token and UserInfo claims during OAuth2/OIDC login. Use this to diagnose claim issues (e.g. "Attribute value for 'email' cannot be null" with ADFS). WARNING: writes PII (sub, email, name) to logs; disable after troubleshooting.
client:
keycloak:
issuer: "" # URL of the Keycloak realm's OpenID Connect Discovery endpoint
@@ -94,8 +93,8 @@ premium:
key: 00000000-0000-0000-0000-000000000000
enabled: false # Enable license key checks for pro/enterprise features
proFeatures:
ssoAutoLogin: false
customMetadata:
SSOAutoLogin: false
CustomMetadata:
autoUpdateMetadata: false
author: username
creator: Stirling-PDF
@@ -246,39 +245,6 @@ storage:
provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage
local:
basePath: './storage' # base directory for stored files
# ====================================================================================
# S3-COMPATIBLE OBJECT STORAGE - PRO / ENTERPRISE LICENSE REQUIRED
# storage.provider=s3, storage.provider=database, and cluster.artifactStore=s3 all
# require a valid Pro or Enterprise license.
# ====================================================================================
# Used when provider=s3 (persistent user uploads) and/or cluster.artifactStore=s3
# (transient cluster artifacts). The two consumers share this block.
# Vendor cheat sheet (set the highlighted flags to taste):
# AWS S3 -> endpoint='' region='<your-region>' pathStyleAccess=false
# Cloudflare R2 -> endpoint='https://<acct>.r2.cloudflarestorage.com' region='auto'
# pathStyleAccess=false; if uploads fail with 'unsupported header
# x-amz-checksum-*' set requestChecksumCalculation=WHEN_REQUIRED
# Supabase Storage -> endpoint='https://<project>.supabase.co/storage/v1/s3'
# region='<project-region>' pathStyleAccess=true
# (filenames with non-ASCII display fine - the storage key is opaque)
# MinIO (in-cluster) -> endpoint='http://minio:9000' region='us-east-1'
# pathStyleAccess=true allowPrivateEndpoints=true
# Backblaze B2 -> endpoint='https://s3.<region>.backblazeb2.com'
# If on a B2 deployment older than July-2025 and uploads return
# 'Unsupported header x-amz-checksum-crc32', set
# requestChecksumCalculation=WHEN_REQUIRED
# DigitalOcean Spaces -> endpoint='https://<region>.digitaloceanspaces.com'
# Note: 5GB per-object cap (regardless of multipart)
s3:
endpoint: "" # blank = use AWS regional default; otherwise full URL incl. https://
bucket: "" # required when provider=s3 or cluster.artifactStore=s3
region: us-east-1
accessKey: "" # blank = fall back to AWS DefaultCredentialsProvider (env / profile / IMDS)
secretKey: ""
pathStyleAccess: false # true for MinIO and Supabase; false for AWS/R2/most CDNs
allowPrivateEndpoints: false # true required when endpoint resolves to a private/loopback IP (e.g. in-cluster MinIO). SSRF guard - leave false for any internet-facing vendor.
requestChecksumCalculation: WHEN_SUPPORTED # WHEN_SUPPORTED|WHEN_REQUIRED|DISABLED. Set WHEN_REQUIRED if your vendor rejects auto-added x-amz-checksum-* headers (older Backblaze B2, some R2 corner cases).
responseChecksumValidation: WHEN_SUPPORTED # WHEN_SUPPORTED|WHEN_REQUIRED|DISABLED. Set WHEN_REQUIRED if you see false-positive checksum-mismatch errors on GET from a vendor that never returns checksum headers.
quotas:
maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap
maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap
@@ -369,8 +335,6 @@ cluster:
enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here.
backplane: inprocess # Backplane implementation: 'inprocess' (single JVM only) or 'valkey' (multi-node via Valkey/Redis)
artifactStore: local # Transient cluster job-artifact backend: 'local' (per-node disk; single-node only) or 's3' (shared object store; required for multi-node). Distinct from 'storage.provider' which controls persistent user uploads - when both are 's3' they share the storage.s3.* credentials block. Multi-node deployments MUST set this to 's3'.
s3:
keyPrefix: transient/ # Bucket key prefix used by the cluster artifact store when artifactStore=s3. Trailing slash recommended. Lets a single bucket host both persistent uploads (storage.s3.*) and transient job artifacts under separate prefixes.
valkey:
url: "" # Valkey/Redis URL, e.g. 'redis://valkey:6379' or 'rediss://...' for TLS. Required when enabled=true and backplane=valkey.
tls:
@@ -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",
@@ -1,132 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
import org.springframework.core.type.filter.TypeFilter;
import stirling.software.common.annotations.AutoJobPostMapping;
/**
* Build-time guardrail: every {@link AutoJobPostMapping} method must declare an explicit {@code
* resourceWeight}.
*
* <p>The credits interceptor multiplies {@code resourceWeight} into the per-call charge. An
* endpoint that falls through to the annotation default produces a charge derived from a value
* nobody chose silently under- or over-billing depending on the endpoint's true cost. Forcing
* each method to pick a value from {@link stirling.software.common.enumeration.ResourceWeight}
* keeps the choice deliberate.
*
* <p>The annotation's default is {@link Integer#MIN_VALUE} (a sentinel). Runtime readers clamp the
* value into {@code [1, 100]}, so a missed declaration can't crash production this test is the
* contract, the clamp is the safety net.
*
* <p>Lives in {@code :stirling-pdf} (core) because that's the module whose compile classpath
* transitively sees every other module's controllers ({@code :common}, {@code :proprietary}, and
* {@code :saas} when enabled).
*/
class AutoJobPostMappingWeightTest {
private static final String SCAN_BASE_PACKAGE = "stirling.software";
@Test
void everyAutoJobPostMappingDeclaresExplicitResourceWeight() throws Exception {
List<String> offenders = findOffendingMethods();
assertTrue(
offenders.isEmpty(),
() ->
"The following @AutoJobPostMapping methods do not declare an explicit"
+ " resourceWeight. Pick a value from"
+ " stirling.software.common.enumeration.ResourceWeight (SMALL,"
+ " MEDIUM, LARGE, XLARGE) and add it to the annotation:\n - "
+ String.join("\n - ", offenders));
}
private List<String> findOffendingMethods() throws IOException, ClassNotFoundException {
List<String> offenders = new ArrayList<>();
for (Class<?> candidate : scanForCandidateClasses()) {
for (Method method : candidate.getDeclaredMethods()) {
AutoJobPostMapping annotation = method.getAnnotation(AutoJobPostMapping.class);
if (annotation == null) {
continue;
}
if (annotation.resourceWeight() == Integer.MIN_VALUE) {
offenders.add(candidate.getName() + "#" + method.getName());
}
}
}
return offenders;
}
/**
* Returns every class under {@link #SCAN_BASE_PACKAGE} that has an @AutoJobPostMapping method.
*/
private List<Class<?>> scanForCandidateClasses() throws IOException, ClassNotFoundException {
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
String pattern = "classpath*:" + SCAN_BASE_PACKAGE.replace('.', '/') + "/**/*.class";
Resource[] resources = resolver.getResources(pattern);
// Pre-filter by reading annotation metadata from the class file so we don't have to load
// every class on the test classpath just to find the few that are annotated.
TypeFilter mentionsAutoJobPostMapping =
(reader, factory) ->
reader.getAnnotationMetadata()
.getAnnotatedMethods(AutoJobPostMapping.class.getName())
.size()
> 0;
List<Class<?>> matches = new ArrayList<>();
for (Resource resource : resources) {
if (!resource.isReadable()) {
continue;
}
MetadataReader reader = metadataReaderFactory.getMetadataReader(resource);
if (!mentionsAutoJobPostMapping.match(reader, metadataReaderFactory)) {
continue;
}
matches.add(Class.forName(reader.getClassMetadata().getClassName()));
}
return matches;
}
/**
* Sanity check that the classpath scan returns non-empty; otherwise the main test passes
* vacuously.
*/
@Test
void scannerFindsAtLeastOneAutoJobPostMapping() throws Exception {
long count =
scanForCandidateClasses().stream()
.flatMap(c -> java.util.Arrays.stream(c.getDeclaredMethods()))
.filter(m -> m.isAnnotationPresent(AutoJobPostMapping.class))
.count();
assertTrue(
count > 10,
() ->
"Expected the classpath scan to find many @AutoJobPostMapping methods but"
+ " found only "
+ count
+ ". Scanner regression?");
}
@SuppressWarnings("unused")
private static String describeCandidates(List<Class<?>> candidates) {
return candidates.stream().map(Class::getName).collect(Collectors.joining(", "));
}
}
@@ -237,8 +237,7 @@ class ScalePagesControllerTest {
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A4");
request.setOrientation("LANDSCAPE");
request.setPageSize("A4_LANDSCAPE");
request.setScaleFactor(1.0f);
setupFactory();
@@ -15,14 +15,10 @@ import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.System;
import stirling.software.common.service.LicenseServiceInterface;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@@ -177,119 +173,4 @@ class ConfigControllerTest {
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(endpointConfiguration).getAllEndpoints();
}
@Test
void resolveFrontendUrl_prefersExplicitConfiguredValue() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn("https://pdf.example.com");
// Request would say something else, but configured wins.
HttpServletRequest req = mock(HttpServletRequest.class);
AppConfig appConfig = mock(AppConfig.class);
assertEquals(
"https://pdf.example.com", configController.resolveFrontendUrl(req, appConfig));
}
@Test
void resolveFrontendUrl_usesRequestHostWhenNotConfigured() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn(null);
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("192.168.1.100");
when(req.getScheme()).thenReturn("http");
when(req.getServerPort()).thenReturn(8080);
assertEquals(
"http://192.168.1.100:8080",
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
}
@Test
void resolveFrontendUrl_elidesDefaultHttpsPort() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn("");
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("pdf.example.com");
when(req.getScheme()).thenReturn("https");
when(req.getServerPort()).thenReturn(443);
assertEquals(
"https://pdf.example.com",
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
}
@Test
void resolveFrontendUrl_fallsThroughOnLoopbackHost() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn(null);
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("localhost");
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080");
when(appConfig.getServerPort()).thenReturn("8080");
// Detected IP (if any) wins over loopback request host. We can't assert the
// exact value (depends on the host running the test) but we can assert it
// never returns "localhost".
String result = configController.resolveFrontendUrl(req, appConfig);
assertNotNull(result);
assertFalse(result.contains("localhost"));
}
@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,93 +0,0 @@
package stirling.software.common.configuration;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mockStatic;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.YamlHelper;
/**
* End-to-end check of the container-restart path. {@link ConfigInitializer#ensureConfigExists()} is
* what runs on every startup, merging the on-disk settings.yml with the bundled
* settings.yml.template. These tests exercise it against the real template on the classpath to
* prove admin-saved proFeatures values survive a restart - the bug behind "the SSO auto-login
* button resets every time the container resets".
*/
class ConfigInitializerRestartTest {
private static String read(Path settings, String... keyPath) throws IOException {
return String.valueOf(new YamlHelper(settings).getValueByExactKeyPath(keyPath));
}
@Test
void ssoAutoLoginAndCustomMetadata_persistAcrossRestart(@TempDir Path tmp) throws Exception {
Path settings = tmp.resolve("settings.yml");
Path custom = tmp.resolve("custom_settings.yml");
try (MockedStatic<InstallationPathConfig> paths =
mockStatic(InstallationPathConfig.class)) {
paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString());
ConfigInitializer init = new ConfigInitializer();
// First boot: settings.yml created from the bundled template (camelCase, default off).
init.ensureConfigExists();
assertEquals("false", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
// Admin enables SSO auto-login and edits custom metadata via the exact save path the
// admin settings controller uses.
GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true);
GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "acme");
// Container restart: ensureConfigExists merges the saved file with the template again.
init.ensureConfigExists();
assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
assertEquals(
"acme", read(settings, "premium", "proFeatures", "customMetadata", "author"));
}
}
@Test
void legacyPascalCaseConfig_isMigratedAndPreservedOnRestart(@TempDir Path tmp)
throws Exception {
Path settings = tmp.resolve("settings.yml");
Path custom = tmp.resolve("custom_settings.yml");
try (MockedStatic<InstallationPathConfig> paths =
mockStatic(InstallationPathConfig.class)) {
paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString());
ConfigInitializer init = new ConfigInitializer();
// Seed a full settings.yml as an OLD install would have written it: PascalCase keys
// with
// SSO auto-login enabled.
init.ensureConfigExists();
String legacy =
Files.readString(settings)
.replace("ssoAutoLogin: false", "SSOAutoLogin: true")
.replace("customMetadata:", "CustomMetadata:");
Files.writeString(settings, legacy);
// Upgrade restart.
init.ensureConfigExists();
// Value carried forward onto the new camelCase key; the legacy PascalCase key is gone.
assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
assertNull(
new YamlHelper(settings)
.getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin"));
}
}
}
@@ -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
View File
@@ -123,8 +123,6 @@ SwaggerDoc.json
*.tar.gz
*.rar
*.db
# Whitelist the H2 fixtures that feed the version-migration CI smoke test.
!src/test/resources/db-migration-fixtures/*.mv.db
/build
/app/proprietary/build/
+1 -18
View File
@@ -5,8 +5,6 @@ repositories {
ext {
jwtVersion = '0.13.0'
awsSdkVersion = '2.44.12'
testcontainersMinioVersion = '1.21.4'
}
bootRun {
@@ -55,13 +53,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"
@@ -78,16 +71,6 @@ dependencies {
implementation('com.coveo:saml-client:5.0.0') {
exclude group: 'org.opensaml', module: 'opensaml-core'
}
implementation "software.amazon.awssdk:s3:$awsSdkVersion"
implementation "software.amazon.awssdk:url-connection-client:$awsSdkVersion"
// 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"
}
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,200 +0,0 @@
package stirling.software.proprietary.cluster.s3;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3ClientBuilder;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
/**
* Shared factory for {@link S3Client} and {@link S3Presigner} instances used by both {@code
* S3StorageProvider} and {@code S3FileStore}, so endpoint/region/credentials wiring lives in
* exactly one place.
*/
@Slf4j
public final class S3Clients {
private S3Clients() {}
/** Paired client and presigner with coordinated lifecycle. */
public record Bundle(S3Client client, S3Presigner presigner) implements AutoCloseable {
@Override
public void close() {
try {
presigner.close();
} catch (Exception e) {
log.warn("Error closing S3 presigner", e);
}
try {
client.close();
} catch (Exception e) {
log.warn("Error closing S3 client", e);
}
}
}
/** Build a client+presigner pair from the shared S3 config block. */
public static Bundle build(ApplicationProperties.Storage.S3 cfg, String usage) {
if (cfg == null) {
throw new IllegalStateException(
usage + " requires storage.s3.* configuration to be set");
}
if (cfg.getBucket() == null || cfg.getBucket().isBlank()) {
throw new IllegalStateException(usage + " requires storage.s3.bucket to be set");
}
String region =
cfg.getRegion() == null || cfg.getRegion().isBlank()
? "us-east-1"
: cfg.getRegion();
S3Configuration s3Configuration =
S3Configuration.builder().pathStyleAccessEnabled(cfg.isPathStyleAccess()).build();
RequestChecksumCalculation requestChecksum =
parseRequestChecksum(cfg.getRequestChecksumCalculation());
ResponseChecksumValidation responseChecksum =
parseResponseChecksum(cfg.getResponseChecksumValidation());
S3ClientBuilder clientBuilder =
S3Client.builder()
.httpClient(UrlConnectionHttpClient.create())
.region(Region.of(region))
.serviceConfiguration(s3Configuration)
.requestChecksumCalculation(requestChecksum)
.responseChecksumValidation(responseChecksum);
S3Presigner.Builder presignerBuilder =
S3Presigner.builder()
.region(Region.of(region))
.serviceConfiguration(s3Configuration);
if (cfg.getEndpoint() != null && !cfg.getEndpoint().isBlank()) {
URI endpoint;
try {
endpoint = new URI(cfg.getEndpoint());
} catch (URISyntaxException e) {
throw new IllegalStateException(
"Invalid storage.s3.endpoint: " + cfg.getEndpoint(), e);
}
validateEndpointHost(endpoint, cfg.isAllowPrivateEndpoints());
clientBuilder.endpointOverride(endpoint);
presignerBuilder.endpointOverride(endpoint);
}
boolean hasStaticCreds =
cfg.getAccessKey() != null
&& !cfg.getAccessKey().isBlank()
&& cfg.getSecretKey() != null
&& !cfg.getSecretKey().isBlank();
if (hasStaticCreds) {
AwsBasicCredentials credentials =
AwsBasicCredentials.create(cfg.getAccessKey(), cfg.getSecretKey());
StaticCredentialsProvider provider = StaticCredentialsProvider.create(credentials);
clientBuilder.credentialsProvider(provider);
presignerBuilder.credentialsProvider(provider);
} else {
clientBuilder.credentialsProvider(DefaultCredentialsProvider.create());
presignerBuilder.credentialsProvider(DefaultCredentialsProvider.create());
}
log.debug(
"Configured S3 {}: bucket={}, region={}, endpoint={}, pathStyle={}",
usage,
cfg.getBucket(),
region,
cfg.getEndpoint() == null || cfg.getEndpoint().isBlank()
? "<aws-default>"
: cfg.getEndpoint(),
cfg.isPathStyleAccess());
return new Bundle(clientBuilder.build(), presignerBuilder.build());
}
/**
* Block SSRF via the S3 endpoint setting. An admin who can edit config could otherwise point
* the SDK at the cloud metadata service (e.g. {@code http://169.254.169.254/}) and exfiltrate
* instance-role credentials. Reject any endpoint whose host resolves to a loopback, link-local,
* or RFC1918 private address unless the operator has explicitly opted in via {@code
* storage.s3.allow-private-endpoints=true}.
*/
static void validateEndpointHost(URI endpoint, boolean allowPrivate) {
if (allowPrivate) {
return;
}
String host = endpoint.getHost();
if (host == null || host.isBlank()) {
throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint);
}
InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new IllegalStateException(
"Unable to resolve storage.s3.endpoint host '" + host + "'", e);
}
for (InetAddress address : addresses) {
if (isPrivateOrLocal(address)) {
throw new IllegalStateException(
"storage.s3.endpoint host '"
+ host
+ "' resolves to private/link-local address "
+ address.getHostAddress()
+ "; set storage.s3.allow-private-endpoints=true to opt in"
+ " (e.g. for MinIO or in-cluster S3).");
}
}
}
private static boolean isPrivateOrLocal(InetAddress address) {
return address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isAnyLocalAddress()
|| address.isMulticastAddress();
}
static RequestChecksumCalculation parseRequestChecksum(String value) {
if (value == null || value.isBlank()) {
return RequestChecksumCalculation.WHEN_SUPPORTED;
}
try {
return RequestChecksumCalculation.valueOf(
value.trim().toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException ex) {
log.warn(
"Unknown storage.s3.request-checksum-calculation value '{}', falling back to WHEN_SUPPORTED",
value);
return RequestChecksumCalculation.WHEN_SUPPORTED;
}
}
static ResponseChecksumValidation parseResponseChecksum(String value) {
if (value == null || value.isBlank()) {
return ResponseChecksumValidation.WHEN_SUPPORTED;
}
try {
return ResponseChecksumValidation.valueOf(
value.trim().toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException ex) {
log.warn(
"Unknown storage.s3.response-checksum-validation value '{}', falling back to WHEN_SUPPORTED",
value);
return ResponseChecksumValidation.WHEN_SUPPORTED;
}
}
}
@@ -1,226 +0,0 @@
package stirling.software.proprietary.cluster.s3;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Optional;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
/**
* S3-backed {@link FileStore} for transient job-result files. Objects are namespaced under a
* configurable key prefix (default {@code transient/}) and can coexist in the same bucket as {@code
* S3StorageProvider}.
*/
@Slf4j
public class S3FileStore implements FileStore, AutoCloseable {
public static final String DEFAULT_KEY_PREFIX = "transient/";
private final S3Client s3Client;
private final String bucket;
private final String keyPrefix;
private final boolean ownsClient;
public S3FileStore(S3Client s3Client, String bucket) {
this(s3Client, bucket, DEFAULT_KEY_PREFIX, true);
}
public S3FileStore(S3Client s3Client, String bucket, String keyPrefix) {
this(s3Client, bucket, keyPrefix, true);
}
/**
* @param ownsClient when true, {@link #close()} will close the supplied client. Set to false in
* tests that share the client with another consumer.
*/
public S3FileStore(S3Client s3Client, String bucket, String keyPrefix, boolean ownsClient) {
if (bucket == null || bucket.isBlank()) {
throw new IllegalArgumentException("S3 bucket must be configured");
}
this.s3Client = s3Client;
this.bucket = bucket;
this.keyPrefix = normalizePrefix(keyPrefix);
this.ownsClient = ownsClient;
}
@Override
public Stored store(InputStream in, String originalName) throws IOException {
String fileId = UUID.randomUUID().toString();
// S3 PUT requires a known content-length; spool to a temp file first so memory stays
// bounded for large payloads, then stream the file to S3 via RequestBody.fromFile.
Path tempFile = Files.createTempFile("s3-upload-", ".bin");
long size;
try {
try (InputStream src = in) {
Files.copy(src, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
size = Files.size(tempFile);
PutObjectRequest request =
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
try {
s3Client.putObject(request, RequestBody.fromFile(tempFile));
} catch (SdkException e) {
throw new IOException("Failed to upload object to S3", e);
}
} finally {
try {
Files.deleteIfExists(tempFile);
} catch (IOException cleanupError) {
log.warn("Failed to delete S3 upload temp file: {}", tempFile, cleanupError);
}
}
return new Stored(fileId, size);
}
@Override
public InputStream retrieve(String fileId) throws IOException {
validateFileId(fileId);
GetObjectRequest request =
GetObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
try {
ResponseInputStream<GetObjectResponse> stream = s3Client.getObject(request);
return new BufferedInputStream(stream);
} catch (NoSuchKeyException e) {
throw new IOException("File not found with ID: " + fileId, e);
} catch (SdkException e) {
throw new IOException("Failed to load object from S3", e);
}
}
@Override
public byte[] retrieveBytes(String fileId) throws IOException {
validateFileId(fileId);
GetObjectRequest request =
GetObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
try (ResponseInputStream<GetObjectResponse> stream = s3Client.getObject(request)) {
return stream.readAllBytes();
} catch (NoSuchKeyException e) {
throw new IOException("File not found with ID: " + fileId, e);
} catch (SdkException e) {
throw new IOException("Failed to load object from S3", e);
}
}
@Override
public long size(String fileId) throws IOException {
validateFileId(fileId);
HeadObjectRequest request =
HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
try {
HeadObjectResponse response = s3Client.headObject(request);
return Optional.ofNullable(response.contentLength()).orElse(0L);
} catch (NoSuchKeyException e) {
throw new IOException("File not found with ID: " + fileId, e);
} catch (S3Exception e) {
if (e.statusCode() == 404) {
throw new IOException("File not found with ID: " + fileId, e);
}
throw new IOException("Failed to head object in S3", e);
} catch (SdkException e) {
throw new IOException("Failed to head object in S3", e);
}
}
@Override
public boolean delete(String fileId) {
try {
validateFileId(fileId);
} catch (IllegalArgumentException e) {
log.warn("Refusing to delete invalid file id: {}", fileId);
return false;
}
try {
s3Client.deleteObject(
DeleteObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build());
return true;
} catch (SdkException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
@Override
public boolean exists(String fileId) {
try {
validateFileId(fileId);
} catch (IllegalArgumentException e) {
return false;
}
HeadObjectRequest request =
HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
try {
s3Client.headObject(request);
return true;
} catch (NoSuchKeyException e) {
return false;
} catch (S3Exception e) {
if (e.statusCode() == 404) {
return false;
}
log.warn("Error checking existence for file ID: {}", fileId, e);
return false;
} catch (SdkException e) {
log.warn("Error checking existence for file ID: {}", fileId, e);
return false;
}
}
@Override
public void close() {
if (!ownsClient) {
return;
}
try {
s3Client.close();
} catch (Exception e) {
log.warn("Error closing S3 client", e);
}
}
String resolveKey(String fileId) {
return keyPrefix + fileId;
}
private static void validateFileId(String fileId) {
if (fileId == null || fileId.isBlank()) {
throw new IllegalArgumentException("File ID must not be blank");
}
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
}
private static String normalizePrefix(String prefix) {
if (prefix == null || prefix.isBlank()) {
return "";
}
String trimmed = prefix.trim();
if (trimmed.startsWith("/")) {
trimmed = trimmed.substring(1);
}
if (!trimmed.isEmpty() && !trimmed.endsWith("/")) {
trimmed = trimmed + "/";
}
return trimmed;
}
}
@@ -1,37 +0,0 @@
package stirling.software.proprietary.cluster.s3;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
import stirling.software.common.model.ApplicationProperties;
/** Activates the S3-backed transient {@link FileStore} when {@code cluster.artifactStore=s3}. */
@Slf4j
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "cluster", name = "artifactStore", havingValue = "s3")
public class S3FileStoreConfiguration {
private final ApplicationProperties applicationProperties;
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public FileStore fileStore(@Value("${cluster.s3.keyPrefix:transient/}") String keyPrefix) {
ApplicationProperties.Storage.S3 cfg = applicationProperties.getStorage().getS3();
S3Clients.Bundle bundle = S3Clients.build(cfg, "cluster file store");
// FileStore has no signed-URL contract; close the unused presigner immediately.
try {
bundle.presigner().close();
} catch (Exception ignored) {
}
log.info("Cluster FileStore: s3 (bucket={}, keyPrefix={})", cfg.getBucket(), keyPrefix);
return new S3FileStore(bundle.client(), cfg.getBucket(), keyPrefix, true);
}
}
@@ -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 filejob 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,15 +6,17 @@ 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;
@Data
@Schema(description = "Run an AI workflow")
@Schema(description = "Run an AI workflow against one or more PDF files")
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")

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