Merge remote-tracking branch 'origin/main' into kill-policies-page

# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java
#	app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java
#	frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
#	frontend/editor/src/portal/views/PipelineBuilder.tsx
#	frontend/editor/src/portal/views/Pipelines.test.tsx
#	frontend/editor/src/portal/views/Pipelines.tsx
#	frontend/editor/src/portal/views/Policies.tsx
This commit is contained in:
James Brunton
2026-09-01 15:32:55 +01:00
273 changed files with 6743 additions and 3144 deletions
+48 -2
View File
@@ -182,7 +182,7 @@ jobs:
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Get version number
id: versionNumber
@@ -220,6 +220,42 @@ jobs:
echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT
fi
# The Stirling account previews connect to. Derived from the ref rather than stored as a URL
# so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected
# by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in.
# Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not
# itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable.
- name: Resolve Stirling account config
id: saas
env:
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }}
run: |
# Set, this is the one value both halves use: the browser's portal reads and the backend's
# register/entitlement calls have to land on the same SaaS, and nothing checks that they
# do. Unset, only the backend gets a base, from its own compiled-in default.
API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}"
echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT"
if [ -z "${PROJECT_REF}" ]; then
echo "Not configured for this environment: the preview will build without a Stirling"
echo "account, and the connect dialog will say so. To wire one up, set on the"
echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and"
echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project."
echo "supabase_url=" >> "$GITHUB_OUTPUT"
echo "frontend_base=" >> "$GITHUB_OUTPUT"
else
# Only whether, not which: the ref is a secret here, so Actions masks it out of any
# line it appears in, derived URL included.
echo "Stirling account configured, at ${API_BASE}."
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
# Deliberately the override and not API_BASE: the backend's default is a subpath URL
# nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames,
# so portal reads stay off until someone sets a base they have checked. Empty leaves the
# committed .env default alone, which is the clean "not configured" state.
echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT"
fi
- name: Check if image exists
id: check-image
run: |
@@ -246,6 +282,9 @@ jobs:
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }}
platforms: linux/amd64
- name: Set up SSH
@@ -279,6 +318,13 @@ jobs:
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}"
# Off so preview traffic never accrues against a real wallet or trips its cap. The
# 402 gate is separate and stays on, so gating is still testable here.
STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false"
# Stated rather than inferred from the request: the callback has to come back to the
# preview hostname, not to the container's own :8080 behind this proxy.
SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}"
SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}"
@@ -353,7 +399,7 @@ jobs:
- name: Install Task for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build and deploy Storybook
id: storybook
@@ -206,7 +206,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Run Gradle Command
run: |
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
@@ -222,7 +222,7 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -45,7 +45,7 @@ jobs:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+1 -2
View File
@@ -34,10 +34,9 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: ai-engine
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Quality-check engine
id: engine-check
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.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
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (needed for playwright's vite preview webServer)
+1 -2
View File
@@ -42,7 +42,6 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: generated-models
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
@@ -76,7 +75,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Verify generated models are up to date
id: models-check
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Check licenses for compatibility
run: task backend:licenses:check
env:
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Generate OpenAPI documentation
run: task backend:swagger
env:
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (production bundle for vite preview)
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: a11y gate (changed stories)
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
- name: Upload scan reports
@@ -97,7 +97,7 @@ jobs:
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
@@ -367,7 +367,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Check licenses and generate report
id: license-check
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Quality-check frontend
id: frontend-check
run: task frontend:check:all
+3 -3
View File
@@ -69,7 +69,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Get version number
id: versionNumber
run: |
@@ -169,7 +169,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
@@ -268,7 +268,7 @@ jobs:
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
+3 -3
View File
@@ -38,7 +38,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Install all Playwright browsers
run: task e2e:install
@@ -89,7 +89,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: a11y gate (every story, ${{ matrix.theme }})
run: task frontend:storybook:a11y:${{ matrix.theme }}
@@ -162,7 +162,7 @@ jobs:
engine/uv.lock
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Start the fat image with login and storage enabled
run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build
+1 -2
View File
@@ -31,10 +31,9 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: pre-commit
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Run pre-commit checks
run: task pre-commit
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
+2 -2
View File
@@ -85,10 +85,10 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: results.sarif
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
SWAGGERHUB_USER: "Frooodle"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+1 -2
View File
@@ -59,14 +59,13 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: sync-files
- name: Install Python dependencies
run: |
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Sync translation TOML files
run: |
+1 -1
View File
@@ -212,7 +212,7 @@ jobs:
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
+3 -3
View File
@@ -127,7 +127,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build application
run: task backend:build
env:
@@ -142,7 +142,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Set base image and platform for this build
id: build-params
@@ -229,7 +229,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build docker/unoserver/Dockerfile
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -48,7 +48,7 @@ public class EndpointConfiguration {
private final ApplicationProperties applicationProperties;
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>();
private Set<String> disabledGroups = ConcurrentHashMap.newKeySet();
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
@@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser {
score -= 0.3f;
}
return Math.max(0f, Math.min(1f, score));
return Math.clamp(score, 0f, 1f);
}
private Bounds tableBounds(Table table) {
@@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport {
@Override
public void setAsText(String text) throws IllegalArgumentException {
try {
TypeReference<HashMap<String, String>> typeRef = new TypeReference<>() {};
TypeReference<HashMap<String, String>> typeRef =
new TypeReference<HashMap<String, String>>() {};
Map<String, String> map = objectMapper.readValue(text, typeRef);
setValue(map);
} catch (Exception e) {
@@ -237,7 +237,7 @@ public class EditTextController {
Matcher matcher = edit.pattern().matcher(joined);
List<MatchSpan> spans = new ArrayList<>();
StringBuffer interpolation = new StringBuffer();
StringBuilder interpolation = new StringBuilder();
int previousAppendPosition = 0;
while (matcher.find()) {
if (matcher.start() == matcher.end()) {
@@ -95,7 +95,8 @@ public class UIDataController {
try (InputStream is = resource.getInputStream()) {
Map<String, List<Dependency>> licenseData =
objectMapper.readValue(is, new TypeReference<>() {});
objectMapper.readValue(
is, new TypeReference<Map<String, List<Dependency>>>() {});
data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) {
log.error("Failed to load licenses data", e);
@@ -25,12 +25,15 @@ final class FormPayloadParser {
private static final String KEY_VALUE = "value";
private static final String KEY_DEFAULT_VALUE = "defaultValue";
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<Map<String, Object>> MAP_TYPE =
new TypeReference<Map<String, Object>>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
MODIFY_FIELD_LIST_TYPE =
new TypeReference<List<FormUtils.ModifyFormFieldDefinition>>() {};
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE =
new TypeReference<List<String>>() {};
private FormPayloadParser() {}
@@ -96,7 +96,9 @@ public class AddCommentsController {
List<CommentSpecDto> dtos;
try {
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
dtos =
objectMapper.readValue(
commentsJson, new TypeReference<List<CommentSpecDto>>() {});
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
@@ -338,6 +338,19 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Whether this instance can link a Stirling (SaaS) account at all. The account-link
// beans live in :proprietary and are @ConditionalOnProperty on this same key, so when
// it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot
// tell that 404 apart from "not linked yet", so it needs this told to it explicitly
// before it can prompt anyone to link. Read from the environment rather than
// AccountLinkProperties because :core must not depend on :proprietary.
configData.put(
"accountLinkAvailable",
applicationContext
.getEnvironment()
.getProperty(
"stirling.billing.account-link.enabled", Boolean.class, false));
// AI Engine settings
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
@@ -4,7 +4,9 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
@@ -21,7 +23,7 @@ public class WeeklyActiveUsersService {
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
private final AtomicLong totalUniqueBrowsers = new AtomicLong(0);
// Application start time
private final Instant startTime = Instant.now();
@@ -36,12 +38,12 @@ public class WeeklyActiveUsersService {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
Instant now = Instant.now();
Instant previous = activeBrowsers.put(browserId, now);
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
if (previous == null) {
long total = totalUniqueBrowsers.incrementAndGet();
log.debug("New browser recorded: {} (Total: {})", browserId, total);
}
}
@@ -61,7 +63,7 @@ public class WeeklyActiveUsersService {
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
return totalUniqueBrowsers.get();
}
/**
@@ -88,7 +90,8 @@ public class WeeklyActiveUsersService {
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/** Manual cleanup trigger (can be called by scheduled task if needed) */
/** Scheduled cleanup trigger running every hour */
@Scheduled(fixedRate = 3600000)
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
@@ -94,7 +94,7 @@
{
"moduleName": "com.fasterxml.jackson.core:jackson-core",
"moduleUrl": "https://github.com/FasterXML/jackson-core",
"moduleVersion": "2.22.1",
"moduleVersion": "2.22.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -108,7 +108,7 @@
{
"moduleName": "com.fasterxml.jackson.core:jackson-databind",
"moduleUrl": "https://github.com/FasterXML/jackson",
"moduleVersion": "2.22.1",
"moduleVersion": "2.22.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -143,7 +143,7 @@
{
"moduleName": "com.fasterxml.jackson:jackson-bom",
"moduleUrl": "https://github.com/FasterXML/jackson-bom",
"moduleVersion": "2.22.1",
"moduleVersion": "2.22.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -440,42 +440,14 @@
{
"moduleName": "com.stirling:jpdfium",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.4",
"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.4",
"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.4",
"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.4",
"moduleVersion": "1.1.3",
"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.4",
"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.4",
"moduleVersion": "1.1.3",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@@ -521,36 +493,18 @@
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "com.twelvemonkeys.common:common-image",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-image",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-io",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-io",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-lang",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-lang",
"moduleVersion": "3.14.0",
@@ -569,12 +523,6 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
"moduleVersion": "3.14.0",
@@ -587,12 +535,6 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
"moduleVersion": "3.14.0",
@@ -605,24 +547,12 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
"moduleVersion": "3.14.0",
@@ -769,13 +699,6 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-beanutils:commons-beanutils",
"moduleUrl": "https://commons.apache.org/proper/commons-beanutils",
"moduleVersion": "1.11.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-cli:commons-cli",
"moduleUrl": "http://commons.apache.org/proper/commons-cli/",
@@ -790,13 +713,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-collections:commons-collections",
"moduleUrl": "http://commons.apache.org/collections/",
"moduleVersion": "3.2.2",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
@@ -1360,13 +1276,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-math3",
"moduleUrl": "http://commons.apache.org/proper/commons-math/",
"moduleVersion": "3.6.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-text",
"moduleUrl": "https://commons.apache.org/proper/commons-text",
@@ -59,7 +59,7 @@ public enum AuditLevel {
*/
public static AuditLevel fromInt(int level) {
// Ensure level is within valid bounds
int boundedLevel = Math.min(Math.max(level, 0), 3);
int boundedLevel = Math.clamp(level, 0, 3);
for (AuditLevel auditLevel : values()) {
if (auditLevel.level == boundedLevel) {
@@ -17,16 +17,16 @@ 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;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
*
@@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore {
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 static final TypeReference<List<String>> LIST_STRING =
new TypeReference<List<String>>() {};
private static final TypeReference<Map<String, String>> MAP_STRING =
new TypeReference<Map<String, String>>() {};
private final StringRedisTemplate template;
@@ -265,7 +267,7 @@ public class ValkeyJobStore implements JobStore {
}
try {
return MAPPER.readValue(v.toString(), MAP_STRING);
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
log.warn(
"JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty",
key,
@@ -277,7 +279,7 @@ public class ValkeyJobStore implements JobStore {
private static String writeJson(Object value) {
try {
return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
throw new IllegalStateException("Failed to JSON-serialize JobStore field", e);
}
}
@@ -286,7 +288,7 @@ public class ValkeyJobStore implements JobStore {
try {
List<String> parsed = MAPPER.readValue(json, LIST_STRING);
return parsed == null ? new ArrayList<>() : parsed;
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
log.warn(
"JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty",
key,
@@ -35,7 +35,7 @@ public class AuditConfigurationProperties {
// Ensure level is within valid bounds (0-3)
int configLevel = auditConfig.getLevel();
this.level = Math.min(Math.max(configLevel, 0), 3);
this.level = Math.clamp(configLevel, 0, 3);
// Retention days (0 means infinite)
this.retentionDays = auditConfig.getRetentionDays();
@@ -48,7 +48,7 @@ public class UsageRestController {
@RequestParam(value = "dataType", defaultValue = "all") String dataType,
@RequestParam(value = "days", defaultValue = "30") Integer days) {
int lookbackDays = Math.max(1, Math.min(days, 365));
int lookbackDays = Math.clamp(days, 1, 365);
// Get audit events filtered by type
List<PersistentAuditEvent> events = getEventsByDataType(dataType, lookbackDays);
@@ -1,5 +1,6 @@
package stirling.software.proprietary.model;
import java.io.Serial;
import java.io.Serializable;
import jakarta.persistence.*;
@@ -19,7 +20,7 @@ import lombok.*;
@ToString
public class UserLicenseSettings implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@@ -336,6 +336,15 @@ public class PolicyController {
* nothing to check.
*/
private void requireAccessibleOutput(Policy policy) {
// An editor policy hands its results back to the workspace the file came from. A stored
// destination would send the run to a folder or bucket instead, leaving the editor's copy
// untouched - and the editor's import would then have nothing to collect.
if (policy.editor().allowed() && !policy.outputIds().isEmpty()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"An editor policy delivers back to the editor and can't also have a"
+ " destination");
}
for (String outputId : policy.outputIds()) {
Source destination =
sourceStore
@@ -395,7 +404,8 @@ public class PolicyController {
policy.steps(),
policy.output(),
policy.outputIds(),
teamId);
teamId,
policy.editor());
}
/** Output secrets never leave the server: reads return the redaction sentinel instead. */
@@ -0,0 +1,34 @@
package stirling.software.proprietary.policy.model;
/**
* How a policy participates in the editor: it fires in the browser as each file passes through,
* rather than being swept from a stored {@code Source} on a trigger.
*
* <p>An object rather than a bare flag so the moment it fires ({@code runOn}) travels with the
* decision, and so later editor-only settings have somewhere to live.
*
* @param allowed whether the editor may run this policy at all
* @param runOn which moment it fires on: {@code "upload"} or {@code "export"}
*/
public record EditorConfig(boolean allowed, String runOn) {
public static final String UPLOAD = "upload";
public static final String EXPORT = "export";
public EditorConfig {
runOn = EXPORT.equals(runOn) ? EXPORT : UPLOAD;
}
/** Not an editor policy: swept server-side, or run only on demand. */
public static EditorConfig disabled() {
return new EditorConfig(false, UPLOAD);
}
public static EditorConfig onUpload() {
return new EditorConfig(true, UPLOAD);
}
public static EditorConfig onExport() {
return new EditorConfig(true, EXPORT);
}
}
@@ -1,6 +1,7 @@
package stirling.software.proprietary.policy.model;
import java.util.List;
import java.util.Optional;
/**
* A stored automation: ordered tool steps, input bindings, and output destinations.
@@ -26,7 +27,8 @@ public record Policy(
List<PipelineStep> steps,
OutputSpec output,
List<String> outputIds,
Long teamId) {
Long teamId,
EditorConfig editor) {
public Policy {
icon = icon == null ? "" : icon;
@@ -34,12 +36,13 @@ public record Policy(
steps = steps == null ? List.of() : steps;
output = output == null ? OutputSpec.inline() : output;
outputIds = outputIds == null ? List.of() : List.copyOf(outputIds);
editor = editor == null ? EditorConfig.disabled() : editor;
}
/**
* Without the {@code required} flag or an {@code icon}: defaults to not org-required and no
* icon. Kept for the many callers and tests written before those existed; the frontend and
* stores that care use the full constructor.
* Without the {@code required} flag, {@code icon}, or editor participation: defaults to not
* org-required, no icon, and a swept/on-demand policy. Kept for the many callers and tests
* written before those fields; the frontend and stores that care use the full constructor.
*/
public Policy(
String id,
@@ -51,7 +54,26 @@ public record Policy(
OutputSpec output,
List<String> outputIds,
Long teamId) {
this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId);
this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId, null);
}
/**
* Without the {@code required} flag or {@code icon} but with explicit editor participation: the
* seeded Classification policy runs on the editor, so it must set {@link EditorConfig} even
* though it predates the org-required and icon fields.
*/
public Policy(
String id,
String name,
String owner,
boolean enabled,
List<PipelineInput> inputs,
List<PipelineStep> steps,
OutputSpec output,
List<String> outputIds,
Long teamId,
EditorConfig editor) {
this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId, editor);
}
/**
@@ -91,6 +113,14 @@ public record Policy(
return inputs.stream().map(PipelineInput::sourceId).toList();
}
/**
* The moment this policy fires in the editor ("upload" / "export"), or empty when the editor
* does not run it. Legacy blobs are lifted onto {@link EditorConfig} when they are read.
*/
public Optional<String> editorRunOn() {
return editor.allowed() ? Optional.of(editor.runOn()) : Optional.empty();
}
/** The distinct trigger types configured across this policy's inputs (manual inputs aside). */
public List<String> triggerTypes() {
return inputs.stream()
@@ -105,14 +135,14 @@ public record Policy(
public Policy withOutput(OutputSpec resolved) {
return new Policy(
id, name, owner, enabled, required, icon, inputs, steps, resolved, outputIds,
teamId);
teamId, editor);
}
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
public Policy withOwner(String newOwner) {
return new Policy(
id, name, newOwner, enabled, required, icon, inputs, steps, output, outputIds,
teamId);
teamId, editor);
}
/** A copy referencing the given saved output destinations. */
@@ -128,7 +158,8 @@ public record Policy(
steps,
output,
newOutputIds,
teamId);
teamId,
editor);
}
/**
@@ -118,10 +118,14 @@ public class PolicyOverviewService {
/**
* Summarise a policy's triggers for the overview row: "manual" when no input is triggered,
* otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule").
*
* <p>An editor policy has no wire input to trigger, but it is not manual either - it fires in
* the editor on every upload or export, so it reports that rather than reading as on-demand.
*/
private static String triggerSummary(Policy policy) {
List<String> types = policy.triggerTypes();
return types.isEmpty() ? "manual" : String.join(", ", types);
if (!types.isEmpty()) return String.join(", ", types);
return policy.editorRunOn().map(runOn -> "editor-" + runOn).orElse("manual");
}
private static String outputSummary(OutputSpec output) {
@@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.TeamCreatedEvent;
import stirling.software.proprietary.policy.model.EditorConfig;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
@@ -98,9 +99,8 @@ public class DefaultClassificationPolicySeeder {
static Policy defaultPolicy(Long teamId) {
Map<String, Object> options = new HashMap<>();
options.put("categoryId", CATEGORY);
options.put("runOn", "upload");
options.put("mode", "new_version");
options.put("sources", List.of("editor"));
options.put("sources", List.of());
options.put("scopeTypes", List.of());
options.put("reviewerEmail", "");
return new Policy(
@@ -113,6 +113,9 @@ public class DefaultClassificationPolicySeeder {
List.of(),
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
new OutputSpec("inline", options),
teamId);
List.of(),
teamId,
// Classification runs in the editor on every upload.
EditorConfig.onUpload());
}
}
@@ -107,14 +107,12 @@ public class SourceOverviewService {
}
/**
* Whether a policy runs from the editor. Editor membership is carried in the policy's output
* metadata ({@code output.options.sources}) - a client-side list the editor writes when a
* policy targets it - rather than as a persisted {@code sourceId}, because the editor is
* virtual and has no stored source to reference.
* Whether a policy runs from the editor. Read from the policy's first-class {@link
* stirling.software.proprietary.policy.model.EditorConfig}, never inferred from a sources list
* (the editor is not a real source).
*/
private static boolean runsFromEditor(Policy policy) {
Object sources = policy.output().options().get("sources");
return sources instanceof List<?> list && list.contains(EditorSource.ID);
return policy.editor().allowed();
}
/**
@@ -40,7 +40,8 @@ public class InProcessPolicyStore implements PolicyStore {
policy.steps(),
policy.output(),
policy.outputIds(),
policy.teamId());
policy.teamId(),
policy.editor());
policies.put(id, stored);
// Existing policy keeps its position; a new one appends to the end of its team's queue.
sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId()));
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
@@ -11,8 +12,10 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.policy.model.EditorConfig;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyBinding;
import stirling.software.proprietary.policy.source.EditorSource;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
@@ -51,7 +54,8 @@ public class JpaPolicyStore implements PolicyStore {
policy.steps(),
policy.output(),
policy.outputIds(),
policy.teamId());
policy.teamId(),
policy.editor());
PolicyEntity entity = new PolicyEntity();
entity.setId(id);
@@ -151,7 +155,9 @@ public class JpaPolicyStore implements PolicyStore {
// One unreadable row must never abort a bulk read or crash startup.
private Optional<Policy> toPolicy(PolicyEntity entity) {
try {
JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson()));
JsonNode node =
liftEditorConfig(
upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())));
// A blob written by an older version won't carry fields added since (e.g. required,
// icon). Default absent primitives rather than rejecting the whole policy, so upgrades
// don't drop existing pipelines.
@@ -201,4 +207,61 @@ public class JpaPolicyStore implements PolicyStore {
obj.remove("sourceIds");
return obj;
}
/** Categories whose editor moment defaulted to export before it was stored (see runOn.ts). */
private static final Set<String> EXPORT_BY_DEFAULT = Set.of("security");
/**
* Derive {@code editor} for a blob written before editor participation had its own field, from
* its {@code output.options}: allowed when {@code sources} lists {@code "editor"}, or - for a
* catalogue policy - when there is no {@code sources} list at all (an unnarrowed catalogue
* policy runs in the editor).
*
* <p>Runs on every read, deliberately outside {@link #upgradeLegacyShape}'s early return: a
* blob written after triggers moved onto {@code inputs} but before this field existed still
* needs lifting, and that early return would skip exactly those rows.
*/
private JsonNode liftEditorConfig(JsonNode root) {
if (!(root instanceof ObjectNode obj) || obj.hasNonNull("editor")) {
return root;
}
JsonNode options = obj.path("output").path("options");
String categoryId = text(options, "categoryId");
JsonNode sources = options.get("sources");
boolean listed = sources != null && sources.isArray() && !sources.isEmpty();
boolean allowed;
if (listed) {
// An explicit scope list decides: only the editor's own id puts it on the editor.
allowed = false;
for (JsonNode source : sources) {
if (source.isValueNode() && EditorSource.ID.equals(source.asString())) {
allowed = true;
break;
}
}
} else {
// No list: a catalogue policy ran in the editor by default, but a builder pipeline
// (no category) could not reach the editor at all, so silence is not consent there.
allowed = !categoryId.isBlank();
}
ObjectNode editor = objectMapper.createObjectNode();
editor.put("allowed", allowed);
editor.put("runOn", legacyRunOn(options, categoryId));
obj.set("editor", editor);
return obj;
}
/** The stored moment, or the category default the client applied when none was stored. */
private static String legacyRunOn(JsonNode options, String categoryId) {
String stored = text(options, "runOn");
if (EditorConfig.EXPORT.equals(stored) || EditorConfig.UPLOAD.equals(stored)) {
return stored;
}
return EXPORT_BY_DEFAULT.contains(categoryId) ? EditorConfig.EXPORT : EditorConfig.UPLOAD;
}
private static String text(JsonNode parent, String field) {
JsonNode node = parent.path(field);
return node.isValueNode() ? node.asString() : "";
}
}
@@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
} else {
// Handle unknown authentication types
log.error(
"Authentication class unknown: {}",
authentication.getClass().getSimpleName());
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
switch (authentication) {
case Saml2Authentication samlAuthentication ->
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
case OAuth2AuthenticationToken oAuthToken ->
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken ->
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
default -> {
// Handle unknown authentication types
log.error(
"Authentication class unknown: {}",
authentication.getClass().getSimpleName());
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
}
} else {
if (jwtService != null) {
@@ -392,40 +392,40 @@ public class SecurityConfiguration {
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 -> {
oauth2.loginPage("/login")
.authorizationEndpoint(
authorizationEndpoint -> {
if (clientRegistrationRepository != null) {
authorizationEndpoint
.authorizationRequestResolver(
new TauriAuthorizationRequestResolver(
clientRegistrationRepository));
}
})
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService,
applicationProperties))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll();
});
oauth2 ->
oauth2.loginPage("/login")
.authorizationEndpoint(
authorizationEndpoint -> {
if (clientRegistrationRepository != null) {
authorizationEndpoint
.authorizationRequestResolver(
new TauriAuthorizationRequestResolver(
clientRegistrationRepository));
}
})
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService,
applicationProperties))
.failureHandler(
new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll());
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {
@@ -703,17 +703,18 @@ public class AuthController {
}
private long extractEpochMillis(Object claimValue) {
if (claimValue == null) {
return -1L;
}
if (claimValue instanceof java.util.Date date) {
return date.getTime();
}
if (claimValue instanceof Number number) {
long epochSeconds = number.longValue();
return epochSeconds * 1000L;
switch (claimValue) {
case null -> {
return -1L;
}
case java.util.Date date -> {
return date.getTime();
}
case Number number -> {
long epochSeconds = number.longValue();
return epochSeconds * 1000L;
}
default -> {}
}
return -1L;
@@ -760,14 +760,14 @@ public class UserController {
for (Object principal : principals) {
List<SessionInformation> sessionsInformation =
sessionRegistry.getAllSessions(principal, false);
if (principal instanceof UserDetails detailsUser) {
userNameP = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
userNameP = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
userNameP = saml2User.name();
} else if (principal instanceof String stringUser) {
userNameP = stringUser;
switch (principal) {
case null -> {}
case UserDetails detailsUser -> userNameP = detailsUser.getUsername();
case OAuth2User oAuth2User -> userNameP = oAuth2User.getName();
case CustomSaml2AuthenticatedPrincipal saml2User ->
userNameP = saml2User.name();
case String stringUser -> userNameP = stringUser;
default -> {}
}
if (userNameP.equalsIgnoreCase(username)) {
for (SessionInformation sessionInfo : sessionsInformation) {
@@ -1,5 +1,6 @@
package stirling.software.proprietary.security.model;
import java.io.Serial;
import java.io.Serializable;
import org.springframework.security.core.GrantedAuthority;
@@ -28,7 +29,7 @@ import lombok.Setter;
@Setter
public class Authority implements GrantedAuthority, Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -1,5 +1,6 @@
package stirling.software.proprietary.security.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
@@ -18,7 +19,7 @@ import lombok.Setter;
@Setter
public class InviteToken implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler
AuthenticationException exception)
throws IOException, ServletException {
if (exception instanceof BadCredentialsException) {
log.error("BadCredentialsException", exception);
getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials");
return;
}
if (exception instanceof DisabledException) {
log.error("User is deactivated: ", exception);
getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true");
return;
}
if (exception instanceof LockedException) {
log.error("Account locked: ", exception);
getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked");
return;
}
if (exception instanceof OAuth2AuthenticationException oAuth2Exception) {
OAuth2Error error = oAuth2Exception.getError();
String errorCode = error.getErrorCode();
if ("Password must not be null".equals(error.getErrorCode())) {
errorCode = "userAlreadyExistsWeb";
switch (exception) {
case BadCredentialsException badCredentialsException -> {
log.error("BadCredentialsException", exception);
getRedirectStrategy()
.sendRedirect(request, response, "/login?error=badCredentials");
return;
}
case DisabledException disabledException -> {
log.error("User is deactivated: ", exception);
getRedirectStrategy()
.sendRedirect(request, response, "/logout?userIsDisabled=true");
return;
}
case LockedException lockedException -> {
log.error("Account locked: ", exception);
getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked");
return;
}
case OAuth2AuthenticationException oAuth2Exception -> {
OAuth2Error error = oAuth2Exception.getError();
log.error(
"OAuth2 Authentication error: {}",
errorCode != null ? errorCode : exception.getMessage(),
exception);
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
clearRedirectCookie(response);
boolean tauriState = TauriOAuthUtils.isTauriState(request);
String redirectUrl;
if (tauriState) {
String basePath =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
redirectUrl = basePath;
String stateParam = request.getParameter("state");
if (stateParam != null && !stateParam.isBlank()) {
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
// Extract and pass nonce for CSRF validation
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
String errorCode = error.getErrorCode();
if ("Password must not be null".equals(error.getErrorCode())) {
errorCode = "userAlreadyExistsWeb";
}
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
} else {
redirectUrl = buildFailureRedirectUrl(request, errorValue);
log.error(
"OAuth2 Authentication error: {}",
errorCode != null ? errorCode : exception.getMessage(),
exception);
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
clearRedirectCookie(response);
boolean tauriState = TauriOAuthUtils.isTauriState(request);
String redirectUrl;
if (tauriState) {
String basePath =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
redirectUrl = basePath;
String stateParam = request.getParameter("state");
if (stateParam != null && !stateParam.isBlank()) {
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
// Extract and pass nonce for CSRF validation
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
}
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
} else {
redirectUrl = buildFailureRedirectUrl(request, errorValue);
}
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
return;
}
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
return;
default -> {}
}
log.error("Unhandled authentication exception", exception);
super.onAuthenticationFailure(request, response, exception);
@@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter
@Override
public Saml2Authentication convert(ResponseToken responseToken) {
Assertion assertion = responseToken.getResponse().getAssertions().getFirst();
List<Assertion> assertions = responseToken.getResponse().getAssertions();
if (assertions == null || assertions.isEmpty()) {
log.error("SAML response contains no assertions");
return null;
}
Assertion assertion = assertions.getFirst();
Map<String, List<Object>> attributes = extractAttributes(assertion);
// Debug log with actual values
@@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
}
sb.append(
"\nWARNING: this block contains PII. Set security.oauth2.debugLogging=false once"
+ " troubleshooting is complete.\n");
"""
WARNING: this block contains PII. Set security.oauth2.debugLogging=false once\
troubleshooting is complete.
""");
sb.append("========== [/OAUTH2 DEBUG] ==========");
if (failure) {
@@ -132,7 +132,9 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
verifyingKeyCache.put(
key.getKeyId(), new JwtVerificationKey(key.getKeyId(), key.getVerifyingKey()));
}
activeKey = new JwtVerificationKey(keys.get(0).getKeyId(), keys.get(0).getVerifyingKey());
activeKey =
new JwtVerificationKey(
keys.getFirst().getKeyId(), keys.getFirst().getVerifyingKey());
log.info("Loaded {} JWT key(s) from DB, active key: {}", keys.size(), activeKey.getKeyId());
}
@@ -640,14 +640,14 @@ public class UserService implements UserServiceInterface {
for (Object principal : sessionRegistry.getAllPrincipals()) {
for (SessionInformation sessionsInformation :
sessionRegistry.getAllSessions(principal, false)) {
if (principal instanceof UserDetails detailsUser) {
usernameP = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
usernameP = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
usernameP = saml2User.name();
} else if (principal instanceof String stringUser) {
usernameP = stringUser;
switch (principal) {
case null -> {}
case UserDetails detailsUser -> usernameP = detailsUser.getUsername();
case OAuth2User oAuth2User -> usernameP = oAuth2User.getName();
case CustomSaml2AuthenticatedPrincipal saml2User ->
usernameP = saml2User.name();
case String stringUser -> usernameP = stringUser;
default -> {}
}
if (usernameP.equalsIgnoreCase(username)) {
sessionRegistry.expireSession(sessionsInformation.getSessionId());
@@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry {
List<SessionInformation> sessionInformations = new ArrayList<>();
String principalName = null;
if (principal instanceof UserDetails detailsUser) {
principalName = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
principalName = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
principalName = saml2User.name();
} else if (principal instanceof String stringUser) {
principalName = stringUser;
switch (principal) {
case null -> {}
case UserDetails detailsUser -> principalName = detailsUser.getUsername();
case OAuth2User oAuth2User -> principalName = oAuth2User.getName();
case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name();
case String stringUser -> principalName = stringUser;
default -> {}
}
if (principalName != null) {
@@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry {
public void registerNewSession(String sessionId, Object principal) {
String principalName = null;
if (principal instanceof UserDetails detailsUser) {
principalName = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
principalName = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
principalName = saml2User.name();
} else if (principal instanceof String stringUser) {
principalName = stringUser;
switch (principal) {
case null -> {}
case UserDetails detailsUser -> principalName = detailsUser.getUsername();
case OAuth2User oAuth2User -> principalName = oAuth2User.getName();
case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name();
case String stringUser -> principalName = stringUser;
default -> {}
}
if (principalName != null) {
@@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* JPA AttributeConverter for storing Map<String, Object> as JSON in database columns.
*
@@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter<Map<String, Object>,
try {
return objectMapper.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
log.error("Failed to convert map to JSON", e);
throw new RuntimeException("Failed to convert map to JSON", e);
}
@@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter<Map<String, Object>,
try {
// Try normal parsing first
return objectMapper.readValue(dbData, new TypeReference<Map<String, Object>>() {});
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
// Fallback: try double-parsing for legacy double-encoded data
// This handles data that was stored as JSON strings instead of JSON objects
log.debug("Attempting double-decode fallback for legacy metadata format");
@@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter<Map<String, Object>,
return objectMapper.readValue(
node.asText(), new TypeReference<Map<String, Object>>() {});
}
} catch (JsonProcessingException e2) {
} catch (JacksonException e2) {
log.error("Failed to parse metadata even with double-decode fallback", e2);
}
@@ -1,5 +1,6 @@
package stirling.software.proprietary.storage.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
@@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User;
@Setter
public class FileShare implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -1,5 +1,6 @@
package stirling.software.proprietary.storage.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
@@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User;
@Setter
public class FileShareAccess implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -1,5 +1,6 @@
package stirling.software.proprietary.storage.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
@@ -24,7 +25,7 @@ import lombok.Setter;
@Setter
public class StorageCleanupEntry implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -1,5 +1,6 @@
package stirling.software.proprietary.storage.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession;
@Setter
public class StoredFile implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -1,5 +1,6 @@
package stirling.software.proprietary.storage.model;
import java.io.Serial;
import java.io.Serializable;
import jakarta.persistence.Column;
@@ -19,7 +20,7 @@ import lombok.Setter;
@Setter
public class StoredFileBlob implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@Column(name = "storage_key", nullable = false, length = 128)
@@ -7,6 +7,7 @@ import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter {
if (auth != null && auth.getAuthorities() != null) {
String roles =
auth.getAuthorities().stream()
.map(a -> a.getAuthority())
.map(GrantedAuthority::getAuthority)
.reduce((a, b) -> a + "," + b)
.orElse("");
MDC.put("userRoles", roles);
@@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -39,11 +37,14 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@RestController
@RequestMapping("/api/v1/security")
@@ -259,7 +260,9 @@ public class SigningSessionController {
+ "database until manual cleanup.",
sessionId,
session.getParticipants() != null
? session.getParticipants().stream().map(p -> p.getEmail()).toList()
? session.getParticipants().stream()
.map(WorkflowParticipant::getEmail)
.toList()
: "unknown",
e);
throw new ResponseStatusException(
@@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.ContentDisposition;
@@ -429,7 +430,7 @@ public class WorkflowParticipantController {
java.util.List<Map<String, Object>> wetSigs =
objectMapper.readValue(
request.getWetSignaturesData(),
new TypeReference<java.util.List<Map<String, Object>>>() {});
new TypeReference<List<Map<String, Object>>>() {});
if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Too many wet signatures submitted");
@@ -1,5 +1,6 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole;
@Setter
public class WorkflowParticipant implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -1,5 +1,6 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile;
@Setter
public class WorkflowSession implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -217,16 +217,13 @@ public class SigningFinalizationService {
wetSignatures.size(),
session.getSessionId());
PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes));
try {
try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) {
for (WetSignatureMetadata wetSig : wetSignatures) {
applyWetSignatureToPage(document, wetSig);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
} finally {
document.close();
}
}
@@ -242,11 +239,10 @@ public class SigningFinalizationService {
}
PDPage page = document.getPage(pageIndex);
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
try {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
// Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix
String base64Data = wetSig.extractBase64Data();
if (base64Data == null || base64Data.isBlank()) {
@@ -279,8 +275,6 @@ public class SigningFinalizationService {
pdfY,
width,
height);
} finally {
contentStream.close();
}
}
@@ -954,21 +954,22 @@ public class WorkflowSessionService {
Object pemObject = pemParser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
PrivateKeyInfo keyInfo;
if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) {
InputDecryptorProvider decryptor =
new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password);
keyInfo = encrypted.decryptPrivateKeyInfo(decryptor);
} else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) {
PEMDecryptorProvider decryptor =
new JcePEMDecryptorProviderBuilder().build(password);
keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo();
} else if (pemObject instanceof PEMKeyPair keyPair) {
keyInfo = keyPair.getPrivateKeyInfo();
} else if (pemObject instanceof PrivateKeyInfo info) {
keyInfo = info;
} else {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unsupported PEM private key format");
switch (pemObject) {
case PKCS8EncryptedPrivateKeyInfo encrypted -> {
InputDecryptorProvider decryptor =
new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password);
keyInfo = encrypted.decryptPrivateKeyInfo(decryptor);
}
case PEMEncryptedKeyPair encryptedKeyPair -> {
PEMDecryptorProvider decryptor =
new JcePEMDecryptorProviderBuilder().build(password);
keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo();
}
case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo();
case PrivateKeyInfo info -> keyInfo = info;
case null, default ->
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unsupported PEM private key format");
}
return converter.getPrivateKey(keyInfo);
}
@@ -4,14 +4,14 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import tools.jackson.databind.ObjectMapper;
/**
* Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent
* API responses.
@@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.EditorConfig;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineInput;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -156,7 +157,8 @@ class PolicyOverviewServiceTest {
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
OutputSpec.inline(),
List.of(),
null));
null,
EditorConfig.disabled()));
PolicyView view = find(service.overview(), "Mandatory redaction");
assertTrue(view.required());
@@ -177,7 +179,8 @@ class PolicyOverviewServiceTest {
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
List.of(),
null));
null,
EditorConfig.disabled()));
// No explicit icon: a template-derived policy falls back to its categoryId marker.
policyStore.save(
new Policy(
@@ -191,7 +194,8 @@ class PolicyOverviewServiceTest {
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
new OutputSpec("inline", Map.of("categoryId", "security")),
List.of(),
null));
null,
EditorConfig.disabled()));
assertEquals("shield", find(service.overview(), "Custom with icon").icon());
assertEquals("security", find(service.overview(), "Template derived").icon());
@@ -278,6 +282,44 @@ class PolicyOverviewServiceTest {
teamId));
}
@Test
void editorPolicyReportsItsRunMomentRatherThanReadingAsManual() {
policyStore.save(
new Policy(
null,
"Editor flatten",
"owner",
true,
List.of(),
List.of(new PipelineStep("/api/v1/misc/flatten", Map.of())),
OutputSpec.inline(),
List.of(),
1L,
EditorConfig.onUpload()));
PolicyView view = find(service.overview(), "Editor flatten");
assertEquals("editor-upload", view.trigger());
}
@Test
void sweptPolicyWithNoTriggeredInputIsStillManual() {
policyStore.save(
new Policy(
null,
"Swept compress",
"owner",
true,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
1L));
PolicyView view = find(service.overview(), "Swept compress");
assertEquals("manual", view.trigger());
}
private static PolicyView find(PoliciesOverviewResponse response, String name) {
return response.pipelines().stream()
.filter(view -> view.name().equals(name))
@@ -64,14 +64,30 @@ class DefaultClassificationPolicySeederTest {
assertThat(policy.teamId()).isEqualTo(7L);
assertThat(policy.output().type()).isEqualTo("inline");
assertThat(policy.output().options().get("categoryId")).isEqualTo("classification");
assertThat(policy.output().options().get("runOn")).isEqualTo("upload");
assertThat(policy.output().options().get("mode")).isEqualTo("new_version");
assertThat(policy.output().options().get("sources")).isEqualTo(List.of("editor"));
// Editor participation is the policy's own flag, not a marker in the output options.
assertThat(policy.editor().allowed()).isTrue();
assertThat(policy.editor().runOn()).isEqualTo("upload");
assertThat(policy.steps()).hasSize(1);
assertThat(policy.steps().get(0).operation())
.isEqualTo("/api/v1/ai/tools/classify-and-label");
}
@Test
void marksEditorParticipationOnEditorConfigAndSeedsNoSources() {
when(policyStore.findByTeam(7L)).thenReturn(List.of());
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
ArgumentCaptor<Policy> saved = ArgumentCaptor.forClass(Policy.class);
verify(policyStore).save(saved.capture());
Policy policy = saved.getValue();
// Editor participation is on EditorConfig, not the sources list; the seed carries no
// sources.
assertThat(policy.editor().allowed()).isTrue();
assertThat(policy.output().options().get("sources")).isEqualTo(List.of());
}
@Test
void doesNotSeedWhenAClassificationPolicyAlreadyExists() {
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L)));
@@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.EditorConfig;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineInput;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -222,9 +223,7 @@ class SourceOverviewServiceTest {
OutputSpec.inline()));
}
/**
* A policy that targets the editor: membership rides in its output metadata, not a sourceId.
*/
/** A policy that targets the editor: membership on its {@link EditorConfig}, not a sourceId. */
private void editorPolicy(String name) {
policyStore.save(
new Policy(
@@ -234,7 +233,10 @@ class SourceOverviewServiceTest {
true,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec("inline", Map.of("sources", List.of("editor")))));
OutputSpec.inline(),
List.of(),
null,
EditorConfig.onUpload()));
}
private void teamPolicy(String name, Long teamId, String... sourceIds) {
@@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.policy.model.EditorConfig;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineInput;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -113,6 +114,129 @@ class JpaPolicyStoreTest {
upgraded.inputs());
}
/**
* The regression this guards: before the editor lift, a blob written by the pre-{@code editor}
* seeder deserialized straight onto {@link EditorConfig#disabled()}, silently taking every
* upgraded install's Classification policy off the editor.
*
* <p>The {@code inputs} variant is the important one - {@link
* JpaPolicyStore#upgradeLegacyShape} returns early on it, so a lift living inside that method
* would miss exactly the rows written between the trigger migration and this field.
*/
@Test
void getLiftsALegacyEditorSourceOntoEditorConfigWhenInputsArePresent() {
Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],"));
assertEquals(EditorConfig.onUpload(), lifted.editor());
assertEquals(Optional.of("upload"), lifted.editorRunOn());
}
@Test
void getLiftsALegacyEditorSourceOnThePreInputsShapeToo() {
// Oldest shape: policy-level trigger + sourceIds, so both migrations have to compose.
Policy lifted =
readLegacy(
legacyJson(
"\"trigger\":{\"type\":\"schedule\",\"options\":{}},"
+ "\"sourceIds\":[\"s1\"],",
"\"sources\":[\"editor\"],"));
assertEquals(EditorConfig.onUpload(), lifted.editor());
assertEquals(
List.of(new PipelineInput("s1", new TriggerConfig("schedule", Map.of()))),
lifted.inputs());
}
@Test
void getTreatsAnUnnarrowedCataloguePolicyAsEditorRun() {
// Empty and absent both meant "nobody narrowed it", which the editor read as its own.
assertTrue(readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[],")).editor().allowed());
assertTrue(readLegacy(legacyJson("\"inputs\":[],", "")).editor().allowed());
}
@Test
void getLeavesACataloguePolicyScopedElsewhereOffTheEditor() {
Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"sharepoint\"],"));
assertFalse(lifted.editor().allowed());
assertEquals(Optional.empty(), lifted.editorRunOn());
}
@Test
void getLeavesASourcelessBuilderPipelineOffTheEditor() {
// No categoryId: a pipeline built on the Pipelines page, which never reached the editor.
String json =
"{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[],"
+ "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{}}}";
assertFalse(readLegacy(json).editor().allowed());
}
@Test
void getKeepsTheCategoryDefaultMomentWhenNoRunOnWasStored() {
// Security enforced on export before runOn was persisted (frontend runOn.ts
// DEFAULT_RUN_ON).
String json =
"{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[],"
+ "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{"
+ "\"categoryId\":\"security\",\"sources\":[\"editor\"]}}}";
assertEquals(EditorConfig.onExport(), readLegacy(json).editor());
}
@Test
void getNeverOverridesAnExplicitlyStoredEditorBlock() {
// A deliberate opt-out survives, so the lift stays safe to leave in permanently.
String json =
"{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[],"
+ "\"steps\":[],\"editor\":{\"allowed\":false,\"runOn\":\"upload\"},"
+ "\"output\":{\"type\":\"inline\",\"options\":{"
+ "\"categoryId\":\"classification\",\"sources\":[\"editor\"]}}}";
assertFalse(readLegacy(json).editor().allowed());
}
/**
* Pins the wire shape the stubbed Playwright spec hardcodes: the derived block is additive, so
* a real response carries it alongside the untouched legacy options bag.
*/
@Test
void getLeavesTheLegacyOptionsBagIntactSoTheResponseCarriesBoth() {
Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],"));
assertEquals(List.of("editor"), lifted.output().options().get("sources"));
String wire = objectMapper.writeValueAsString(lifted);
assertTrue(
wire.contains("\"editor\":{\"allowed\":true,\"runOn\":\"upload\"}"),
"expected the derived editor block on the wire, got: " + wire);
}
/**
* The blob main's DefaultClassificationPolicySeeder wrote, with the shape bits parameterised.
*/
private static String legacyJson(String shapeFields, String sourcesField) {
return "{\"id\":\"p1\",\"name\":\"Classification Policy\",\"owner\":\"system\","
+ "\"enabled\":true,"
+ shapeFields
+ "\"steps\":[{\"operation\":\"/api/v1/ai/tools/classify-and-label\","
+ "\"parameters\":{}}],"
+ "\"output\":{\"type\":\"inline\",\"options\":{"
+ "\"categoryId\":\"classification\",\"runOn\":\"upload\","
+ "\"mode\":\"new_version\","
+ sourcesField
+ "\"scopeTypes\":[],\"reviewerEmail\":\"\"}},\"teamId\":1}";
}
private Policy readLegacy(String policyJson) {
PolicyEntity entity = new PolicyEntity();
entity.setId("p1");
entity.setName("legacy");
entity.setEnabled(true);
entity.setPolicyJson(policyJson);
when(repository.findById("p1")).thenReturn(Optional.of(entity));
return store.get("p1").orElseThrow();
}
@Test
void saveDenormalizesTeamIdForScopedQueries() {
store.save(
@@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -167,7 +167,7 @@ public class AiCreateController {
if (request.constraints() != null) {
try {
constraintsPayload = objectMapper.writeValueAsString(request.constraints());
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc);
}
@@ -202,7 +202,7 @@ public class AiCreateController {
String payload;
try {
payload = objectMapper.writeValueAsString(request.draftSections());
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
}
@@ -392,7 +392,7 @@ public class AiCreateController {
objectMapper
.getTypeFactory()
.constructCollectionType(List.class, DraftSection.class));
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
log.warn("Failed to parse draft sections payload", exc);
return null;
}
@@ -408,7 +408,7 @@ public class AiCreateController {
objectMapper
.getTypeFactory()
.constructMapType(Map.class, String.class, Object.class));
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
log.warn("Failed to parse outline constraints payload", exc);
return null;
}
@@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -61,7 +61,7 @@ public class AiCreateInternalController {
try {
outlineConstraintsPayload =
objectMapper.writeValueAsString(request.outlineConstraints());
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc);
}
@@ -70,7 +70,7 @@ public class AiCreateInternalController {
if (request.draftSections() != null) {
try {
draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections());
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
}
@@ -136,7 +136,7 @@ public class AiCreateInternalController {
.getTypeFactory()
.constructCollectionType(
List.class, AiCreateController.DraftSection.class));
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
log.warn("Failed to parse draft sections payload", exc);
return null;
}
@@ -152,7 +152,7 @@ public class AiCreateInternalController {
objectMapper
.getTypeFactory()
.constructMapType(Map.class, String.class, Object.class));
} catch (JsonProcessingException exc) {
} catch (JacksonException exc) {
log.warn("Failed to parse outline constraints payload", exc);
return null;
}
@@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@@ -13,8 +13,8 @@ import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
@@ -56,28 +56,26 @@ public class LegalDocumentRegistry {
subprocessorUrl = root.path("subprocessorUrl").asText("");
eulaUrl = root.path("eulaUrl").asText("");
JsonNode docs = root.path("documents");
docs.fieldNames()
.forEachRemaining(
id -> {
JsonNode d = docs.get(id);
List<String> parts =
objectMapper.convertValue(
d.path("parts"),
objectMapper
.getTypeFactory()
.constructCollectionType(
List.class, String.class));
documents.put(
docs.forEachEntry(
(id, d) -> {
List<String> parts =
objectMapper.convertValue(
d.path("parts"),
objectMapper
.getTypeFactory()
.constructCollectionType(
List.class, String.class));
documents.put(
id,
new LegalDocumentMeta(
id,
new LegalDocumentMeta(
id,
d.path("label").asText(id),
d.path("displayName").asText(id),
d.path("version").asText("0"),
d.path("effectiveDate").asText(""),
d.path("status").asText("draft"),
parts == null ? List.of() : parts));
});
d.path("label").asText(id),
d.path("displayName").asText(id),
d.path("version").asText("0"),
d.path("effectiveDate").asText(""),
d.path("status").asText("draft"),
parts == null ? List.of() : parts));
});
log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST);
}
@@ -20,7 +20,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
@@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
@@ -10,7 +10,7 @@ import java.util.Map;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@@ -707,7 +707,7 @@ public class ProcurementService {
private String writeLineItems(QuoteBreakdown breakdown) {
try {
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
log.warn("[procurement] failed to serialise line items", e);
return "[]";
}
@@ -113,19 +113,13 @@ public class RateLimitService {
public void cleanupExpiredBuckets() {
long now = System.currentTimeMillis();
int hourlyRemoved =
(int)
hourlyLimits.entrySet().stream()
.filter(e -> e.getValue().getResetTime() < now)
.peek(e -> hourlyLimits.remove(e.getKey()))
.count();
int hourlyBefore = hourlyLimits.size();
hourlyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now);
int hourlyRemoved = hourlyBefore - hourlyLimits.size();
int dailyRemoved =
(int)
dailyLimits.entrySet().stream()
.filter(e -> e.getValue().getResetTime() < now)
.peek(e -> dailyLimits.remove(e.getKey()))
.count();
int dailyBefore = dailyLimits.size();
dailyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now);
int dailyRemoved = dailyBefore - dailyLimits.size();
if (hourlyRemoved + dailyRemoved > 0) {
log.debug(
@@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.method.HandlerMethod;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+2 -2
View File
@@ -38,9 +38,9 @@ ext {
gsonVersion = "2.14.0"
guavaVersion = "33.6.0-jre"
jinjavaVersion = "2.8.4"
jackson2Version = "2.22.1"
jackson2Version = "2.22.2"
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
archunitVersion = "1.5.0"
batikVersion = "1.19"
jpdfiumVersion = "1.1.3"
jwtVersion = "0.13.0"
+1 -1
View File
@@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+5 -5
View File
@@ -5,7 +5,7 @@
ARG TARGETPLATFORM
# Stage 1: Build and strip Calibre
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS calibre-build
FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS calibre-build
ARG TARGETPLATFORM
ARG CALIBRE_VERSION=9.13.0
ARG CALIBRE_STRIP_WEBENGINE=false
@@ -274,7 +274,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \
# Stage 2: Build Ghostscript from source
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS gs-build
FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS gs-build
ARG TARGETPLATFORM
ARG GS_VERSION=10.07.1
@@ -298,7 +298,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# Stage 3: Build PDF Tools (QPDF and ImageMagick 7)
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS pdf-tools-build
FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS pdf-tools-build
ARG TARGETPLATFORM
ARG QPDF_VERSION=12.4.0
ARG IM_VERSION=7.1.2-29
@@ -343,7 +343,7 @@ RUN mkdir -p /magick-export/usr/bin \
# Stage 4: Build Python venv
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS python-venv-build
FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS python-venv-build
ARG TARGETPLATFORM
ARG UNOSERVER_VERSION=3.7
@@ -368,7 +368,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
# Final runtime image - the actual base image
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime
FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+15 -1
View File
@@ -48,9 +48,23 @@ ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
# portal or AI layers change; defaults false so normal builds skip the extra app.
ARG BUILD_PORTAL=false
# Which Stirling account the portal connects to. Build-time because Vite inlines VITE_* into the
# bundle; there is no runtime override. Empty leaves the committed .env.proprietary defaults, which
# is what an ordinary image wants: no Stirling account and no connect flow. The publishable key is
# client-side by design, not a secret. Pass the URL and the key from the same Supabase project or
# the browser accepts the pair and Supabase rejects it, which surfaces later as "session expired".
ARG VITE_SUPABASE_URL=""
ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=""
ARG VITE_SAAS_API_URL=""
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
# Exported only when non-empty: Vite reads process.env ahead of the .env files, so exporting an
# empty value would blank the committed default rather than fall back to it.
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \
if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \
if [ -n "${VITE_SAAS_API_URL}" ]; then export VITE_SAAS_API_URL="${VITE_SAAS_API_URL}"; fi; \
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
gradle clean build \
-PbuildWithFrontend=true \
@@ -61,7 +75,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+1 -1
View File
@@ -61,7 +61,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+1 -1
View File
@@ -62,7 +62,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke
# Stage 2: Runtime image
# glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked.
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db
FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
+1 -1
View File
@@ -1,7 +1,7 @@
# Standalone unoserver image for Stirling-PDF remote UNO mode.
# Pinned to unoserver 3.7 to match Stirling-PDF's client (avoids wire mismatch).
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea
FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517
ARG UNOSERVER_VERSION=3.7
# ~120 MB of CJK fonts — opt-in.
+3 -3
View File
@@ -20,7 +20,7 @@ engine = [
# No `voyageai` extra either; stirling.documents.voyage speaks its API directly.
"pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0",
"pydantic-settings>=2.15.0",
"python-dotenv>=1.2.2",
"python-dotenv>=1.2.3",
"sqlite-vec>=0.1.9",
"uvicorn>=0.52.3",
]
@@ -42,7 +42,7 @@ cucumber = [
"pillow>=12.3.0",
"pypdf[crypto]>=6.15.0",
"qrcode[pil]>=8.2",
"reportlab>=5.0.0",
"reportlab>=5.0.1",
"requests>=2.34.2",
]
# Shared Python utilities used by repository scripts and CI workflows.
@@ -51,7 +51,7 @@ tools = [
"defusedxml>=0.7.1",
"fonttools>=4.63.0",
"fpdf2>=2.8.8",
"openai>=2.53.0",
"openai>=3.3.1",
"requests>=2.34.2",
"tomli-w>=1.2.0",
"tomlkit>=0.15.1",
+13 -27
View File
@@ -466,7 +466,7 @@ cucumber = [
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "pypdf", extras = ["crypto"], specifier = ">=6.15.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
{ name = "reportlab", specifier = ">=5.0.0" },
{ name = "reportlab", specifier = ">=5.0.1" },
{ name = "requests", specifier = ">=2.34.2" },
]
engine = [
@@ -479,7 +479,7 @@ engine = [
{ name = "pydantic", specifier = ">=2.13.4" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.107.2,<2.0.0" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "python-dotenv", specifier = ">=1.2.3" },
{ name = "sqlite-vec", specifier = ">=0.1.9" },
{ name = "uvicorn", specifier = ">=0.52.3" },
]
@@ -501,7 +501,7 @@ tools = [
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "fonttools", specifier = ">=4.63.0" },
{ name = "fpdf2", specifier = ">=2.8.8" },
{ name = "openai", specifier = ">=2.53.0" },
{ name = "openai", specifier = ">=3.3.1" },
{ name = "requests", specifier = ">=2.34.2" },
{ name = "tomli-w", specifier = ">=1.2.0" },
{ name = "tomlkit", specifier = ">=0.15.1" },
@@ -844,21 +844,19 @@ wheels = [
[[package]]
name = "openai"
version = "2.53.0"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "httpx2" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/9c/ba0c292b4032ede74c249ca314ad64eb1bb5a03a843f6e01facb02f80cd8/openai-3.3.1.tar.gz", hash = "sha256:6f22807de1a976c932cecda620e8172a8c3fdbaeed29c7f21564e0c2410edf56", size = 1282113, upload-time = "2026-08-19T16:31:35.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" },
{ url = "https://files.pythonhosted.org/packages/6a/db/2b7a1b3de659bb82aef979116c74e809982b13e42c057759767552b5155f/openai-3.3.1-py3-none-any.whl", hash = "sha256:9652df7fdf8ee6f5bd58e0a12f2b1d414a18e0f06bb7a9a57c8643a5f5469bd3", size = 1690337, upload-time = "2026-08-19T16:31:32.812Z" },
]
[[package]]
@@ -1262,11 +1260,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.2.2"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
@@ -1373,15 +1371,15 @@ wheels = [
[[package]]
name = "reportlab"
version = "5.0.0"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/d6/4b7b0cf56880eb96533e607967be6a939e344675601e033d113a0bfa1f4e/reportlab-5.0.0.tar.gz", hash = "sha256:e4494a0c6623ae213bb856fba523171b2b54a7bf629fda02d5e525a7b899a784", size = 3701928, upload-time = "2026-06-18T11:34:31.145Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/07/70085c17a369605f15e301d10ab902115019b1126c7253d964afc230c7d6/reportlab-5.0.0-py3-none-any.whl", hash = "sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c", size = 1956710, upload-time = "2026-06-18T11:34:29.07Z" },
{ url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" },
]
[[package]]
@@ -1566,18 +1564,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
]
[[package]]
name = "tqdm"
version = "4.70.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
@@ -3526,7 +3526,6 @@ label = "إحداثي Y"
[crop.error]
failed = "فشل قصّ PDF"
invalidArea = "منطقة القص تتجاوز حدود PDF"
[crop.preview]
title = "معاينة منطقة القص"
@@ -3526,7 +3526,6 @@ label = "Y mövqeyi"
[crop.error]
failed = "PDF-i kəsmək alınmadı"
invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır"
[crop.preview]
title = "Kəsmə sahəsinin seçimi"

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