Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7265d1b9fa | ||
|
|
a1808b6c0f | ||
|
|
e78b8fcd37 | ||
|
|
e263b9f507 | ||
|
|
16ac5e4822 | ||
|
|
b4a1104902 | ||
|
|
2581c11c9d | ||
|
|
b64f626926 | ||
|
|
76b129decb | ||
|
|
6810155799 | ||
|
|
fe520d24a9 | ||
|
|
351abf54a1 | ||
|
|
cad2ac206a | ||
|
|
4598ae1e8d | ||
|
|
a6a6de960e | ||
|
|
9a4af79c6d | ||
|
|
b43cd350f7 | ||
|
|
9a985abf46 | ||
|
|
837371cff0 | ||
|
|
da7b586313 | ||
|
|
33bfb18d7c | ||
|
|
584fd0cb09 | ||
|
|
ecdb8160a9 | ||
|
|
0bd7d72d07 | ||
|
|
10eac6622c | ||
|
|
641114c5c2 |
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.2
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.2
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary|saas)/build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
|
||||
openapi: &openapi
|
||||
- *build
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
docker-base: &docker-base
|
||||
- docker/base/Dockerfile
|
||||
@@ -23,9 +23,9 @@ docker: &docker
|
||||
- *docker-base
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- *build
|
||||
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
@@ -82,7 +82,7 @@ tauri: &tauri
|
||||
# tool surfaces it generates models from.
|
||||
engine: &engine
|
||||
- engine/**
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
@@ -63,7 +63,6 @@ labels:
|
||||
files:
|
||||
- 'app/core/src/main/resources/static/.*'
|
||||
- 'app/proprietary/src/main/resources/static/.*'
|
||||
- 'app/saas/src/main/resources/static/.*'
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
@@ -84,7 +83,6 @@ labels:
|
||||
- 'app/common/src/main/java/.*.java'
|
||||
- 'app/proprietary/src/main/java/.*.java'
|
||||
- 'app/core/src/main/java/.*.java'
|
||||
- 'app/saas/src/main/java/.*.java'
|
||||
|
||||
- label: 'Back End'
|
||||
files:
|
||||
@@ -92,9 +90,6 @@ labels:
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
|
||||
- 'app/core/src/main/resources/settings.yml.template'
|
||||
- 'app/core/src/main/resources/application.properties'
|
||||
- 'app/proprietary/src/main/resources/application-proprietary.properties'
|
||||
- 'app/saas/src/main/resources/application-dev.properties'
|
||||
- 'app/saas/src/main/resources/application-saas.properties'
|
||||
- 'app/core/src/main/resources/banner.txt'
|
||||
- 'app/core/src/main/resources/static/python/png_to_webp.py'
|
||||
- 'app/core/src/main/resources/static/python/split_photos.py'
|
||||
@@ -158,7 +153,6 @@ labels:
|
||||
- 'app/common/src/test/.*'
|
||||
- 'app/proprietary/src/test/.*'
|
||||
- 'app/core/src/test/.*'
|
||||
- 'app/saas/src/test/.*'
|
||||
- 'testing/.*'
|
||||
- '.github/workflows/scorecards.yml'
|
||||
- 'exampleYmlFiles/test_cicd.yml'
|
||||
@@ -177,4 +171,3 @@ labels:
|
||||
- 'app/common/build.gradle'
|
||||
- 'app/proprietary/build.gradle'
|
||||
- 'app/core/build.gradle'
|
||||
- 'app/saas/build.gradle'
|
||||
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
|
||||
@@ -270,7 +270,7 @@ jobs:
|
||||
|
||||
- name: Build and push PR-specific image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -296,7 +296,7 @@ jobs:
|
||||
|
||||
- name: Build and push engine image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
|
||||
- name: Build and push frontend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Build and push backend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -510,7 +510,6 @@ jobs:
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
@@ -532,26 +531,11 @@ jobs:
|
||||
|
||||
# Extract MSI and verify the inner exe (the file that actually gets installed).
|
||||
# This is the critical check - AV flags the installed exe at runtime.
|
||||
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
|
||||
# _MSIExecute mutex and hangs forever on hosted runners when another
|
||||
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
|
||||
$msi = $msiFiles[0].FullName
|
||||
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
choco install lessmsi -y --no-progress --limit-output | Out-Null
|
||||
|
||||
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
|
||||
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit(120000)) {
|
||||
try { $proc.Kill() } catch {}
|
||||
Write-Host "[ERROR] MSI extraction timed out after 120s"
|
||||
$allSigned = $false
|
||||
} elseif ($proc.ExitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
|
||||
if ($innerExe) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
|
||||
@@ -564,6 +548,9 @@ jobs:
|
||||
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
|
||||
$allSigned = $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
}
|
||||
|
||||
if (-not $allSigned) {
|
||||
@@ -813,11 +800,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
# Don't regenerate/append notes on re-runs, and don't force this into the
|
||||
# "Latest" slot - leave the release body and latest marker as they are.
|
||||
generate_release_notes: false
|
||||
append_body: false
|
||||
make_latest: false
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
# Installers + updater payloads + manifest. .sig contents are embedded
|
||||
# in latest.json so the .sig files themselves are not uploaded.
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: build-push-base
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: docker/base
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
id: build-push-latest
|
||||
# Empty-tag guard: build-push-action errors when asked to push with no tags.
|
||||
if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != ''
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
- name: Build and push unoserver image
|
||||
id: build-push-unoserver
|
||||
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
# (multi-platform + gha cache) against the published base image.
|
||||
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -259,7 +259,7 @@ jobs:
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Build and push test image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"frontend/editor/src-tauri/icons/icon.png"
|
||||
]
|
||||
}
|
||||
@@ -80,12 +80,6 @@ tasks:
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
|
||||
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
|
||||
# full path, hostname, or user. Consumed at dev-serve time by vite.config
|
||||
# and dropped from production builds.
|
||||
STIRLING_DEV_LABEL:
|
||||
sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
cmds:
|
||||
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
|
||||
@@ -208,18 +208,6 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The W3C License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.re2j:re2j",
|
||||
"moduleLicense": "Go License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot:algebra",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot.immutables:immutables-exceptions",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnRar License"
|
||||
|
||||
@@ -73,7 +73,7 @@ public class RuntimePathConfig {
|
||||
defaultWatchedFolders,
|
||||
watchedFoldersDirs,
|
||||
pipeline != null ? pipeline.getWatchedFoldersDir() : null);
|
||||
this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.get(0);
|
||||
this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.getFirst();
|
||||
this.pipelineFinishedFoldersPath =
|
||||
resolvePath(
|
||||
defaultFinishedFolders,
|
||||
|
||||
@@ -246,14 +246,6 @@ public class ApplicationProperties {
|
||||
* and paused runs are kept regardless of age.
|
||||
*/
|
||||
private int runExpiryMinutes = 30;
|
||||
|
||||
/**
|
||||
* Whether a policy S3 source's custom endpoint may resolve to a loopback, link-local, or
|
||||
* private address. Off by default so a user-supplied endpoint cannot be pointed at internal
|
||||
* services (e.g. the cloud metadata address); enable for a self-hosted MinIO or other
|
||||
* in-network object store.
|
||||
*/
|
||||
private boolean allowPrivateS3Endpoints = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -60,54 +60,40 @@ public class Provider {
|
||||
}
|
||||
|
||||
private UsernameAttribute validateUsernameAttribute(UsernameAttribute usernameAttribute) {
|
||||
switch (name) {
|
||||
case "google" -> {
|
||||
return validateGoogleUsernameAttribute(usernameAttribute);
|
||||
}
|
||||
case "github" -> {
|
||||
return validateGitHubUsernameAttribute(usernameAttribute);
|
||||
}
|
||||
case "keycloak" -> {
|
||||
return validateKeycloakUsernameAttribute(usernameAttribute);
|
||||
}
|
||||
default -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
}
|
||||
return switch (name) {
|
||||
case "google" -> validateGoogleUsernameAttribute(usernameAttribute);
|
||||
case "github" -> validateGitHubUsernameAttribute(usernameAttribute);
|
||||
case "keycloak" -> validateKeycloakUsernameAttribute(usernameAttribute);
|
||||
default -> usernameAttribute;
|
||||
};
|
||||
}
|
||||
|
||||
private UsernameAttribute validateKeycloakUsernameAttribute(
|
||||
UsernameAttribute usernameAttribute) {
|
||||
switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME, PREFERRED_USERNAME -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
return switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME, PREFERRED_USERNAME -> usernameAttribute;
|
||||
default ->
|
||||
throw new UnsupportedClaimException(
|
||||
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private UsernameAttribute validateGoogleUsernameAttribute(UsernameAttribute usernameAttribute) {
|
||||
switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
return switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> usernameAttribute;
|
||||
default ->
|
||||
throw new UnsupportedClaimException(
|
||||
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private UsernameAttribute validateGitHubUsernameAttribute(UsernameAttribute usernameAttribute) {
|
||||
switch (usernameAttribute) {
|
||||
case LOGIN, EMAIL, NAME -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
return switch (usernameAttribute) {
|
||||
case LOGIN, EMAIL, NAME -> usernameAttribute;
|
||||
default ->
|
||||
throw new UnsupportedClaimException(
|
||||
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -361,8 +361,8 @@ public class PdfMarkdownConverter {
|
||||
if (xs.isEmpty()) {
|
||||
return List.of(lines);
|
||||
}
|
||||
float minX = xs.get(0);
|
||||
float maxX = xs.get(xs.size() - 1);
|
||||
float minX = xs.getFirst();
|
||||
float maxX = xs.getLast();
|
||||
float splitAt = (minX + maxX) / 2f;
|
||||
float biggestGap = 0;
|
||||
for (int i = 1; i < xs.size(); i++) {
|
||||
@@ -492,7 +492,7 @@ public class PdfMarkdownConverter {
|
||||
|
||||
List<List<Line>> anchorGroups = new ArrayList<>();
|
||||
List<Line> current = new ArrayList<>();
|
||||
current.add(cands.get(0));
|
||||
current.add(cands.getFirst());
|
||||
for (int i = 1; i < cands.size(); i++) {
|
||||
float gap = cands.get(i - 1).y - cands.get(i).y;
|
||||
if (gap > splitThreshold) {
|
||||
@@ -513,8 +513,8 @@ public class PdfMarkdownConverter {
|
||||
if (anchors.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
float top = anchors.get(0).y;
|
||||
float bottom = anchors.get(anchors.size() - 1).y;
|
||||
float top = anchors.getFirst().y;
|
||||
float bottom = anchors.getLast().y;
|
||||
|
||||
// Each anchor seeds a row; absorb wrapped continuation lines (non-anchors within the
|
||||
// run's vertical span, with a little slack below the last row) into the anchor above.
|
||||
@@ -674,8 +674,8 @@ public class PdfMarkdownConverter {
|
||||
float minGutter = Math.max(10f, charWidth * 2.5f);
|
||||
List<float[]> merged = new ArrayList<>();
|
||||
for (float[] band : columns) {
|
||||
if (!merged.isEmpty() && band[0] - merged.get(merged.size() - 1)[1] < minGutter) {
|
||||
merged.get(merged.size() - 1)[1] = band[1];
|
||||
if (!merged.isEmpty() && band[0] - merged.getLast()[1] < minGutter) {
|
||||
merged.getLast()[1] = band[1];
|
||||
} else {
|
||||
merged.add(new float[] {band[0], band[1]});
|
||||
}
|
||||
@@ -734,7 +734,7 @@ public class PdfMarkdownConverter {
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(buildGfmRow(rows.get(0), widths, cols)).append('\n');
|
||||
sb.append(buildGfmRow(rows.getFirst(), widths, cols)).append('\n');
|
||||
sb.append('|');
|
||||
for (int c = 0; c < cols; c++) {
|
||||
sb.append('-').append("-".repeat(widths[c])).append('-').append('|');
|
||||
@@ -910,8 +910,8 @@ public class PdfMarkdownConverter {
|
||||
}
|
||||
// Only merge a sentence continuation between two text paragraphs, never into/out of a
|
||||
// table.
|
||||
if (!(output.get(output.size() - 1) instanceof String last)
|
||||
|| !(pageItems.get(0) instanceof String first)) {
|
||||
if (!(output.getLast() instanceof String last)
|
||||
|| !(pageItems.getFirst() instanceof String first)) {
|
||||
return;
|
||||
}
|
||||
if (!first.isEmpty()
|
||||
@@ -932,13 +932,13 @@ public class PdfMarkdownConverter {
|
||||
for (Object e : elements) {
|
||||
if (e instanceof TableBlock tb
|
||||
&& !out.isEmpty()
|
||||
&& out.get(out.size() - 1) instanceof TableBlock prev
|
||||
&& out.getLast() instanceof TableBlock prev
|
||||
&& columnsMatch(flatten(prev.rows()), flatten(tb.rows()))) {
|
||||
List<List<Line>> merged = new ArrayList<>(prev.rows());
|
||||
List<List<Line>> tail = tb.rows();
|
||||
if (!tail.isEmpty()
|
||||
&& !prev.rows().isEmpty()
|
||||
&& rowText(tail.get(0)).equals(rowText(prev.rows().get(0)))) {
|
||||
&& rowText(tail.getFirst()).equals(rowText(prev.rows().getFirst()))) {
|
||||
tail = tail.subList(1, tail.size());
|
||||
}
|
||||
merged.addAll(tail);
|
||||
@@ -971,7 +971,7 @@ public class PdfMarkdownConverter {
|
||||
continue;
|
||||
}
|
||||
if (e instanceof TableBlock tb && !tb.rows().isEmpty()) {
|
||||
return rowText(tb.rows().get(0));
|
||||
return rowText(tb.rows().getFirst());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
/**
|
||||
* Thread-scoped correlation id for one automation run — a single pipeline, policy, or AI-workflow
|
||||
* execution over its input file(s).
|
||||
*
|
||||
* <p>Automations dispatch each tool step as a separate internal loopback POST via {@link
|
||||
* InternalApiClient}. The orchestrator opens a run scope around its dispatch loop; {@code
|
||||
* InternalApiClient} reads {@link #current()} and stamps it on every sub-step request as {@link
|
||||
* #RUN_ID_HEADER}. The SaaS PAYG interceptor uses that header so all sub-steps of ONE run group
|
||||
* into a single charge, while two <em>separate</em> runs that happen to touch identical bytes stay
|
||||
* distinct charges (the old content+time-window grouping merged them).
|
||||
*
|
||||
* <p>Sub-steps dispatch synchronously on the orchestrator's own thread (loopback {@code
|
||||
* RestTemplate}), so this ThreadLocal is visible to {@code InternalApiClient}. The id then crosses
|
||||
* to the receiving request thread via the HTTP header — never via this ThreadLocal.
|
||||
*
|
||||
* <p>No-op when the id is absent (a standalone tool call): the interceptor treats a missing run id
|
||||
* as "its own charge", which is exactly what a one-off call should be.
|
||||
*/
|
||||
public final class AutomationRunContext {
|
||||
|
||||
/** Header carrying the run id on internal sub-step dispatches. */
|
||||
public static final String RUN_ID_HEADER = "X-Stirling-Run-Id";
|
||||
|
||||
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
|
||||
|
||||
private AutomationRunContext() {}
|
||||
|
||||
/**
|
||||
* Opens a run scope on the current thread. Returns an {@link AutoCloseable} that restores the
|
||||
* previously-active id (nesting-safe) — use in try-with-resources around the dispatch loop.
|
||||
*/
|
||||
public static Scope open(String runId) {
|
||||
String previous = CURRENT.get();
|
||||
CURRENT.set(runId);
|
||||
return () -> {
|
||||
if (previous == null) {
|
||||
CURRENT.remove();
|
||||
} else {
|
||||
CURRENT.set(previous);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** The run id active on this thread, or {@code null} when not inside a run scope. */
|
||||
public static String current() {
|
||||
return CURRENT.get();
|
||||
}
|
||||
|
||||
/** AutoCloseable whose {@link #close()} declares no checked exception. */
|
||||
public interface Scope extends AutoCloseable {
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
@@ -61,17 +60,6 @@ public class InternalApiClient {
|
||||
*/
|
||||
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
|
||||
|
||||
/**
|
||||
* Header carrying the parent policy's name onto each sub-step dispatch, read from MDC key
|
||||
* {@link #POLICY_NAME_MDC_KEY} (set by the policy runner on the worker thread). Lets the audit
|
||||
* layer attribute a tool step to the policy that ran it, instead of showing it as a bare direct
|
||||
* call.
|
||||
*/
|
||||
public static final String POLICY_NAME_HEADER = "X-Stirling-Policy-Name";
|
||||
|
||||
/** MDC key the policy runner stamps with the running policy's name; forwarded as a header. */
|
||||
public static final String POLICY_NAME_MDC_KEY = "auditPolicyName";
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
private final TempFileManager tempFileManager;
|
||||
@@ -123,27 +111,6 @@ public class InternalApiClient {
|
||||
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
|
||||
// every caller of this dispatcher is an automation surface by design.
|
||||
headers.add(AUTOMATION_HEADER, "true");
|
||||
// Propagate the current automation run id (set by the orchestrator around its dispatch
|
||||
// loop) so the PAYG interceptor groups every sub-step of this one run into a single charge,
|
||||
// and never merges two separate runs that happen to touch identical bytes. Absent → the
|
||||
// receiving call is treated as standalone. See AutomationRunContext.
|
||||
String runId = AutomationRunContext.current();
|
||||
if (runId != null && !runId.isEmpty()) {
|
||||
headers.add(AutomationRunContext.RUN_ID_HEADER, runId);
|
||||
}
|
||||
|
||||
// Forward the parent policy name (set in MDC by the policy runner) so the audited sub-step
|
||||
// ties back to its policy. Single-line, length-capped: it becomes an HTTP header value.
|
||||
String policyName = MDC.get(POLICY_NAME_MDC_KEY);
|
||||
if (policyName != null && !policyName.isBlank()) {
|
||||
String safe = policyName.replaceAll("[\\r\\n]", " ").trim();
|
||||
if (safe.length() > 200) {
|
||||
safe = safe.substring(0, 200);
|
||||
}
|
||||
if (!safe.isEmpty()) {
|
||||
headers.add(POLICY_NAME_HEADER, safe);
|
||||
}
|
||||
}
|
||||
|
||||
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
|
||||
// without this RestTemplate would use urlencoded instead of the multipart the controller
|
||||
@@ -152,7 +119,7 @@ public class InternalApiClient {
|
||||
boolean hasFilePart =
|
||||
body.values().stream()
|
||||
.flatMap(java.util.List::stream)
|
||||
.anyMatch(v -> v instanceof Resource);
|
||||
.anyMatch(Resource.class::isInstance);
|
||||
if (isAiTool && !hasFilePart) {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
}
|
||||
|
||||
@@ -144,10 +144,8 @@ public class TempFileCleanupService {
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)
|
||||
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
|
||||
if (Files.exists(directory)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
@@ -277,21 +275,6 @@ public class TempFileCleanupService {
|
||||
return totalDeletedCount.get();
|
||||
}
|
||||
|
||||
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
|
||||
if (maxAgeMillis <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long lastModified = Files.getLastModifiedTime(directory).toMillis();
|
||||
return (currentTime - lastModified) > maxAgeMillis;
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the system temp directory path based on configuration or system property. */
|
||||
private Path getSystemTempPath() {
|
||||
String systemTempDir =
|
||||
|
||||
@@ -140,20 +140,25 @@ public class ChecksumUtils {
|
||||
|
||||
for (String algorithm : algorithms) {
|
||||
String key = algorithm; // keep original key for output
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
checksums.put(key, new CRC32());
|
||||
break;
|
||||
case "ADLER32":
|
||||
checksums.put(key, new Adler32());
|
||||
break;
|
||||
default:
|
||||
try {
|
||||
// For MessageDigest, pass the original name (case-insensitive per JCA)
|
||||
digests.put(key, MessageDigest.getInstance(algorithm));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Unsupported algorithm: " + algorithm, e);
|
||||
}
|
||||
Object digestOrChecksum =
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32" -> new CRC32();
|
||||
case "ADLER32" -> new Adler32();
|
||||
default -> {
|
||||
try {
|
||||
// For MessageDigest, pass the original name (case-insensitive
|
||||
// per JCA)
|
||||
yield MessageDigest.getInstance(algorithm);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(
|
||||
"Unsupported algorithm: " + algorithm, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (digestOrChecksum instanceof Checksum checksum) {
|
||||
checksums.put(key, checksum);
|
||||
} else {
|
||||
digests.put(key, (MessageDigest) digestOrChecksum);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -751,7 +751,7 @@ public class ExceptionUtils {
|
||||
String targetDescription;
|
||||
if (errorInfo.affectedPages() != null && !errorInfo.affectedPages().isEmpty()) {
|
||||
if (errorInfo.affectedPages().size() == 1) {
|
||||
targetDescription = "page " + errorInfo.affectedPages().get(0);
|
||||
targetDescription = "page " + errorInfo.affectedPages().getFirst();
|
||||
} else {
|
||||
targetDescription =
|
||||
"pages "
|
||||
@@ -848,7 +848,7 @@ public class ExceptionUtils {
|
||||
}
|
||||
|
||||
// Use the first page number, or null if none found
|
||||
Integer pageNumber = affectedPages.isEmpty() ? null : affectedPages.get(0);
|
||||
Integer pageNumber = affectedPages.isEmpty() ? null : affectedPages.getFirst();
|
||||
|
||||
return new GhostscriptErrorInfo(
|
||||
ErrorCode.GHOSTSCRIPT_PAGE_DRAWING,
|
||||
|
||||
@@ -114,7 +114,7 @@ public enum FormFieldTypeSupport {
|
||||
return;
|
||||
}
|
||||
|
||||
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
|
||||
PDAnnotationWidget widget = checkBox.getWidgets().getFirst();
|
||||
|
||||
PDAppearanceCharacteristicsDictionary appearanceChars =
|
||||
widget.getAppearanceCharacteristics();
|
||||
|
||||
@@ -88,28 +88,16 @@ public class FormUtils {
|
||||
* text)
|
||||
*/
|
||||
public String detectFieldType(PDField field) {
|
||||
if (field instanceof PDSignatureField) {
|
||||
return FIELD_TYPE_SIGNATURE;
|
||||
}
|
||||
if (field instanceof PDPushButton) {
|
||||
return FIELD_TYPE_BUTTON;
|
||||
}
|
||||
if (field instanceof PDTextField) {
|
||||
return FIELD_TYPE_TEXT;
|
||||
}
|
||||
if (field instanceof PDCheckBox) {
|
||||
return FIELD_TYPE_CHECKBOX;
|
||||
}
|
||||
if (field instanceof PDComboBox) {
|
||||
return FIELD_TYPE_COMBOBOX;
|
||||
}
|
||||
if (field instanceof PDListBox) {
|
||||
return FIELD_TYPE_LISTBOX;
|
||||
}
|
||||
if (field instanceof PDRadioButton) {
|
||||
return FIELD_TYPE_RADIO;
|
||||
}
|
||||
return FIELD_TYPE_TEXT;
|
||||
return switch (field) {
|
||||
case PDSignatureField ignored -> FIELD_TYPE_SIGNATURE;
|
||||
case PDPushButton ignored -> FIELD_TYPE_BUTTON;
|
||||
case PDTextField ignored -> FIELD_TYPE_TEXT;
|
||||
case PDCheckBox ignored -> FIELD_TYPE_CHECKBOX;
|
||||
case PDComboBox ignored -> FIELD_TYPE_COMBOBOX;
|
||||
case PDListBox ignored -> FIELD_TYPE_LISTBOX;
|
||||
case PDRadioButton ignored -> FIELD_TYPE_RADIO;
|
||||
case null, default -> FIELD_TYPE_TEXT;
|
||||
};
|
||||
}
|
||||
|
||||
public List<FormFieldInfo> extractFormFields(PDDocument document) {
|
||||
@@ -583,22 +571,17 @@ public class FormUtils {
|
||||
continue;
|
||||
}
|
||||
String type = info.type();
|
||||
Object value;
|
||||
switch (type) {
|
||||
case FIELD_TYPE_CHECKBOX:
|
||||
value = isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE;
|
||||
break;
|
||||
case FIELD_TYPE_LISTBOX:
|
||||
if (info.multiSelect()) {
|
||||
value = new ArrayList<>();
|
||||
} else {
|
||||
value = safeDefault(info.value());
|
||||
}
|
||||
break;
|
||||
case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE:
|
||||
continue; // skip non-fillable
|
||||
default:
|
||||
value = safeDefault(info.value());
|
||||
Object value =
|
||||
switch (type) {
|
||||
case FIELD_TYPE_CHECKBOX ->
|
||||
isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE;
|
||||
case FIELD_TYPE_LISTBOX ->
|
||||
info.multiSelect() ? new ArrayList<>() : safeDefault(info.value());
|
||||
case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE -> null;
|
||||
default -> safeDefault(info.value());
|
||||
};
|
||||
if (value == null) {
|
||||
continue; // skip non-fillable
|
||||
}
|
||||
record.put(info.name(), value);
|
||||
}
|
||||
@@ -949,44 +932,44 @@ public class FormUtils {
|
||||
if (selection == null || selection.trim().isEmpty()) return null;
|
||||
List<String> filtered =
|
||||
filterChoiceSelections(List.of(selection), allowedOptions, fieldName);
|
||||
return filtered.isEmpty() ? null : filtered.get(0);
|
||||
return filtered.isEmpty() ? null : filtered.getFirst();
|
||||
}
|
||||
|
||||
private void applyValueToField(PDField field, String value, boolean strict) throws IOException {
|
||||
try {
|
||||
if (field instanceof PDTextField textField) {
|
||||
setTextValue(textField, value);
|
||||
} else if (field instanceof PDCheckBox checkBox) {
|
||||
LinkedHashSet<String> candidateStates = collectCheckBoxStates(checkBox);
|
||||
boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates);
|
||||
try {
|
||||
if (shouldCheck) {
|
||||
checkBox.check();
|
||||
} else {
|
||||
checkBox.unCheck();
|
||||
}
|
||||
} catch (IOException checkProblem) {
|
||||
log.warn(
|
||||
"Failed to set checkbox state for '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
checkProblem.getMessage(),
|
||||
checkProblem);
|
||||
if (strict) {
|
||||
throw checkProblem;
|
||||
switch (field) {
|
||||
case PDTextField textField -> setTextValue(textField, value);
|
||||
case PDCheckBox checkBox -> {
|
||||
LinkedHashSet<String> candidateStates = collectCheckBoxStates(checkBox);
|
||||
boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates);
|
||||
try {
|
||||
if (shouldCheck) {
|
||||
checkBox.check();
|
||||
} else {
|
||||
checkBox.unCheck();
|
||||
}
|
||||
} catch (IOException checkProblem) {
|
||||
log.warn(
|
||||
"Failed to set checkbox state for '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
checkProblem.getMessage(),
|
||||
checkProblem);
|
||||
if (strict) {
|
||||
throw checkProblem;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (field instanceof PDRadioButton radioButton) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
radioButton.setValue(value);
|
||||
case PDRadioButton radioButton -> {
|
||||
if (value != null && !value.isBlank()) {
|
||||
radioButton.setValue(value);
|
||||
}
|
||||
}
|
||||
} else if (field instanceof PDChoice choiceField) {
|
||||
applyChoiceValue(choiceField, value);
|
||||
} else if (field instanceof PDPushButton) {
|
||||
log.debug("Ignore Push button");
|
||||
} else if (field instanceof PDSignatureField) {
|
||||
log.debug("Skipping signature field '{}'", field.getFullyQualifiedName());
|
||||
} else {
|
||||
field.setValue(value != null ? value : "");
|
||||
case PDChoice choiceField -> applyChoiceValue(choiceField, value);
|
||||
case PDPushButton ignored -> log.debug("Ignore Push button");
|
||||
case PDSignatureField ignored ->
|
||||
log.debug("Skipping signature field '{}'", field.getFullyQualifiedName());
|
||||
case null -> log.warn("Attempted to set value on null field");
|
||||
default -> field.setValue(value != null ? value : "");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
@@ -1306,37 +1289,42 @@ public class FormUtils {
|
||||
|
||||
List<String> resolveOptions(PDTerminalField field) {
|
||||
try {
|
||||
if (field instanceof PDChoice choice) {
|
||||
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||
List<String> exportValues = choice.getOptionsExportValues();
|
||||
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||
return switch (field) {
|
||||
case PDChoice choice -> {
|
||||
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||
List<String> exportValues = choice.getOptionsExportValues();
|
||||
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||
|
||||
if (exportValues != null) {
|
||||
exportValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
if (exportValues != null) {
|
||||
exportValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
if (displayValues != null) {
|
||||
displayValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
yield new ArrayList<>(allowed);
|
||||
}
|
||||
if (displayValues != null) {
|
||||
displayValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
case PDRadioButton radio -> {
|
||||
List<String> exports = radio.getExportValues();
|
||||
yield exports != null && !exports.isEmpty()
|
||||
? new ArrayList<>(exports)
|
||||
: Collections.emptyList();
|
||||
}
|
||||
return new ArrayList<>(allowed);
|
||||
} else if (field instanceof PDRadioButton radio) {
|
||||
List<String> exports = radio.getExportValues();
|
||||
if (exports != null && !exports.isEmpty()) {
|
||||
return new ArrayList<>(exports);
|
||||
case PDCheckBox checkBox -> {
|
||||
List<String> exports = checkBox.getExportValues();
|
||||
yield exports != null && !exports.isEmpty()
|
||||
? new ArrayList<>(exports)
|
||||
: Collections.emptyList();
|
||||
}
|
||||
} else if (field instanceof PDCheckBox checkBox) {
|
||||
List<String> exports = checkBox.getExportValues();
|
||||
if (exports != null && !exports.isEmpty()) {
|
||||
return new ArrayList<>(exports);
|
||||
}
|
||||
}
|
||||
case null, default -> Collections.emptyList();
|
||||
};
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to resolve options for field '{}': {}",
|
||||
@@ -1465,7 +1453,7 @@ public class FormUtils {
|
||||
|
||||
// Only check options for choice-type fields (combobox, listbox, radio)
|
||||
if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) {
|
||||
String optionCandidate = cleanLabel(options.get(0));
|
||||
String optionCandidate = cleanLabel(options.getFirst());
|
||||
if (optionCandidate != null && !looksGeneric(optionCandidate)) {
|
||||
return optionCandidate;
|
||||
}
|
||||
@@ -1557,7 +1545,7 @@ public class FormUtils {
|
||||
continue;
|
||||
}
|
||||
|
||||
PDAnnotationWidget widget = widgets.get(0);
|
||||
PDAnnotationWidget widget = widgets.getFirst();
|
||||
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page == null || originalRectangle == null) {
|
||||
@@ -2446,19 +2434,19 @@ public class FormUtils {
|
||||
|
||||
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getPageIndex()
|
||||
? f.getWidgets().getFirst().getPageIndex()
|
||||
: -1;
|
||||
}
|
||||
|
||||
private static float firstWidgetY(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getY()
|
||||
? f.getWidgets().getFirst().getY()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static float firstWidgetX(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getX()
|
||||
? f.getWidgets().getFirst().getX()
|
||||
: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,29 +26,27 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class ImageProcessingUtils {
|
||||
|
||||
static BufferedImage convertColorType(BufferedImage sourceImage, String colorType) {
|
||||
BufferedImage convertedImage;
|
||||
switch (colorType) {
|
||||
case "greyscale":
|
||||
convertedImage =
|
||||
return switch (colorType) {
|
||||
case "greyscale" -> {
|
||||
BufferedImage convertedImage =
|
||||
new BufferedImage(
|
||||
sourceImage.getWidth(),
|
||||
sourceImage.getHeight(),
|
||||
BufferedImage.TYPE_BYTE_GRAY);
|
||||
convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null);
|
||||
break;
|
||||
case "blackwhite":
|
||||
convertedImage =
|
||||
yield convertedImage;
|
||||
}
|
||||
case "blackwhite" -> {
|
||||
BufferedImage convertedImage =
|
||||
new BufferedImage(
|
||||
sourceImage.getWidth(),
|
||||
sourceImage.getHeight(),
|
||||
BufferedImage.TYPE_BYTE_BINARY);
|
||||
convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null);
|
||||
break;
|
||||
default: // full color
|
||||
convertedImage = sourceImage;
|
||||
break;
|
||||
}
|
||||
return convertedImage;
|
||||
yield convertedImage;
|
||||
}
|
||||
default -> sourceImage;
|
||||
};
|
||||
}
|
||||
|
||||
public static byte[] getImageData(BufferedImage image) {
|
||||
|
||||
@@ -330,7 +330,7 @@ public class PDFToFile {
|
||||
|
||||
if (outputFiles.size() == 1) {
|
||||
// Return single output file
|
||||
File outputFile = outputFiles.get(0);
|
||||
File outputFile = outputFiles.getFirst();
|
||||
if ("txt:Text".equals(outputFormat)) {
|
||||
outputFormat = "txt";
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ public class ProcessExecutor {
|
||||
boolean isQpdf =
|
||||
commandToRun != null
|
||||
&& !commandToRun.isEmpty()
|
||||
&& commandToRun.get(0).contains("qpdf");
|
||||
&& commandToRun.getFirst().contains("qpdf");
|
||||
|
||||
if (!outputLines.isEmpty()) {
|
||||
String outputMessage = String.join("\n", outputLines);
|
||||
@@ -370,7 +370,7 @@ public class ProcessExecutor {
|
||||
}
|
||||
|
||||
// Check if this is a UNO conversion by looking for unoconvert executable
|
||||
String executable = command.get(0);
|
||||
String executable = command.getFirst();
|
||||
if (executable != null) {
|
||||
// Extract basename from path for matching
|
||||
String basename = executable;
|
||||
@@ -504,7 +504,7 @@ public class ProcessExecutor {
|
||||
}
|
||||
|
||||
// Validate executable (first argument)
|
||||
String executable = command.get(0);
|
||||
String executable = command.getFirst();
|
||||
if (executable == null || executable.isBlank()) {
|
||||
throw new IllegalArgumentException("Command executable must not be empty");
|
||||
}
|
||||
|
||||
@@ -155,7 +155,6 @@ public class TempFileManager {
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
try {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
log.debug("Deleted temp directory: {}", directory.toString());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
|
||||
|
||||
@@ -85,18 +85,6 @@ public class TempFileRegistry {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a temporary directory from the registry.
|
||||
*
|
||||
* @param directory The directory to unregister
|
||||
*/
|
||||
public void unregisterDirectory(Path directory) {
|
||||
if (directory != null) {
|
||||
tempDirectories.remove(directory);
|
||||
log.debug("Unregistered temp directory: {}", directory.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a third-party temporary file that requires special handling.
|
||||
*
|
||||
|
||||
@@ -114,7 +114,7 @@ public class YamlHelper {
|
||||
|
||||
for (NodeTuple tuple : mappingNode.getValue()) {
|
||||
ScalarNode keyNode = (tuple.getKeyNode() instanceof ScalarNode sk) ? sk : null;
|
||||
if (keyNode == null || !keyNode.getValue().equals(keys.get(0))) {
|
||||
if (keyNode == null || !keyNode.getValue().equals(keys.getFirst())) {
|
||||
updatedTuples.add(tuple);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -176,13 +176,11 @@ class TempFileCleanupServiceMoreTest {
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes stale registered temp directories and reports counts")
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
void deletesRegisteredDirectories() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Files.setLastModifiedTime(
|
||||
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
@@ -195,22 +193,6 @@ class TempFileCleanupServiceMoreTest {
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps a fresh registered temp directory")
|
||||
void keepsFreshRegisteredDirectory() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
|
||||
assertThat(Files.exists(regDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips a registered directory that no longer exists")
|
||||
void skipsMissingRegisteredDirectory() {
|
||||
|
||||
@@ -721,7 +721,7 @@ class PDFToFileTest {
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> args = invocation.getArgument(0);
|
||||
String outputPath = args.get(args.size() - 1);
|
||||
String outputPath = args.getLast();
|
||||
Files.write(Path.of(outputPath), "Fake DOCX content".getBytes());
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.servlet.MultipartConfigFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -22,7 +21,11 @@ import stirling.software.SPDF.controller.web.UploadLimitService;
|
||||
@Slf4j
|
||||
public class MultipartConfiguration {
|
||||
|
||||
@Autowired private UploadLimitService uploadLimitService;
|
||||
private final UploadLimitService uploadLimitService;
|
||||
|
||||
public MultipartConfiguration(UploadLimitService uploadLimitService) {
|
||||
this.uploadLimitService = uploadLimitService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment
|
||||
|
||||
@@ -294,24 +294,25 @@ public class BookletImpositionController {
|
||||
|
||||
// Apply rotation if needed (rotate about origin), then translate to keep in cell
|
||||
switch (rot) {
|
||||
case 90:
|
||||
case 90 -> {
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0));
|
||||
// After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0,
|
||||
// r.getWidth()]
|
||||
cs.transform(Matrix.getTranslateInstance(0, -r.getWidth()));
|
||||
break;
|
||||
case 180:
|
||||
}
|
||||
case 180 -> {
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0));
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight()));
|
||||
break;
|
||||
case 270:
|
||||
}
|
||||
case 270 -> {
|
||||
cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0));
|
||||
// After 270° CCW, the content spans x in [0, r.getHeight()] and y in
|
||||
// [-r.getWidth(), 0]
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0));
|
||||
break;
|
||||
default:
|
||||
}
|
||||
default -> {
|
||||
// 0°: no-op
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse LayerUtility passed from caller
|
||||
|
||||
@@ -9,7 +9,6 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
@@ -58,8 +57,6 @@ import stirling.software.jpdfium.doc.PdfBookmarkEditor.BookmarkTree;
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class MergeController {
|
||||
|
||||
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -161,30 +158,6 @@ public class MergeController {
|
||||
};
|
||||
}
|
||||
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
if (inside.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
|
||||
|
||||
@@ -125,17 +125,14 @@ public class UIDataController {
|
||||
pipelineConfigs.add(content);
|
||||
}
|
||||
|
||||
for (String config : pipelineConfigs) {
|
||||
for (int i = 0; i < pipelineConfigs.size(); i++) {
|
||||
String config = pipelineConfigs.get(i);
|
||||
Map<String, Object> jsonContent =
|
||||
objectMapper.readValue(
|
||||
config, new TypeReference<Map<String, Object>>() {});
|
||||
String name = (String) jsonContent.get("name");
|
||||
if (name == null || name.length() < 1) {
|
||||
String filename =
|
||||
jsonFiles
|
||||
.get(pipelineConfigs.indexOf(config))
|
||||
.getFileName()
|
||||
.toString();
|
||||
String filename = jsonFiles.get(i).getFileName().toString();
|
||||
name = filename.substring(0, filename.lastIndexOf('.'));
|
||||
}
|
||||
Map<String, String> configWithName = new HashMap<>();
|
||||
@@ -301,20 +298,14 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
private static String getFormatFromExtension(String extension) {
|
||||
switch (extension) {
|
||||
case "ttf":
|
||||
return "truetype";
|
||||
case "woff":
|
||||
return "woff";
|
||||
case "woff2":
|
||||
return "woff2";
|
||||
case "eot":
|
||||
return "embedded-opentype";
|
||||
case "svg":
|
||||
return "svg";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
return switch (extension) {
|
||||
case "ttf" -> "truetype";
|
||||
case "woff" -> "woff";
|
||||
case "woff2" -> "woff2";
|
||||
case "eot" -> "embedded-opentype";
|
||||
case "svg" -> "svg";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ public class ConvertImgPDFController {
|
||||
}
|
||||
|
||||
if (webpFiles.size() == 1) {
|
||||
Path webpFilePath = webpFiles.get(0);
|
||||
Path webpFilePath = webpFiles.getFirst();
|
||||
byte[] webpBytes = Files.readAllBytes(webpFilePath);
|
||||
Files.deleteIfExists(tempFile);
|
||||
tempFile = null;
|
||||
|
||||
@@ -147,7 +147,7 @@ public class ConvertSvgToPDF {
|
||||
String outputFilename =
|
||||
filenames.isEmpty()
|
||||
? "combined_svgs.pdf"
|
||||
: GeneralUtils.generateFilename(filenames.get(0), "_combined.pdf");
|
||||
: GeneralUtils.generateFilename(filenames.getFirst(), "_combined.pdf");
|
||||
|
||||
log.info("Successfully combined {} SVGs into single PDF", sanitizedSvgs.size());
|
||||
|
||||
@@ -203,7 +203,7 @@ public class ConvertSvgToPDF {
|
||||
|
||||
try {
|
||||
if (convertedPdfs.size() == 1) {
|
||||
ConvertedPdf pdf = convertedPdfs.get(0);
|
||||
ConvertedPdf pdf = convertedPdfs.getFirst();
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try {
|
||||
Files.write(tempOut.getPath(), pdf.content);
|
||||
@@ -218,7 +218,7 @@ public class ConvertSvgToPDF {
|
||||
filenames.isEmpty()
|
||||
? "converted_svgs.zip"
|
||||
: GeneralUtils.generateFilename(
|
||||
filenames.get(0), "_converted_svgs.zip");
|
||||
filenames.getFirst(), "_converted_svgs.zip");
|
||||
TempFile zipFile = createZipFromPdfs(convertedPdfs);
|
||||
return WebResponseUtils.zipFileToWebResponse(zipFile, zipFilename);
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -82,7 +82,7 @@ public class ExtractCSVController {
|
||||
if (csvEntries.isEmpty()) {
|
||||
return ResponseEntity.noContent().build();
|
||||
} else if (csvEntries.size() == 1) {
|
||||
return createCsvResponse(csvEntries.get(0), baseName);
|
||||
return createCsvResponse(csvEntries.getFirst(), baseName);
|
||||
} else {
|
||||
return createZipResponse(csvEntries, baseName);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,9 @@ public class AutoRenameController {
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.getFirst().text;
|
||||
|
||||
return title != null
|
||||
? title
|
||||
|
||||
@@ -333,7 +333,7 @@ public class AutoSplitPdfController {
|
||||
}
|
||||
|
||||
if (!splitDocuments.isEmpty() && !isValidQrCode) {
|
||||
splitDocuments.get(splitDocuments.size() - 1).addPage(document.getPage(page));
|
||||
splitDocuments.getLast().addPage(document.getPage(page));
|
||||
} else if (page == 0) {
|
||||
PDDocument firstDocument = new PDDocument();
|
||||
firstDocument.addPage(document.getPage(page));
|
||||
|
||||
@@ -267,7 +267,7 @@ public class CompressController {
|
||||
if (references.isEmpty()) continue;
|
||||
|
||||
// Get the first instance of this image
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.getFirst());
|
||||
|
||||
// Track original size
|
||||
int originalSize = (int) originalImage.getCOSObject().getLength();
|
||||
@@ -1167,7 +1167,7 @@ public class CompressController {
|
||||
List<ImageReference> references = entry.getValue();
|
||||
if (references.isEmpty()) continue;
|
||||
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.getFirst());
|
||||
|
||||
int originalSize = (int) originalImage.getCOSObject().getLength();
|
||||
stats.totalOriginalBytes += originalSize;
|
||||
|
||||
@@ -211,7 +211,7 @@ public class ExtractImageScansController {
|
||||
} else {
|
||||
|
||||
// Return the processed image as a response
|
||||
byte[] imageBytes = processedImageBytes.get(0);
|
||||
byte[] imageBytes = processedImageBytes.getFirst();
|
||||
finalOutput = tempFileManager.createManagedTempFile(".png");
|
||||
try (OutputStream out = Files.newOutputStream(finalOutput.getPath())) {
|
||||
out.write(imageBytes);
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -28,7 +27,6 @@ import stirling.software.SPDF.model.PipelineConfig;
|
||||
import stirling.software.SPDF.model.PipelineOperation;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.service.AutomationRunContext;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
@@ -73,17 +71,6 @@ public class PipelineProcessor {
|
||||
|
||||
PipelineResult runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfig config)
|
||||
throws Exception {
|
||||
// One pipeline execution = one automation run. Scope a run id so every tool sub-step
|
||||
// dispatched via InternalApiClient groups into a single charge on the SaaS billing side
|
||||
// (see AutomationRunContext); pipeline steps run synchronously on this thread.
|
||||
try (AutomationRunContext.Scope ignored =
|
||||
AutomationRunContext.open(UUID.randomUUID().toString())) {
|
||||
return runPipelineAgainstFilesInternal(outputFiles, config);
|
||||
}
|
||||
}
|
||||
|
||||
private PipelineResult runPipelineAgainstFilesInternal(
|
||||
List<Resource> outputFiles, PipelineConfig config) throws Exception {
|
||||
PipelineResult result = new PipelineResult();
|
||||
|
||||
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
|
||||
|
||||
@@ -404,7 +404,7 @@ public class CertSignController {
|
||||
PDAcroForm acroForm = new PDAcroForm(doc);
|
||||
doc.getDocumentCatalog().setAcroForm(acroForm);
|
||||
PDSignatureField signatureField = new PDSignatureField(acroForm);
|
||||
PDAnnotationWidget widget = signatureField.getWidgets().get(0);
|
||||
PDAnnotationWidget widget = signatureField.getWidgets().getFirst();
|
||||
List<PDField> acroFormFields = acroForm.getFields();
|
||||
acroForm.setSignaturesExist(true);
|
||||
acroForm.setAppendOnly(true);
|
||||
|
||||
@@ -136,17 +136,11 @@ public class RedactController {
|
||||
+ "Users can provide text patterns to redact, with options for regex and whole word matching. "
|
||||
+ "Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
|
||||
String rawListOfText = request.getListOfText();
|
||||
String[] listOfText = request.getListOfText().split("\n");
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
|
||||
if (rawListOfText == null || rawListOfText.trim().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
String[] listOfText = rawListOfText.split("\n");
|
||||
if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) {
|
||||
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
@@ -634,7 +634,7 @@ class RedactExecuteService {
|
||||
PageColumnLayout layout =
|
||||
PageColumnLayout.fromLineBoxes(extractor.getLineBoxes(), pageWidth);
|
||||
if (layout.columnCount() > 1) {
|
||||
float[] g = layout.gutters().get(0);
|
||||
float[] g = layout.gutters().getFirst();
|
||||
log.info(
|
||||
"[redact/execute] page {} layout: 2 cols, gutter x=[{}, {}]",
|
||||
pageIdx + 1,
|
||||
|
||||
@@ -60,7 +60,7 @@ public class RemoveCertSignController {
|
||||
// Remove signature fields safely
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(field -> field instanceof PDSignatureField)
|
||||
.filter(PDSignatureField.class::isInstance)
|
||||
.toList();
|
||||
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -13,7 +12,11 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
@Slf4j
|
||||
public class UploadLimitService {
|
||||
|
||||
@Autowired private ApplicationProperties applicationProperties;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public UploadLimitService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public long getUploadLimit() {
|
||||
String raw =
|
||||
|
||||
@@ -2688,7 +2688,7 @@ public class PdfJsonConversionService {
|
||||
|
||||
// Find which page the field is on
|
||||
PDAnnotationWidget widget =
|
||||
field.getWidgets().isEmpty() ? null : field.getWidgets().get(0);
|
||||
field.getWidgets().isEmpty() ? null : field.getWidgets().getFirst();
|
||||
if (widget != null) {
|
||||
PDPage fieldPage = widget.getPage();
|
||||
if (fieldPage != null) {
|
||||
@@ -3164,7 +3164,7 @@ public class PdfJsonConversionService {
|
||||
&& imageObjectNames != null
|
||||
&& !imageObjectNames.isEmpty()
|
||||
&& !targetTokens.isEmpty()) {
|
||||
Object previous = targetTokens.get(targetTokens.size() - 1);
|
||||
Object previous = targetTokens.getLast();
|
||||
if (previous instanceof COSName cosName
|
||||
&& imageObjectNames.contains(cosName.getName())) {
|
||||
targetTokens.remove(targetTokens.size() - 1);
|
||||
@@ -5246,7 +5246,7 @@ public class PdfJsonConversionService {
|
||||
throws IOException {
|
||||
if (OperatorName.DRAW_OBJECT.equals(operator.getName())
|
||||
&& !operands.isEmpty()
|
||||
&& operands.get(0) instanceof COSName name) {
|
||||
&& operands.getFirst() instanceof COSName name) {
|
||||
currentXObjectName = name;
|
||||
}
|
||||
super.processOperator(operator, operands);
|
||||
|
||||
@@ -420,7 +420,7 @@ public class PdfJsonImageService {
|
||||
throws IOException {
|
||||
if (OperatorName.DRAW_OBJECT.equals(operator.getName())
|
||||
&& !operands.isEmpty()
|
||||
&& operands.get(0) instanceof COSName name) {
|
||||
&& operands.getFirst() instanceof COSName name) {
|
||||
currentXObjectName = name;
|
||||
}
|
||||
super.processOperator(operator, operands);
|
||||
|
||||
@@ -137,7 +137,7 @@ public class JobController {
|
||||
if (result.hasFiles() && !result.hasMultipleFiles()) {
|
||||
try {
|
||||
List<ResultFile> files = result.getAllResultFiles();
|
||||
ResultFile singleFile = files.get(0);
|
||||
ResultFile singleFile = files.getFirst();
|
||||
|
||||
byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId());
|
||||
return ResponseEntity.ok()
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -1 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24"><symbol id="icon-redact-auto" viewBox="0 0 24 24"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect width="24" height="24" style="fill:none"/><g><path d="M17.541,15.64258a.91793.91793,0,0,1,.55469-.18555h1.1084a.91586.91586,0,0,1,.55469.18555,1.30889,1.30889,0,0,1,.40429.499,1.57206,1.57206,0,0,1,.15039.68457v5.47754H19.2041V20.21094H18.0957v2.09277H16.9873V16.82617a1.55843,1.55843,0,0,1,.15039-.68457A1.2979,1.2979,0,0,1,17.541,15.64258Zm1.66309,1.10547H18.0957v2.17187h1.1084Z" style="fill:currentColor"/><path d="M5.68653,22.30351a2.00588,2.00588,0,0,1-2-2v-16A1.92585,1.92585,0,0,1,4.274,2.891a1.92585,1.92585,0,0,1,1.4125-.5875h8l6,6v5.66931h-2V9.30351h-5v-5h-7v16h9.74021v2Z" style="fill:currentColor"/><rect width="4.338" height=".795" x="7.698" y="10.432" style="fill:currentColor"/><rect width="7.312" height="1.213" x="7.698" y="12.169" style="fill:currentColor"/><rect width="7.312" height="1.213" x="7.698" y="17.146" style="fill:currentColor"/><rect width="7.312" height=".575" x="7.698" y="14.324" style="fill:currentColor"/><rect width="5.256" height=".448" x="7.698" y="15.798" style="fill:currentColor"/></g></g></g></symbol></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
|
||||
<symbol id="icon-redact-auto" viewBox="0 0 24 24"> <g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect width="24" height="24" style="fill: none"/>
|
||||
<g>
|
||||
<path d="M17.541,15.64258a.91793.91793,0,0,1,.55469-.18555h1.1084a.91586.91586,0,0,1,.55469.18555,1.30889,1.30889,0,0,1,.40429.499,1.57206,1.57206,0,0,1,.15039.68457v5.47754H19.2041V20.21094H18.0957v2.09277H16.9873V16.82617a1.55843,1.55843,0,0,1,.15039-.68457A1.2979,1.2979,0,0,1,17.541,15.64258Zm1.66309,1.10547H18.0957v2.17187h1.1084Z" style="fill: currentColor"/>
|
||||
<path d="M5.68653,22.30351a2.00588,2.00588,0,0,1-2-2v-16A1.92585,1.92585,0,0,1,4.274,2.891a1.92585,1.92585,0,0,1,1.4125-.5875h8l6,6v5.66931h-2V9.30351h-5v-5h-7v16h9.74021v2Z" style="fill: currentColor"/>
|
||||
<rect x="7.69809" y="10.43189" width="4.33778" height="0.79501" style="fill: currentColor"/>
|
||||
<rect x="7.69809" y="12.16889" width="7.31192" height="1.21288" style="fill: currentColor"/>
|
||||
<rect x="7.69809" y="17.14555" width="7.31192" height="1.21288" style="fill: currentColor"/>
|
||||
<rect x="7.69809" y="14.32375" width="7.31192" height="0.57517" style="fill: currentColor"/>
|
||||
<rect x="7.69809" y="15.79848" width="5.25578" height="0.4475" style="fill: currentColor"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="800" version="1.1" viewBox="0 0 512 512"><title>rename</title><symbol id="icon-rename" viewBox="0 0 512 512"><g id="Page-1" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="Combined-Shape" fill="currentColor"><path d="M362.666667,1.42108547e-14 L362.666667,21.3333333 L320,21.333 L320,362.666 L362.666667,362.666667 L362.666667,384 L320,383.999 L320,384 L298.666667,384 L298.666,383.999 L256,384 L256,362.666667 L298.666,362.666 L298.666,21.333 L256,21.3333333 L256,1.42108547e-14 L362.666667,1.42108547e-14 Z M426.666667,64 L426.666667,320 L341.333333,320 L341.333333,277.333333 L384,277.333333 L384,106.666667 L341.333333,106.666667 L341.333333,64 L426.666667,64 Z M277.333333,64 L277.333333,320 L3.55271368e-14,320 L3.55271368e-14,64 L277.333333,64 Z M179.2,89.6 L149.333333,89.6 L149.333333,234.666667 C149.333333,248 148.5,256.333333 147.875,264.354167 L147.792993,265.422171 L147.792993,265.422171 L147.714003,266.48894 C147.417695,270.579012 147.2,274.696296 147.2,279.466667 L147.2,279.466667 L177.066667,279.466667 L177.066667,260.266667 C184.941497,273.926888 199.708077,282.130544 215.466667,281.6 C229.540046,281.805757 242.921593,275.508559 251.733333,264.533333 C263.162478,248.989677 269.832496,230.461848 270.933333,211.2 C270.933333,170.666667 249.6,142.933333 217.6,142.933333 C202.507405,142.999748 188.308689,150.099106 179.2,162.133333 L179.2,162.133333 L179.2,89.6 Z M119.466667,162.133333 C107.961824,149.843793 91.4322333,143.546807 74.6666667,145.066667 C57.6785115,144.485924 40.8138255,148.15216 25.6,155.733333 L25.6,155.733333 L34.1333333,177.066667 C45.3979052,171.147831 57.7246848,167.522308 70.4,166.4 C78.5613135,165.511423 86.6853595,168.371259 92.4903835,174.176283 C98.2954074,179.981307 101.155244,188.105353 100.266667,196.266667 L100.266667,196.266667 L100.266667,198.4 L78.9333333,198.4 C65.8181975,197.679203 52.705771,199.864608 40.5333333,204.8 C26.2806563,210.950309 17.6507691,225.621117 19.2,241.066667 C19.0625857,252.057651 23.6679763,262.574827 31.8381493,269.927982 C40.0083223,277.281138 50.9508304,280.757072 61.8666667,279.466667 C77.2795695,280.291768 92.2192911,274.001359 102.4,262.4 L102.4,262.4 L102.4,277.333333 L130.133333,277.333333 C128.292479,266.054406 127.577851,254.620365 128,243.2 L128,243.2 L128,204.8 C129.999138,190.023932 126.995128,175.003882 119.466667,162.133333 Z M98.1333333,213.333333 L98.1333333,238.933333 C92.082572,249.988391 80.836024,257.218314 68.2666667,258.133333 C63.0655139,258.520242 57.9538681,256.621996 54.2659359,252.934064 C50.5780036,249.246132 48.6797582,244.134486 49.0666667,238.933333 C49.0666667,224 59.7333333,215.466667 85.3333333,213.333333 L85.3333333,213.333333 L98.1333333,213.333333 Z M209.066667,166.4 C226.133333,166.4 238.933333,183.466667 238.933333,211.2 C238.933333,238.933333 228.266667,256 211.2,256 C197.298049,255.69869 184.825037,247.383349 179.2,234.666667 L179.2,234.666667 L179.2,187.733333 C185.154203,176.240507 196.263981,168.304951 209.066667,166.4 Z" transform="translate(42.666667, 64.000000)"/></g></g></symbol></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>rename</title>
|
||||
<symbol id="icon-rename" viewBox="0 0 512 512">
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Combined-Shape" fill="currentColor" transform="translate(42.666667, 64.000000)">
|
||||
<path d="M362.666667,1.42108547e-14 L362.666667,21.3333333 L320,21.333 L320,362.666 L362.666667,362.666667 L362.666667,384 L320,383.999 L320,384 L298.666667,384 L298.666,383.999 L256,384 L256,362.666667 L298.666,362.666 L298.666,21.333 L256,21.3333333 L256,1.42108547e-14 L362.666667,1.42108547e-14 Z M426.666667,64 L426.666667,320 L341.333333,320 L341.333333,277.333333 L384,277.333333 L384,106.666667 L341.333333,106.666667 L341.333333,64 L426.666667,64 Z M277.333333,64 L277.333333,320 L3.55271368e-14,320 L3.55271368e-14,64 L277.333333,64 Z M179.2,89.6 L149.333333,89.6 L149.333333,234.666667 C149.333333,248 148.5,256.333333 147.875,264.354167 L147.792993,265.422171 L147.792993,265.422171 L147.714003,266.48894 C147.417695,270.579012 147.2,274.696296 147.2,279.466667 L147.2,279.466667 L177.066667,279.466667 L177.066667,260.266667 C184.941497,273.926888 199.708077,282.130544 215.466667,281.6 C229.540046,281.805757 242.921593,275.508559 251.733333,264.533333 C263.162478,248.989677 269.832496,230.461848 270.933333,211.2 C270.933333,170.666667 249.6,142.933333 217.6,142.933333 C202.507405,142.999748 188.308689,150.099106 179.2,162.133333 L179.2,162.133333 L179.2,89.6 Z M119.466667,162.133333 C107.961824,149.843793 91.4322333,143.546807 74.6666667,145.066667 C57.6785115,144.485924 40.8138255,148.15216 25.6,155.733333 L25.6,155.733333 L34.1333333,177.066667 C45.3979052,171.147831 57.7246848,167.522308 70.4,166.4 C78.5613135,165.511423 86.6853595,168.371259 92.4903835,174.176283 C98.2954074,179.981307 101.155244,188.105353 100.266667,196.266667 L100.266667,196.266667 L100.266667,198.4 L78.9333333,198.4 C65.8181975,197.679203 52.705771,199.864608 40.5333333,204.8 C26.2806563,210.950309 17.6507691,225.621117 19.2,241.066667 C19.0625857,252.057651 23.6679763,262.574827 31.8381493,269.927982 C40.0083223,277.281138 50.9508304,280.757072 61.8666667,279.466667 C77.2795695,280.291768 92.2192911,274.001359 102.4,262.4 L102.4,262.4 L102.4,277.333333 L130.133333,277.333333 C128.292479,266.054406 127.577851,254.620365 128,243.2 L128,243.2 L128,204.8 C129.999138,190.023932 126.995128,175.003882 119.466667,162.133333 Z M98.1333333,213.333333 L98.1333333,238.933333 C92.082572,249.988391 80.836024,257.218314 68.2666667,258.133333 C63.0655139,258.520242 57.9538681,256.621996 54.2659359,252.934064 C50.5780036,249.246132 48.6797582,244.134486 49.0666667,238.933333 C49.0666667,224 59.7333333,215.466667 85.3333333,213.333333 L85.3333333,213.333333 L98.1333333,213.333333 Z M209.066667,166.4 C226.133333,166.4 238.933333,183.466667 238.933333,211.2 C238.933333,238.933333 228.266667,256 211.2,256 C197.298049,255.69869 184.825037,247.383349 179.2,234.666667 L179.2,234.666667 L179.2,187.733333 C185.154203,176.240507 196.263981,168.304951 209.066667,166.4 Z">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -4,8 +4,6 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -24,10 +22,7 @@ class MultipartConfigurationTest {
|
||||
void setUp() throws Exception {
|
||||
// Manually constructed config with a mocked service, so Spring env overrides do not apply.
|
||||
uploadLimitService = mock(UploadLimitService.class);
|
||||
configuration = new MultipartConfiguration();
|
||||
Field field = MultipartConfiguration.class.getDeclaredField("uploadLimitService");
|
||||
field.setAccessible(true);
|
||||
field.set(configuration, uploadLimitService);
|
||||
configuration = new MultipartConfiguration(uploadLimitService);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -84,12 +84,6 @@ class MergeControllerGapTest {
|
||||
return (MultipartFile[]) m.invoke(null, files, fileOrder);
|
||||
}
|
||||
|
||||
private String[] parseClientFileIds(String value) throws Exception {
|
||||
Method m = MergeController.class.getDeclaredMethod("parseClientFileIds", String.class);
|
||||
m.setAccessible(true);
|
||||
return (String[]) m.invoke(mergeController, value);
|
||||
}
|
||||
|
||||
private long getPdfDateTimeSafe(MultipartFile file) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod("getPdfDateTimeSafe", MultipartFile.class);
|
||||
@@ -331,58 +325,6 @@ class MergeControllerGapTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseClientFileIds -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseClientFileIds")
|
||||
class ParseClientFileIds {
|
||||
|
||||
@Test
|
||||
@DisplayName("null input returns empty array")
|
||||
void nullReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(null).length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank input returns empty array")
|
||||
void blankReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(" ").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty JSON array returns empty array")
|
||||
void emptyArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("[]").length);
|
||||
assertEquals(0, parseClientFileIds("[ ]").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-array text returns empty array")
|
||||
void nonArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("not-an-array").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses quoted, comma-separated ids and strips surrounding quotes")
|
||||
void parsesQuotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[\"id1\", \"id2\",\"id3\"]");
|
||||
assertArrayEquals(new String[] {"id1", "id2", "id3"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses unquoted ids as-is after trimming")
|
||||
void parsesUnquotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[a, b , c]");
|
||||
assertArrayEquals(new String[] {"a", "b", "c"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single element array yields a one-element result")
|
||||
void singleElement() throws Exception {
|
||||
assertArrayEquals(new String[] {"only"}, parseClientFileIds("[\"only\"]"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reorderFilesByProvidedOrder ----------------------------------------
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
class UIDataControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Test
|
||||
void getPipelineData_usesEachSourceFilenameWhenJsonContentIsIdentical() throws Exception {
|
||||
Path configDir = tempDir.resolve("defaultWebUIConfigs");
|
||||
Files.createDirectories(configDir);
|
||||
|
||||
String configJson = "{\"steps\":[]}";
|
||||
Files.writeString(configDir.resolve("first-config.json"), configJson);
|
||||
Files.writeString(configDir.resolve("second-config.json"), configJson);
|
||||
|
||||
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
|
||||
SharedSignatureService signatureService = mock(SharedSignatureService.class);
|
||||
RuntimePathConfig runtimePathConfig = mock(RuntimePathConfig.class);
|
||||
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(configDir.toString());
|
||||
|
||||
UIDataController controller =
|
||||
new UIDataController(
|
||||
applicationProperties,
|
||||
signatureService,
|
||||
null,
|
||||
new DefaultResourceLoader(),
|
||||
runtimePathConfig,
|
||||
new ObjectMapper());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response = controller.getPipelineData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
|
||||
List<Map<String, String>> configsWithNames = body.getPipelineConfigsWithNames();
|
||||
assertThat(configsWithNames).hasSize(2);
|
||||
assertThat(configsWithNames)
|
||||
.extracting(entry -> entry.get("name"))
|
||||
.containsExactlyInAnyOrder("first-config", "second-config");
|
||||
assertThat(configsWithNames)
|
||||
.extracting(entry -> entry.get("json"))
|
||||
.containsOnly(configJson);
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ class ConvertOfficeControllerTest {
|
||||
inv -> {
|
||||
// unoconvert writes directly to the output path (last arg)
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path out = Path.of(command.get(command.size() - 1));
|
||||
Path out = Path.of(command.getLast());
|
||||
Files.writeString(out, "%PDF-1.4 produced");
|
||||
return result;
|
||||
});
|
||||
@@ -239,7 +239,7 @@ class ConvertOfficeControllerTest {
|
||||
inv -> {
|
||||
// soffice writes <basename>.pdf into the --outdir (workDir)
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.writeString(out, "%PDF soffice");
|
||||
return result;
|
||||
@@ -311,7 +311,7 @@ class ConvertOfficeControllerTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.write(out, new byte[0]);
|
||||
return result;
|
||||
@@ -344,7 +344,7 @@ class ConvertOfficeControllerTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path out = inputPath.getParent().resolve("page.pdf");
|
||||
Files.writeString(out, "%PDF html");
|
||||
return result;
|
||||
@@ -398,7 +398,7 @@ class ConvertOfficeControllerTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.writeString(out, "%PDF produced");
|
||||
return result;
|
||||
|
||||
@@ -197,7 +197,7 @@ class ConvertPDFToPDFAMoreTest {
|
||||
// qpdf normalize/clean writes its (last-arg) output file
|
||||
if (command.contains("--normalize-content=y")) {
|
||||
// qpdf produced file is the last argument
|
||||
Path out = Path.of(command.get(command.size() - 1));
|
||||
Path out = Path.of(command.getLast());
|
||||
Files.write(out, simplePdfBytes());
|
||||
}
|
||||
return okResult;
|
||||
|
||||
@@ -262,7 +262,7 @@ class ConvertPdfToVideoControllerTest {
|
||||
assertTrue(command.contains("+faststart"));
|
||||
assertFalse(command.contains("libvpx-vp9"));
|
||||
// Output path is always the last argument.
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
assertEquals(backing.getAbsolutePath(), command.getLast());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -278,7 +278,7 @@ class ConvertPdfToVideoControllerTest {
|
||||
assertTrue(command.contains("30"));
|
||||
assertFalse(command.contains("libx264"));
|
||||
assertFalse(command.contains("+faststart"));
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
assertEquals(backing.getAbsolutePath(), command.getLast());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -195,7 +195,7 @@ class CompressControllerMoreTest {
|
||||
|
||||
// The qpdf output path is the last argument of the command.
|
||||
private static Path qpdfOutputPath(List<String> command) {
|
||||
return Path.of(command.get(command.size() - 1));
|
||||
return Path.of(command.getLast());
|
||||
}
|
||||
|
||||
/** Stub gs to write a valid PDF to its output file and report success. */
|
||||
|
||||
@@ -162,7 +162,7 @@ class RemoveImagesControllerTest {
|
||||
/** Counts every PDImageXObject reachable through page + nested form resources. */
|
||||
private int countImagesInSavedOutput() throws IOException {
|
||||
assertFalse(savedTempFiles.isEmpty(), "expected the controller to create a temp file");
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
File out = savedTempFiles.getLast();
|
||||
try (PDDocument doc = Loader.loadPDF(out)) {
|
||||
int count = 0;
|
||||
for (PDPage page : doc.getPages()) {
|
||||
@@ -245,7 +245,7 @@ class RemoveImagesControllerTest {
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
// page count must be preserved
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
File out = savedTempFiles.getLast();
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertEquals(3, result.getNumberOfPages());
|
||||
}
|
||||
|
||||
@@ -98,12 +98,8 @@ class RepairControllerMoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a valid PDF to the path at the given command index, mimicking a successful tool run.
|
||||
*/
|
||||
private static void writeValidPdfTo(List<String> command, int outputPathIndex)
|
||||
throws Exception {
|
||||
Path out = Path.of(command.get(outputPathIndex));
|
||||
/** Writes a valid PDF to the given output path, mimicking a successful tool run. */
|
||||
private static void writeValidPdfTo(Path out) throws Exception {
|
||||
byte[] pdf = buildPdfBytes(1);
|
||||
Files.write(out, pdf);
|
||||
}
|
||||
@@ -133,7 +129,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(cmd, 2);
|
||||
writeValidPdfTo(Path.of(cmd.get(2)));
|
||||
return okResult;
|
||||
});
|
||||
|
||||
@@ -176,7 +172,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(cmd, cmd.size() - 1);
|
||||
writeValidPdfTo(Path.of(cmd.getLast()));
|
||||
return okResult;
|
||||
});
|
||||
|
||||
@@ -216,7 +212,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(cmd, cmd.size() - 1);
|
||||
writeValidPdfTo(Path.of(cmd.getLast()));
|
||||
return okResult;
|
||||
});
|
||||
|
||||
@@ -256,7 +252,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(cmd, cmd.size() - 1);
|
||||
writeValidPdfTo(Path.of(cmd.getLast()));
|
||||
return okResult;
|
||||
});
|
||||
|
||||
|
||||
@@ -299,18 +299,6 @@ class RedactControllerMoreTest {
|
||||
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null listOfText throws an illegal-argument error before any load")
|
||||
void nullPatternsThrows() throws Exception {
|
||||
RedactPdfRequest request = new RedactPdfRequest();
|
||||
request.setFileInput(pdfFile(new byte[] {1, 2, 3}));
|
||||
request.setListOfText(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.redactPdf(request))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null file input is reported as a failure")
|
||||
void nullFileThrows() {
|
||||
|
||||
@@ -49,15 +49,7 @@ class UploadLimitServiceTest {
|
||||
systemProps = mock(ApplicationProperties.System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(systemProps);
|
||||
|
||||
uploadLimitService = new UploadLimitService();
|
||||
// inject mock
|
||||
try {
|
||||
var field = UploadLimitService.class.getDeclaredField("applicationProperties");
|
||||
field.setAccessible(true);
|
||||
field.set(uploadLimitService, applicationProperties);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
uploadLimitService = new UploadLimitService(applicationProperties);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "getReadableUploadLimit case #{index}: rawValue={0}, expected={1}")
|
||||
|
||||
@@ -66,20 +66,6 @@ dependencies {
|
||||
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
|
||||
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
|
||||
// version directly so it is selected consistently (root build.gradle pins are the fallback).
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
|
||||
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
|
||||
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
|
||||
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
|
||||
exclude group: 'com.google.code.findbugs', module: 'annotations'
|
||||
exclude group: 'org.derive4j', module: 'derive4j-annotation'
|
||||
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
|
||||
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
|
||||
}
|
||||
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
|
||||
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
|
||||
|
||||
@@ -142,9 +142,6 @@ public class AuditAspect {
|
||||
auditService.addTimingData(
|
||||
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
|
||||
|
||||
// Merge controller-set policy context + the internal-automation marker onto the event.
|
||||
auditService.addAutomationContext(auditData, req);
|
||||
|
||||
// Resolve the event type based on annotation and context
|
||||
String httpMethod = null;
|
||||
String path = null;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
/**
|
||||
* Request-scoped keys a controller can set to enrich its own audit event with context the generic
|
||||
* aspect can't infer from the HTTP request alone (e.g. the policy a pipeline run belongs to). The
|
||||
* aspect copies these into the audit data in its {@code finally} block, after the controller body
|
||||
* has run. See {@code AuditService#addAutomationContext}.
|
||||
*/
|
||||
public final class AuditContext {
|
||||
|
||||
/** Request attribute: the name of the policy/pipeline a run executes. */
|
||||
public static final String REQ_ATTR_POLICY_NAME = "stirling.audit.policyName";
|
||||
|
||||
/** Request attribute: the ordered tool endpoint paths a run executes. */
|
||||
public static final String REQ_ATTR_POLICY_STEPS = "stirling.audit.policySteps";
|
||||
|
||||
private AuditContext() {}
|
||||
}
|
||||
@@ -205,10 +205,6 @@ public class ControllerAuditAspect {
|
||||
// Call auditService but with isHttpRequest=true to skip additional timing
|
||||
auditService.addTimingData(data, start, resp, level, true);
|
||||
|
||||
// Merge controller-set policy context + the internal-automation marker (set after
|
||||
// the body ran, so it must happen here rather than with the pre-proceed HTTP data).
|
||||
auditService.addAutomationContext(data, req);
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
|
||||
* fixed, built-in list bundled with the application ({@code
|
||||
* classification/classification-labels.json}) and shared by everyone — there is no per-team
|
||||
* customization or database. Loaded once at startup.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ClassificationLabelProvider {
|
||||
|
||||
private static final String RESOURCE = "classification/classification-labels.json";
|
||||
|
||||
private final List<ClassificationLabel> labels;
|
||||
|
||||
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
|
||||
// can't infer which to use without it.
|
||||
@Autowired
|
||||
public ClassificationLabelProvider(ObjectMapper objectMapper) {
|
||||
this(load(objectMapper));
|
||||
}
|
||||
|
||||
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
|
||||
this.labels = List.copyOf(labels);
|
||||
}
|
||||
|
||||
/** Build a provider with an explicit label set (tests). */
|
||||
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
|
||||
return new ClassificationLabelProvider(labels);
|
||||
}
|
||||
|
||||
/** The built-in vocabulary, in file order. */
|
||||
public List<ClassificationLabel> labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
|
||||
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
|
||||
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
|
||||
return parsed.labels();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load classification labels from {}", RESOURCE, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.model.LabelsValidator;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
|
||||
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
|
||||
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
|
||||
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
|
||||
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
|
||||
* default on the backend or the engine — the label data lives only in the frontend).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/labels")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class ClassificationLabelsController {
|
||||
|
||||
private final ClassificationLabelStore labelStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification labels",
|
||||
description =
|
||||
"Returns the caller's team label set, or 204 when the team has none (its"
|
||||
+ " documents are then not classified).")
|
||||
public ResponseEntity<ClassificationLabels> getTeamLabels() {
|
||||
return labelStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification labels",
|
||||
description =
|
||||
"Validates and stores the label set for the caller's team, shared by everyone"
|
||||
+ " on the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationLabels> saveTeamLabels(
|
||||
@RequestBody ClassificationLabels labels) {
|
||||
requireEditingAllowed();
|
||||
validate(labels);
|
||||
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification labels",
|
||||
description =
|
||||
"Removes the team's stored label set; its documents are then not classified"
|
||||
+ " until labels are saved again. Requires the policy-editor role for the"
|
||||
+ " team.")
|
||||
public ResponseEntity<Void> resetTeamLabels() {
|
||||
requireEditingAllowed();
|
||||
labelStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static void validate(ClassificationLabels labels) {
|
||||
try {
|
||||
LabelsValidator.validate(labels);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the team labels requires the editor role for the caller's team — the same gate
|
||||
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
|
||||
* disabled) have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The team classification labels may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's team key. With login disabled the single operator owns the {@link
|
||||
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
|
||||
* is an error rather than being dropped into the shared sentinel bucket (which would let
|
||||
* unteamed users read and overwrite each other's "team" labels).
|
||||
*/
|
||||
private Long currentTeamId() {
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
if (teamId != null) {
|
||||
return teamId;
|
||||
}
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return TeamLabelsEntity.NO_TEAM;
|
||||
}
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
|
||||
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
|
||||
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
|
||||
* this record is the JSON parse target for that bundled resource.
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
|
||||
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
|
||||
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
|
||||
* neither the backend nor the engine holds a default of its own.
|
||||
*/
|
||||
public record ClassificationLabels(List<ClassificationLabel> labels) {
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
|
||||
*/
|
||||
public final class LabelsValidator {
|
||||
|
||||
private LabelsValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
|
||||
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
|
||||
// exhaust memory on deserialize.
|
||||
static final int MAX_LABELS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
|
||||
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
|
||||
// arbitrary
|
||||
// text that would render as garbage (or worse) in every teammate's sidebar.
|
||||
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationLabels labels) {
|
||||
if (labels == null || labels.labels() == null) {
|
||||
throw new IllegalArgumentException("Labels are required");
|
||||
}
|
||||
if (labels.labels().size() > MAX_LABELS) {
|
||||
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ClassificationLabel label : labels.labels()) {
|
||||
requireText(label.id(), "Label id");
|
||||
requireText(label.name(), "Label name");
|
||||
if (label.icon() != null && !label.icon().isEmpty()) {
|
||||
if (label.icon().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
if (!ICON_KEY.matcher(label.icon()).matches()) {
|
||||
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
|
||||
}
|
||||
}
|
||||
if (!ids.add(label.id().trim())) {
|
||||
throw new IllegalArgumentException("Duplicate label id: " + label.id());
|
||||
}
|
||||
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException("Duplicate label name: " + label.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.trim().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
|
||||
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface ClassificationLabelStore {
|
||||
|
||||
/** The team's stored labels, or empty when it has none (callers then skip classification). */
|
||||
Optional<ClassificationLabels> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's labels. Returns the stored value. */
|
||||
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
|
||||
|
||||
/** Remove the team's labels (reset to default). Returns whether a set existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
|
||||
* JpaClassificationLabelStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
byTeam.put(key(teamId), labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
|
||||
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
|
||||
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
|
||||
* TeamLabelsEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final TeamLabelsRepository teamRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return teamRepository
|
||||
.findById(key(teamId))
|
||||
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
TeamLabelsEntity entity = new TeamLabelsEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
teamRepository.save(entity);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!teamRepository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
teamRepository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Optional<ClassificationLabels> parse(String json, String owner) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored label set that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller treats the team as having no labels (and skips
|
||||
// classification) rather than surfacing a 500 on every upload.
|
||||
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
|
||||
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
|
||||
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
|
||||
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
|
||||
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
|
||||
* not a foreign key — so classification can be enabled or disabled without touching them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "classification_labels")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TeamLabelsEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
|
||||
public static final long NO_TEAM = 0L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private long teamId;
|
||||
|
||||
@Column(name = "labels_json", columnDefinition = "text")
|
||||
private String labelsJson;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private String updatedBy;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
|
||||
@@ -133,45 +133,29 @@ public final class S3Clients {
|
||||
* storage.s3.allow-private-endpoints=true}.
|
||||
*/
|
||||
static void validateEndpointHost(URI endpoint, boolean allowPrivate) {
|
||||
validateEndpointHost(
|
||||
endpoint,
|
||||
allowPrivate,
|
||||
"storage.s3.endpoint",
|
||||
"set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same private-address guard for S3 endpoints configured outside the {@code storage.s3.*}
|
||||
* block (e.g. per-source policy config), with the setting named in messages supplied by the
|
||||
* caller.
|
||||
*/
|
||||
public static void validateEndpointHost(
|
||||
URI endpoint, boolean allowPrivate, String settingName, String optInHint) {
|
||||
if (allowPrivate) {
|
||||
return;
|
||||
}
|
||||
String host = endpoint.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new IllegalStateException(settingName + " must include a host: " + endpoint);
|
||||
throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint);
|
||||
}
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalStateException(
|
||||
"Unable to resolve " + settingName + " host '" + host + "'", e);
|
||||
"Unable to resolve storage.s3.endpoint host '" + host + "'", e);
|
||||
}
|
||||
for (InetAddress address : addresses) {
|
||||
if (isPrivateOrLocal(address)) {
|
||||
throw new IllegalStateException(
|
||||
settingName
|
||||
+ " host '"
|
||||
"storage.s3.endpoint host '"
|
||||
+ host
|
||||
+ "' resolves to private/link-local address "
|
||||
+ address.getHostAddress()
|
||||
+ "; "
|
||||
+ optInHint);
|
||||
+ "; set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,19 +143,12 @@ public class AuditRestController {
|
||||
@RequestParam(value = "period", defaultValue = "week") String period) {
|
||||
|
||||
// Calculate days based on period
|
||||
int days;
|
||||
switch (period.toLowerCase()) {
|
||||
case "day":
|
||||
days = 1;
|
||||
break;
|
||||
case "month":
|
||||
days = 30;
|
||||
break;
|
||||
case "week":
|
||||
default:
|
||||
days = 7;
|
||||
break;
|
||||
}
|
||||
int days =
|
||||
switch (period.toLowerCase()) {
|
||||
case "day" -> 1;
|
||||
case "month" -> 30;
|
||||
default -> 7;
|
||||
};
|
||||
|
||||
// Get events from the specified period
|
||||
Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days));
|
||||
@@ -269,19 +262,12 @@ public class AuditRestController {
|
||||
@RequestParam(value = "period", defaultValue = "week") String period) {
|
||||
|
||||
// Calculate days based on period
|
||||
int days;
|
||||
switch (period.toLowerCase()) {
|
||||
case "day":
|
||||
days = 1;
|
||||
break;
|
||||
case "month":
|
||||
days = 30;
|
||||
break;
|
||||
case "week":
|
||||
default:
|
||||
days = 7;
|
||||
break;
|
||||
}
|
||||
int days =
|
||||
switch (period.toLowerCase()) {
|
||||
case "day" -> 1;
|
||||
case "month" -> 30;
|
||||
default -> 7;
|
||||
};
|
||||
|
||||
// Get events from the specified period and previous period
|
||||
Instant now = Instant.now();
|
||||
@@ -754,7 +740,7 @@ public class AuditRestController {
|
||||
List<Map<String, Object>> files =
|
||||
(List<Map<String, Object>>) eventData.get("files");
|
||||
if (files != null && !files.isEmpty()) {
|
||||
Map<String, Object> firstFile = files.get(0);
|
||||
Map<String, Object> firstFile = files.getFirst();
|
||||
data.put("documentname", String.valueOf(firstFile.getOrDefault("name", "")));
|
||||
data.put("author", String.valueOf(firstFile.getOrDefault("pdfAuthor", "")));
|
||||
data.put("filehash", String.valueOf(firstFile.getOrDefault("fileHash", "")));
|
||||
|
||||
@@ -31,9 +31,10 @@ import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
@@ -45,7 +46,7 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
|
||||
*
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document against the built-in label set, and stores the engine's JSON
|
||||
* engine to classify the document against the caller's team label set, and stores the engine's JSON
|
||||
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
|
||||
* client use.
|
||||
@@ -71,9 +72,13 @@ public class ClassifyLabelController {
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
|
||||
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
|
||||
* team authority are gated on it. Null otherwise, in which case there are no team labels to
|
||||
* classify against and the document is passed through unlabelled.
|
||||
*/
|
||||
private final ClassificationLabelProvider labelProvider;
|
||||
private final ClassificationLabelStore labelStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
public ClassifyLabelController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -82,16 +87,18 @@ public class ClassifyLabelController {
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
ClassificationLabelProvider labelProvider,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ClassificationLabelStore labelStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.labelProvider = labelProvider;
|
||||
this.userService = userService;
|
||||
this.labelStore = labelStore;
|
||||
this.policyManagementAuthority = policyManagementAuthority;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -109,9 +116,9 @@ public class ClassifyLabelController {
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against: pass the file through unlabelled rather than
|
||||
// ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
|
||||
// No vocabulary to classify against (the team stored no labels): pass the file
|
||||
// through unlabelled rather than ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
@@ -168,13 +175,24 @@ public class ClassifyLabelController {
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows
|
||||
* the model the names and returns the ids (icons are presentational and never sent). The engine
|
||||
* holds no default vocabulary of its own, so this bundled set is the only source.
|
||||
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
|
||||
* The engine shows the model the names and returns the ids (icons are presentational and never
|
||||
* sent). Returns an empty list — the caller then skips classification — when the policy
|
||||
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
|
||||
* default vocabulary of its own, so a team's stored labels are the only source.
|
||||
*/
|
||||
private List<EngineLabel> resolveAllowedLabels() {
|
||||
if (labelStore == null) {
|
||||
return List.of();
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
|
||||
Map<String, EngineLabel> byId = new LinkedHashMap<>();
|
||||
collectLabels(labelProvider.labels(), byId);
|
||||
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
|
||||
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,12 @@ import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
@@ -26,24 +24,18 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
|
||||
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
|
||||
*
|
||||
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
|
||||
* structured fields; the HTML is built here from a fixed template.
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@@ -56,9 +48,6 @@ public class CreatePdfAgentController {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiDocumentHtmlRenderer htmlRenderer;
|
||||
|
||||
/**
|
||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||
@@ -85,42 +74,32 @@ public class CreatePdfAgentController {
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert an AI-generated document to a PDF",
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
description =
|
||||
"Accepts a structured document as a JSON parameter and returns a PDF. This"
|
||||
+ " endpoint is dispatched by the AI workflow orchestrator as a plan"
|
||||
+ " step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdf(
|
||||
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
||||
"Accepts an HTML document as a plain-text parameter and returns a PDF."
|
||||
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
|
||||
+ " plan step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdfFromHtml(
|
||||
@RequestParam("htmlContent") String htmlContent,
|
||||
@RequestParam("filename") String filename)
|
||||
throws Exception {
|
||||
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
AiDocument model;
|
||||
try {
|
||||
model = objectMapper.readValue(document, AiDocument.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
String html = htmlRenderer.render(model);
|
||||
|
||||
log.info(
|
||||
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
|
||||
html.length());
|
||||
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getWeasyPrintPath());
|
||||
command.add("-e");
|
||||
command.add("utf-8");
|
||||
command.add("-v");
|
||||
// SSRF: the HTML is self-contained and the engine validates style colours, so no
|
||||
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
|
||||
command.add(htmlFile.getAbsolutePath());
|
||||
command.add(pdfFile.getAbsolutePath());
|
||||
|
||||
@@ -147,8 +126,8 @@ public class CreatePdfAgentController {
|
||||
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
|
||||
// which matters for large generated documents.
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
pdDocument.save(tempOut.getPath().toFile());
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
@@ -4,7 +4,6 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -39,9 +38,6 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
// Self-hosted only: counts are server-wide. On SaaS this endpoint is owned by the team-scoped
|
||||
// SaasFleetUsageController (@Profile("saas")) so one backend can't leak another tenant's usage.
|
||||
@Profile("!saas")
|
||||
public class FleetUsageController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
/**
|
||||
* {@link EncryptedStringConverter} for columns that held plaintext before encryption shipped:
|
||||
* writes are always encrypted, but a stored value that is not valid ciphertext is returned as-is,
|
||||
* so pre-encryption rows keep loading and become encrypted on their next save. The discrimination
|
||||
* is exact for JSON payloads, which can never be mistaken for ciphertext ('{' is not in the Base64
|
||||
* alphabet). The trade-off is that a genuinely corrupted ciphertext surfaces as garbage to the
|
||||
* caller's parser instead of failing here.
|
||||
*/
|
||||
@Converter
|
||||
public class LenientEncryptedStringConverter implements AttributeConverter<String, String> {
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(String attribute) {
|
||||
return CredentialEncryption.encrypt(attribute);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToEntityAttribute(String dbData) {
|
||||
try {
|
||||
return CredentialEncryption.decrypt(dbData);
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
// Not ciphertext: legacy plaintext from before encryption shipped.
|
||||
return dbData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,6 @@ public class IntegrationConfigService {
|
||||
private final OwnershipService ownership;
|
||||
private final SecretMasker secretMasker;
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
// Bean-discovered extension points: features that understand a type contribute its config
|
||||
// schema and report what still references a config, without this module depending on them.
|
||||
private final List<IntegrationConfigValidator> validators;
|
||||
private final List<IntegrationConfigUsageCheck> usageChecks;
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
@@ -70,21 +66,13 @@ public class IntegrationConfigService {
|
||||
? DefaultAccessPolicy.EXPLICIT_ONLY
|
||||
: request.defaultAccess());
|
||||
|
||||
// TEAM scope may omit the team id: default to the caller's own team so clients (the
|
||||
// portal) need not know it. assignOwnership still enforces admin-or-leader of that team.
|
||||
Long ownerTeamId = request.ownerTeamId();
|
||||
if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) {
|
||||
ownerTeamId = currentUser.getTeam().getId();
|
||||
}
|
||||
ownership.assignOwnership(
|
||||
cfg,
|
||||
scope,
|
||||
ownerTeamId,
|
||||
request.ownerTeamId(),
|
||||
currentUser,
|
||||
() -> lockedServerExists(cfg.getIntegrationType()));
|
||||
Map<String, Object> config = secretMasker.sanitize(request.config());
|
||||
validateConfig(cfg.getIntegrationType(), config);
|
||||
cfg.setConfig(writeJson(config));
|
||||
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@@ -113,10 +101,8 @@ public class IntegrationConfigService {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
Map<String, Object> merged =
|
||||
secretMasker.merge(readJson(cfg.getConfig()), request.config());
|
||||
validateConfig(cfg.getIntegrationType(), merged);
|
||||
cfg.setConfig(writeJson(merged));
|
||||
cfg.setConfig(
|
||||
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
|
||||
}
|
||||
return repository.save(cfg);
|
||||
}
|
||||
@@ -127,15 +113,6 @@ public class IntegrationConfigService {
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
// Refuse to pull a connection out from under whatever still references it.
|
||||
List<String> usages =
|
||||
usageChecks.stream()
|
||||
.flatMap(check -> check.usagesOf(cfg.getId()).stream())
|
||||
.toList();
|
||||
if (!usages.isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages));
|
||||
}
|
||||
// Drop grants sharing this config so they do not dangle as dead rows.
|
||||
grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId()));
|
||||
repository.delete(cfg);
|
||||
@@ -211,19 +188,6 @@ public class IntegrationConfigService {
|
||||
|
||||
// ---- integration-specific glue ----
|
||||
|
||||
/** Runs every registered validator for the type; unknown types save free-form. */
|
||||
private void validateConfig(IntegrationType type, Map<String, Object> config) {
|
||||
for (IntegrationConfigValidator validator : validators) {
|
||||
if (validator.type() == type) {
|
||||
try {
|
||||
validator.validate(config == null ? Map.of() : config);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
|
||||
private boolean lockedServerExists(IntegrationType type) {
|
||||
return repository.findByScope(OwnerScope.SERVER).stream()
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reports what still references an integration config, so deletion can be refused instead of
|
||||
* pulling a connection out from under a live consumer. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that
|
||||
* reference a connection).
|
||||
*/
|
||||
public interface IntegrationConfigUsageCheck {
|
||||
|
||||
/** Human-readable labels of everything still using the config; empty when unreferenced. */
|
||||
List<String> usagesOf(long configId);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/**
|
||||
* Validates one integration type's config map at save time. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3
|
||||
* backend) owns its schema without the integration module depending on it. Types with no registered
|
||||
* validator save free-form.
|
||||
*/
|
||||
public interface IntegrationConfigValidator {
|
||||
|
||||
/** The type this validator understands. */
|
||||
IntegrationType type();
|
||||
|
||||
/**
|
||||
* Validates the config as it will be stored (secrets already sanitized/merged, so values are
|
||||
* real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config.
|
||||
*/
|
||||
void validate(Map<String, Object> config);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.ai.create;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AiDocument {
|
||||
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private String referenceNumber;
|
||||
private Style style;
|
||||
private List<Section> sections;
|
||||
|
||||
@Data
|
||||
public static class Style {
|
||||
private String primaryColor;
|
||||
private String backgroundColor;
|
||||
private String bodyTextColor;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Section {
|
||||
private String type;
|
||||
private String heading;
|
||||
private String body;
|
||||
private List<List<String>> pairs;
|
||||
private List<String> columns;
|
||||
private List<List<String>> rows;
|
||||
private List<String> totalRow;
|
||||
private List<String> items;
|
||||
private List<String> signatories;
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,6 @@ public class InfraAuditSummary {
|
||||
@Schema(description = "Total events in the returned window", example = "40")
|
||||
private int totalEvents;
|
||||
|
||||
@Schema(description = "Policy-run events", example = "3")
|
||||
private int policy;
|
||||
|
||||
@Schema(description = "Processing-category events", example = "24")
|
||||
private int processing;
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
@@ -34,7 +32,6 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -45,18 +42,14 @@ import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.audit.AuditContext;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
import stirling.software.proprietary.policy.engine.PolicyValidator;
|
||||
import stirling.software.proprietary.policy.engine.SweepOutcome;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
@@ -65,15 +58,12 @@ import stirling.software.proprietary.policy.model.PolicyRunView;
|
||||
import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
|
||||
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.EditorSource;
|
||||
import stirling.software.proprietary.policy.source.SourceAccessGuard;
|
||||
import stirling.software.proprietary.policy.source.SourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.policy.trigger.TriggerInfo;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
|
||||
@@ -93,7 +83,6 @@ public class PolicyController {
|
||||
private final PolicyStore policyStore;
|
||||
private final SourceStore sourceStore;
|
||||
private final SourceAccessGuard sourceAccessGuard;
|
||||
private final SourceDocCounter docCounter;
|
||||
private final PolicyValidator policyValidator;
|
||||
private final PolicyAccessGuard policyAccessGuard;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
@@ -118,14 +107,11 @@ public class PolicyController {
|
||||
@RequestPart("json") PipelineDefinition definition,
|
||||
@Valid @ModelAttribute PolicyRunFiles files)
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PolicyRunHandle handle =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
|
||||
recordEditorDocs(inputs);
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, handle.runId(), null));
|
||||
String runId =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -139,9 +125,7 @@ public class PolicyController {
|
||||
@RequestPart("json") PipelineDefinition definition,
|
||||
@Valid @ModelAttribute PolicyRunFiles files)
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
|
||||
SseEmitter emitter =
|
||||
@@ -149,7 +133,6 @@ public class PolicyController {
|
||||
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
|
||||
|
||||
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
|
||||
recordEditorDocs(inputs);
|
||||
// whenComplete runs on the worker thread after the run finishes, so the terminal event
|
||||
// never races the step events.
|
||||
handle.completion()
|
||||
@@ -219,7 +202,7 @@ public class PolicyController {
|
||||
+ " assigned; returns the stored policy with its id.")
|
||||
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
|
||||
requirePolicyEditingAllowed();
|
||||
Policy owned = withStoredOutputSecrets(resolveOwnership(policy));
|
||||
Policy owned = resolveOwnership(policy);
|
||||
requireAccessibleSources(owned);
|
||||
try {
|
||||
policyValidator.validate(owned);
|
||||
@@ -230,7 +213,7 @@ public class PolicyController {
|
||||
// Re-sync trigger registrations now so a new/changed folder-watch policy starts being
|
||||
// watched immediately instead of after the next reconcile sweep.
|
||||
policyTriggerManager.notifyPoliciesChanged();
|
||||
return ResponseEntity.ok(withMaskedOutputSecrets(saved));
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@PutMapping("/order")
|
||||
@@ -299,50 +282,6 @@ public class PolicyController {
|
||||
teamId);
|
||||
}
|
||||
|
||||
/** Output secrets never leave the server: reads return the redaction sentinel instead. */
|
||||
private static Policy withMaskedOutputSecrets(Policy policy) {
|
||||
return withOutput(
|
||||
policy,
|
||||
new OutputSpec(
|
||||
policy.output().type(), SecretMasker.mask(policy.output().options())));
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit that round-trips a masked read sends output secrets back as the sentinel; restore
|
||||
* them from the stored policy so saving without re-typing keeps them (validation then runs
|
||||
* against the real values).
|
||||
*/
|
||||
private Policy withStoredOutputSecrets(Policy incoming) {
|
||||
if (incoming.id() == null || incoming.id().isBlank()) {
|
||||
return incoming;
|
||||
}
|
||||
return policyStore
|
||||
.get(incoming.id())
|
||||
.map(
|
||||
existing ->
|
||||
withOutput(
|
||||
incoming,
|
||||
new OutputSpec(
|
||||
incoming.output().type(),
|
||||
SecretMasker.restoreRedacted(
|
||||
incoming.output().options(),
|
||||
existing.output().options()))))
|
||||
.orElse(incoming);
|
||||
}
|
||||
|
||||
private static Policy withOutput(Policy policy, OutputSpec output) {
|
||||
return new Policy(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.sourceIds(),
|
||||
policy.steps(),
|
||||
output,
|
||||
policy.teamId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating, editing, pausing/resuming, and deleting policies requires the editor role for the
|
||||
* caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global
|
||||
@@ -367,14 +306,9 @@ public class PolicyController {
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List policies",
|
||||
description =
|
||||
"Lists the policies belonging to the caller's team. Secret-bearing output"
|
||||
+ " options are returned as a redaction sentinel, never their stored"
|
||||
+ " values.")
|
||||
description = "Lists the policies belonging to the caller's team.")
|
||||
public List<Policy> listPolicies() {
|
||||
return policyAccessGuard.visibleFrom(policyStore).stream()
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.toList();
|
||||
return policyAccessGuard.visibleFrom(policyStore);
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
@@ -403,17 +337,11 @@ public class PolicyController {
|
||||
}
|
||||
|
||||
@GetMapping("/{policyId}")
|
||||
@Operation(
|
||||
summary = "Get a policy by id",
|
||||
description =
|
||||
"Secret-bearing output options are returned as a redaction sentinel, never"
|
||||
+ " their stored values; an edit that sends the sentinel back keeps"
|
||||
+ " them.")
|
||||
@Operation(summary = "Get a policy by id")
|
||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||
return policyStore
|
||||
.get(policyId)
|
||||
.filter(policyAccessGuard::canAccess)
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
@@ -473,7 +401,6 @@ public class PolicyController {
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "No policy: " + policyId));
|
||||
stampPolicyAudit(policy.toDefinition());
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
@@ -485,10 +412,9 @@ public class PolicyController {
|
||||
description =
|
||||
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
|
||||
+ " the enabled flag (which only gates automatic triggering). Returns"
|
||||
+ " the ids of the runs started (poll the run-status endpoint for each)"
|
||||
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
|
||||
+ " and in-flight counts - so an empty result explains itself.")
|
||||
public ResponseEntity<SweepOutcome> trigger(@PathVariable String policyId) {
|
||||
+ " the ids of the runs started; poll the run-status endpoint for each."
|
||||
+ " Empty when the sources yielded no work to do.")
|
||||
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
@@ -507,60 +433,6 @@ public class PolicyController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp this run's policy name and step endpoints onto the current request so the audit aspect
|
||||
* can label the event as the policy it ran (rather than the generic {@code /run} endpoint) and
|
||||
* record which tools it executed. No-op outside a web request.
|
||||
*/
|
||||
private static void stampPolicyAudit(PipelineDefinition definition) {
|
||||
if (definition == null
|
||||
|| !(RequestContextHolder.getRequestAttributes()
|
||||
instanceof ServletRequestAttributes attrs)) {
|
||||
return;
|
||||
}
|
||||
HttpServletRequest request = attrs.getRequest();
|
||||
if (definition.name() != null && !definition.name().isBlank()) {
|
||||
request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, definition.name());
|
||||
}
|
||||
List<String> steps =
|
||||
definition.steps().stream()
|
||||
.map(PipelineStep::operation)
|
||||
.filter(op -> op != null && !op.isBlank())
|
||||
.toList();
|
||||
if (!steps.isEmpty()) {
|
||||
request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, steps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
|
||||
* request thread). The worker thread that later delivers carries no security context, so an S3
|
||||
* output's connection-access check would be skipped there; without this gate a caller could
|
||||
* reference another tenant's connection by id and write to it (confused deputy). Stored
|
||||
* policies are covered by save-time {@link PolicyValidator#validate} instead.
|
||||
*/
|
||||
private void validateAdHocOutput(PipelineDefinition definition) {
|
||||
if (definition.output() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
policyValidator.validateOutput(definition.output());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
|
||||
* feed the same virtual editor source as stored editor policies, counted against the caller's
|
||||
* team. A run with no primary documents (generator pipeline) records nothing.
|
||||
*/
|
||||
private void recordEditorDocs(PolicyInputs inputs) {
|
||||
docCounter.record(
|
||||
EditorSource.counterKey(sourceAccessGuard.currentTeamId()),
|
||||
inputs.primary().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the
|
||||
* named supporting-file store, where each asset's {@code key} is the name a step references
|
||||
|
||||