Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7164bff8e3 | ||
|
|
793cefa5ac | ||
|
|
a84b375f5d | ||
|
|
52358c5bf9 | ||
|
|
c1e68c27c5 | ||
|
|
d4edff9059 | ||
|
|
80febc9993 | ||
|
|
c500c2fae7 | ||
|
|
0a1b4ec173 | ||
|
|
b8d8f028c9 | ||
|
|
cd56367295 | ||
|
|
40a2d2844f | ||
|
|
f79968f336 | ||
|
|
df43e09eca | ||
|
|
cb0f335e8a | ||
|
|
38d06d3104 | ||
|
|
fe33378333 | ||
|
|
5944cd106b | ||
|
|
fd81bf4cf8 | ||
|
|
d23318cfa6 | ||
|
|
142544c9af | ||
|
|
99a5f2a1bc | ||
|
|
863cad22bd | ||
|
|
c06657c8f9 | ||
|
|
532a80211f | ||
|
|
d06a367b87 | ||
|
|
ce6abe6e23 | ||
|
|
ece3562dc9 | ||
|
|
84d4455682 | ||
|
|
b9a7f2083b | ||
|
|
b36f3e0875 | ||
|
|
e4379184b5 | ||
|
|
5ccb56da2d | ||
|
|
16f589448d | ||
|
|
75ea3c9a1f | ||
|
|
7529190587 | ||
|
|
b9f9f84907 | ||
|
|
68ec176719 | ||
|
|
d17c3f4fec | ||
|
|
783a51950f | ||
|
|
b95b7c53c7 | ||
|
|
a9641bf583 | ||
|
|
9e7661eedd | ||
|
|
ba9eeddf59 | ||
|
|
a49b2275e7 | ||
|
|
a660aed964 | ||
|
|
1077c4f134 | ||
|
|
e468ed5e65 | ||
|
|
bad9e9ac61 | ||
|
|
4da5b4b7eb | ||
|
|
26d835eda6 | ||
|
|
cfd6d19886 | ||
|
|
7bcaeb69c5 | ||
|
|
c168e5a658 | ||
|
|
313f614ec2 | ||
|
|
b0109e174b | ||
|
|
38180550a3 |
@@ -87,6 +87,13 @@ stirling/
|
||||
customFiles/
|
||||
configs/
|
||||
|
||||
# Local dev runtime dirs created by bootRun under the module dir (hold the locked H2 DB,
|
||||
# downloaded models, logs); never build input. Root-level patterns above don't match these.
|
||||
**/configs/
|
||||
app/core/customFiles/
|
||||
app/core/logs/
|
||||
app/core/pipeline/
|
||||
|
||||
# Claude Code workspace
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- app/(common|core|proprietary|saas)/build.gradle
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
|
||||
openapi: &openapi
|
||||
- *build
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
|
||||
docker-base: &docker-base
|
||||
- docker/base/Dockerfile
|
||||
@@ -23,9 +23,9 @@ docker: &docker
|
||||
- *docker-base
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
|
||||
- *build
|
||||
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- "app/(common|core|proprietary|saas)/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)/src/main/java/**
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
@@ -63,6 +63,7 @@ labels:
|
||||
files:
|
||||
- 'app/core/src/main/resources/static/.*'
|
||||
- 'app/proprietary/src/main/resources/static/.*'
|
||||
- 'app/saas/src/main/resources/static/.*'
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
@@ -83,6 +84,7 @@ 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:
|
||||
@@ -90,6 +92,9 @@ 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'
|
||||
@@ -153,6 +158,7 @@ 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'
|
||||
@@ -171,3 +177,4 @@ 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: build-push-base
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: docker/base
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"frontend/editor/src-tauri/icons/icon.png"
|
||||
]
|
||||
}
|
||||
@@ -80,6 +80,12 @@ 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}}
|
||||
|
||||
|
||||
@@ -536,6 +536,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-epub");
|
||||
addEndpointToGroup("Java", "eml-to-pdf");
|
||||
addEndpointToGroup("Java", "handleData");
|
||||
addEndpointToGroup("Java", "form-detection");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
|
||||
@@ -40,6 +40,9 @@ public class RuntimePathConfig {
|
||||
// Tesseract data path
|
||||
private final String tessDataPath;
|
||||
|
||||
// Auto Form Detection model directory
|
||||
private final String formDetectionModelPath;
|
||||
|
||||
private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> unoServerEndpoints;
|
||||
|
||||
// Pipeline paths
|
||||
@@ -130,6 +133,22 @@ public class RuntimePathConfig {
|
||||
|
||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||
|
||||
// Auto Form Detection model directory (kept under <configs> so it survives
|
||||
// restarts/updates)
|
||||
String configuredModelDir =
|
||||
properties.getFormDetection() != null
|
||||
? properties.getFormDetection().getModelDir()
|
||||
: null;
|
||||
this.formDetectionModelPath =
|
||||
StringUtils.isNotBlank(configuredModelDir)
|
||||
? configuredModelDir
|
||||
: Path.of(
|
||||
InstallationPathConfig.getConfigPath(),
|
||||
"models",
|
||||
"form-detection")
|
||||
.toString();
|
||||
log.info("Using Auto Form Detection model path: {}", this.formDetectionModelPath);
|
||||
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
int libreOfficeLimit = 1;
|
||||
if (processExecutor != null && processExecutor.getSessionLimit() != null) {
|
||||
|
||||
@@ -77,6 +77,7 @@ public class ApplicationProperties {
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
private AiEngine aiEngine = new AiEngine();
|
||||
private FormDetection formDetection = new FormDetection();
|
||||
private Mcp mcp = new Mcp();
|
||||
private InternalApi internalApi = new InternalApi();
|
||||
private Cluster cluster = new Cluster();
|
||||
@@ -246,6 +247,14 @@ 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
|
||||
@@ -302,6 +311,37 @@ public class ApplicationProperties {
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto Form Detection settings. The model itself is downloaded on demand by an admin (see
|
||||
* {@code /api/v1/ai/form-detection-model/*}); only lightweight pointers are persisted here.
|
||||
*/
|
||||
@Data
|
||||
public static class FormDetection {
|
||||
/** Master on/off switch for the whole feature (admin-controlled). */
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Where detection runs: {@code auto} (browser first, server fallback), {@code browser}
|
||||
* (in-browser WASM only - the PDF never leaves the device), or {@code server} (backend
|
||||
* inference). Read by the frontend tool to choose its pipeline.
|
||||
*/
|
||||
private String executionMode = "auto";
|
||||
|
||||
/** Id of the installed model; blank means none installed. */
|
||||
private String activeModelId = "";
|
||||
|
||||
/** Optional override dir; blank uses {@code <configs>/models/form-detection}. */
|
||||
private String modelDir = "";
|
||||
|
||||
/**
|
||||
* Read-only dir of models baked into the image (e.g. the Docker server image pre-downloads
|
||||
* FFDNet-S here). On startup any {@code <catalogId>.onnx} found here is copied into the
|
||||
* writable model dir if not already present, and activated if no model is active - so the
|
||||
* feature works out-of-the-box. Blank (default) disables seeding.
|
||||
*/
|
||||
private String preinstalledModelDir = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code
|
||||
* mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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,6 +8,7 @@ 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;
|
||||
@@ -60,6 +61,17 @@ 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;
|
||||
@@ -111,6 +123,27 @@ 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
|
||||
|
||||
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)) {
|
||||
if (Files.exists(directory)
|
||||
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
@@ -275,6 +277,21 @@ 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 =
|
||||
|
||||
@@ -944,6 +944,81 @@ public class FormUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new AcroForm fields from a list of definitions (used by Auto Form Detection). Reuses
|
||||
* the same field-creation and appearance logic as the rest of this class, and creates the
|
||||
* AcroForm (with a Helvetica default resource) when the document has none. Field names are made
|
||||
* unique against any existing fields.
|
||||
*/
|
||||
public void addFields(PDDocument document, List<NewFormFieldDefinition> definitions)
|
||||
throws IOException {
|
||||
if (document == null || definitions == null || definitions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
PDDocumentCatalog documentCatalog = document.getDocumentCatalog();
|
||||
PDAcroForm acroForm = documentCatalog.getAcroForm();
|
||||
if (acroForm == null) {
|
||||
acroForm = new PDAcroForm(document);
|
||||
PDResources dr = new PDResources();
|
||||
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
acroForm.setDefaultResources(dr);
|
||||
acroForm.setNeedAppearances(true);
|
||||
documentCatalog.setAcroForm(acroForm);
|
||||
}
|
||||
|
||||
Set<String> existingNames = new java.util.HashSet<>();
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (field.getPartialName() != null) {
|
||||
existingNames.add(field.getPartialName());
|
||||
}
|
||||
}
|
||||
|
||||
int pageCount = document.getNumberOfPages();
|
||||
for (NewFormFieldDefinition definition : definitions) {
|
||||
Integer pageIndex = definition.pageIndex();
|
||||
if (pageIndex == null
|
||||
|| pageIndex < 0
|
||||
|| pageIndex >= pageCount
|
||||
|| definition.x() == null
|
||||
|| definition.y() == null
|
||||
|| definition.width() == null
|
||||
|| definition.height() == null) {
|
||||
continue;
|
||||
}
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
PDRectangle rectangle =
|
||||
new PDRectangle(
|
||||
definition.x(),
|
||||
definition.y(),
|
||||
definition.width(),
|
||||
definition.height());
|
||||
FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(definition.type());
|
||||
if (handler == null || handler.doesNotsupportsDefinitionCreation()) {
|
||||
handler = FormFieldTypeSupport.TEXT;
|
||||
}
|
||||
String baseName =
|
||||
(definition.name() != null && !definition.name().isBlank())
|
||||
? definition.name()
|
||||
: handler.typeName() + "_" + (pageIndex + 1);
|
||||
String uniqueName = generateUniqueFieldName(baseName, existingNames);
|
||||
existingNames.add(uniqueName);
|
||||
try {
|
||||
createNewField(
|
||||
handler,
|
||||
acroForm,
|
||||
page,
|
||||
rectangle,
|
||||
uniqueName,
|
||||
definition,
|
||||
definition.options());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to create detected field '{}': {}", uniqueName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
ensureAppearances(acroForm);
|
||||
}
|
||||
|
||||
public String filterSingleChoiceSelection(
|
||||
String selection, List<String> allowedOptions, String fieldName) {
|
||||
if (selection == null || selection.trim().isEmpty()) return null;
|
||||
|
||||
@@ -155,6 +155,7 @@ 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,6 +85,18 @@ 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.
|
||||
*
|
||||
|
||||
@@ -176,11 +176,13 @@ class TempFileCleanupServiceMoreTest {
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
@DisplayName("deletes stale 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);
|
||||
@@ -193,6 +195,22 @@ 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() {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.FormUtils.NewFormFieldDefinition;
|
||||
|
||||
class FormUtilsAddFieldsTest {
|
||||
|
||||
private NewFormFieldDefinition def(String type, int page, float x, float y, float w, float h) {
|
||||
return new NewFormFieldDefinition(
|
||||
null, null, type, page, x, y, w, h, false, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsTextAndCheckboxFieldsOnPagelessDocument() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(new PDRectangle(612, 792)));
|
||||
|
||||
FormUtils.addFields(
|
||||
doc,
|
||||
List.of(
|
||||
def("text", 0, 100f, 700f, 200f, 20f),
|
||||
def("checkbox", 0, 100f, 650f, 15f, 15f)));
|
||||
|
||||
PDAcroForm form = doc.getDocumentCatalog().getAcroForm();
|
||||
assertNotNull(form, "AcroForm should be created");
|
||||
|
||||
List<PDField> fields = new ArrayList<>();
|
||||
form.getFieldTree().forEach(fields::add);
|
||||
assertEquals(2, fields.size());
|
||||
|
||||
boolean hasText = fields.stream().anyMatch(f -> f instanceof PDTextField);
|
||||
boolean hasCheckbox = fields.stream().anyMatch(f -> f instanceof PDCheckBox);
|
||||
assertTrue(hasText, "expected a text field");
|
||||
assertTrue(hasCheckbox, "expected a checkbox field");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsOutOfRangePageAndKeepsNamesUnique() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(new PDRectangle(612, 792)));
|
||||
|
||||
FormUtils.addFields(
|
||||
doc,
|
||||
List.of(
|
||||
def("text", 0, 10f, 10f, 50f, 12f),
|
||||
def("text", 0, 10f, 40f, 50f, 12f),
|
||||
def("text", 5, 10f, 70f, 50f, 12f))); // page 5 out of range -> skipped
|
||||
|
||||
PDAcroForm form = doc.getDocumentCatalog().getAcroForm();
|
||||
List<PDField> fields = new ArrayList<>();
|
||||
form.getFieldTree().forEach(fields::add);
|
||||
assertEquals(2, fields.size());
|
||||
|
||||
long distinctNames = fields.stream().map(PDField::getPartialName).distinct().count();
|
||||
assertEquals(2, distinctNames, "field names must be unique");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOpOnEmptyDefinitions() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(new PDRectangle(612, 792)));
|
||||
FormUtils.addFields(doc, List.of());
|
||||
// no AcroForm forced into existence when there is nothing to add
|
||||
assertEquals(null, doc.getDocumentCatalog().getAcroForm());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -27,6 +28,7 @@ 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;
|
||||
@@ -71,6 +73,17 @@ 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();
|
||||
|
||||
@@ -136,11 +136,17 @@ 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[] listOfText = request.getListOfText().split("\n");
|
||||
String rawListOfText = request.getListOfText();
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
|
||||
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
|
||||
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()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
@@ -367,6 +367,13 @@ aiEngine:
|
||||
url: http://localhost:5001 # URL of the Python AI engine
|
||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
||||
|
||||
formDetection:
|
||||
enabled: true # Master on/off switch for the Auto Form Detection feature
|
||||
executionMode: auto # Where detection runs: 'auto' (browser first, server fallback), 'browser' (in-browser WASM only, PDF never leaves the device), or 'server' (backend inference)
|
||||
activeModelId: "" # Id of the installed Auto Form Detection model (set automatically after an admin installs one)
|
||||
modelDir: "" # Optional override directory for downloaded .onnx models; blank uses <configs>/models/form-detection
|
||||
preinstalledModelDir: "" # Read-only dir of models baked into the image (Docker pre-downloads FFDNet-S here); seeded into the model dir on startup. Blank disables.
|
||||
|
||||
policies:
|
||||
# Folder automations can read from and write to the directories you allow here, so treat this as a
|
||||
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -1,18 +1 @@
|
||||
<?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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,13 +1 @@
|
||||
<?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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -299,6 +299,18 @@ 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() {
|
||||
|
||||
@@ -3,6 +3,14 @@ repositories {
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
}
|
||||
|
||||
ext {
|
||||
// CPU build; self-extracting natives for linux/win/mac x64+arm64. Keep major.minor aligned
|
||||
// with the frontend onnxruntime-web pin so the same .onnx runs identically on both paths.
|
||||
// Must be >=1.21 to load opset-22 models: the FFDNet exports are stamped ai.onnx opset 22,
|
||||
// which 1.19/1.20 hard-reject (ORT_FAIL "support is till opset 21"). 1.26.0 matches the
|
||||
// onnxruntime build the upstream CommonForms inference reference runs on.
|
||||
onnxruntimeVersion = '1.26.0'
|
||||
}
|
||||
bootRun {
|
||||
enabled = false
|
||||
}
|
||||
@@ -66,6 +74,18 @@ dependencies {
|
||||
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
|
||||
// ONNX Runtime (Java) for SERVER-SIDE Auto Form Detection inference.
|
||||
// Compiled against everywhere so the module always builds, but the multi-platform native
|
||||
// (~42MB) is only BUNDLED when explicitly requested via -PbundleOnnxRuntime=true - i.e. the
|
||||
// Docker server image (which then slims it to one Linux arch, ~8MB). Desktop/local/core builds
|
||||
// ship WITHOUT it: server-side detection cleanly reports "unavailable" and the in-browser
|
||||
// (onnxruntime-web) engine handles detection instead. Tests always get it.
|
||||
compileOnly "com.microsoft.onnxruntime:onnxruntime:$onnxruntimeVersion"
|
||||
testImplementation "com.microsoft.onnxruntime:onnxruntime:$onnxruntimeVersion"
|
||||
if ((project.findProperty('bundleOnnxRuntime') ?: 'false').toString().toBoolean()) {
|
||||
runtimeOnly "com.microsoft.onnxruntime:onnxruntime:$onnxruntimeVersion"
|
||||
}
|
||||
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
|
||||
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
|
||||
|
||||
@@ -142,6 +142,9 @@ 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;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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,6 +205,10 @@ 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(
|
||||
|
||||
@@ -133,29 +133,45 @@ 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("storage.s3.endpoint must include a host: " + endpoint);
|
||||
throw new IllegalStateException(settingName + " must include a host: " + endpoint);
|
||||
}
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalStateException(
|
||||
"Unable to resolve storage.s3.endpoint host '" + host + "'", e);
|
||||
"Unable to resolve " + settingName + " host '" + host + "'", e);
|
||||
}
|
||||
for (InetAddress address : addresses) {
|
||||
if (isPrivateOrLocal(address)) {
|
||||
throw new IllegalStateException(
|
||||
"storage.s3.endpoint host '"
|
||||
settingName
|
||||
+ " host '"
|
||||
+ host
|
||||
+ "' resolves to private/link-local address "
|
||||
+ address.getHostAddress()
|
||||
+ "; set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
+ "; "
|
||||
+ optInHint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -38,6 +39,9 @@ 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;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package stirling.software.proprietary.formdetection.catalog;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Loads the curated Auto Form Detection model catalog from a bundled JSON resource. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ModelCatalogService {
|
||||
|
||||
private static final String CATALOG_RESOURCE = "formdetection/model-catalog.json";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile List<ModelCatalogEntry> entries = List.of();
|
||||
private volatile Map<String, ModelCatalogEntry> byId = Map.of();
|
||||
|
||||
@PostConstruct
|
||||
void load() {
|
||||
try (InputStream is = new ClassPathResource(CATALOG_RESOURCE).getInputStream()) {
|
||||
List<ModelCatalogEntry> loaded =
|
||||
objectMapper.readValue(is, new TypeReference<List<ModelCatalogEntry>>() {});
|
||||
Map<String, ModelCatalogEntry> map = new LinkedHashMap<>();
|
||||
for (ModelCatalogEntry entry : loaded) {
|
||||
if (entry.getId() != null && !entry.getId().isBlank()) {
|
||||
map.put(entry.getId(), entry);
|
||||
}
|
||||
}
|
||||
this.entries = List.copyOf(map.values());
|
||||
this.byId = Map.copyOf(map);
|
||||
log.info("Loaded {} Auto Form Detection model catalog entries", entries.size());
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to load Auto Form Detection model catalog from {}",
|
||||
CATALOG_RESOURCE,
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<ModelCatalogEntry> getAll() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public Optional<ModelCatalogEntry> getById(String id) {
|
||||
return Optional.ofNullable(id == null ? null : byId.get(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package stirling.software.proprietary.formdetection.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.FormUtils;
|
||||
import stirling.software.common.util.FormUtils.NewFormFieldDefinition;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.formdetection.inference.OnnxFormDetector;
|
||||
import stirling.software.proprietary.formdetection.inference.Yolo;
|
||||
import stirling.software.proprietary.formdetection.model.DetectedField;
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
import stirling.software.proprietary.formdetection.render.CoordinateMapper;
|
||||
import stirling.software.proprietary.formdetection.render.PageRasterizer;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
|
||||
/**
|
||||
* Server-side detection endpoint. Gated behind the {@code form-detection} endpoint key, which is
|
||||
* disabled until a model is installed (so the tool tile is greyed in the UI). Returns the shared
|
||||
* detection schema, or - when {@code applyToPdf=true} - the AcroForm-applied PDF.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/form-detection")
|
||||
@ConditionalOnClass(name = "ai.onnxruntime.OrtEnvironment")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Auto Form Detection")
|
||||
public class FormDetectionController {
|
||||
|
||||
private final FormDetectionModelManager manager;
|
||||
private final OnnxFormDetector detector;
|
||||
private final PageRasterizer rasterizer;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@PostMapping(value = "/detect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Detect form fields with the installed AI model",
|
||||
description =
|
||||
"Runs the installed ONNX model over each page and returns detected fields in"
|
||||
+ " PDF points. With applyToPdf=true, returns the fillable PDF instead.")
|
||||
public ResponseEntity<?> detect(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "confThreshold", required = false) Float confThreshold,
|
||||
@RequestParam(value = "applyToPdf", required = false, defaultValue = "false")
|
||||
boolean applyToPdf)
|
||||
throws IOException {
|
||||
|
||||
if (!manager.isReady()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
Map.of(
|
||||
"reason",
|
||||
"DEPENDENCY",
|
||||
"message",
|
||||
"AI form-detection model is not installed"));
|
||||
}
|
||||
ModelCatalogEntry spec = manager.getActiveEntry().orElse(null);
|
||||
if (spec == null) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
Map.of(
|
||||
"reason",
|
||||
"DEPENDENCY",
|
||||
"message",
|
||||
"Active model spec unavailable"));
|
||||
}
|
||||
float score = confThreshold != null ? confThreshold : spec.getScoreThreshold();
|
||||
byte[] pdfBytes = file.getBytes();
|
||||
|
||||
List<DetectedField> detections = new ArrayList<>();
|
||||
try {
|
||||
for (PageRasterizer.RasterPage page :
|
||||
rasterizer.rasterize(pdfBytes, spec.getInputSize())) {
|
||||
Yolo.Preprocessed pre =
|
||||
Yolo.preprocess(page.rgba(), page.widthPx(), page.heightPx(), spec);
|
||||
Yolo.RawOutput out = detector.infer(pre.chw(), spec.getInputSize());
|
||||
for (Yolo.Detection d : Yolo.decode(out, spec, pre, score)) {
|
||||
DetectedField.RectPt rect = CoordinateMapper.toPdfPoints(d, page);
|
||||
detections.add(
|
||||
new DetectedField(
|
||||
fieldType(spec, d.classId()),
|
||||
page.pageIndex(),
|
||||
rect,
|
||||
d.score()));
|
||||
}
|
||||
}
|
||||
} catch (IllegalStateException e) {
|
||||
// e.g. ONNX Runtime native unavailable for this OS/arch - report unavailable cleanly
|
||||
// rather than a 500. Cannot happen on a normally-built jar (all platforms bundled), but
|
||||
// keeps a slimmed/mis-targeted build from erroring.
|
||||
log.warn("Auto Form Detection inference unavailable: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(Map.of("reason", "DEPENDENCY", "message", e.getMessage()));
|
||||
}
|
||||
|
||||
if (applyToPdf) {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
List<NewFormFieldDefinition> defs = new ArrayList<>();
|
||||
for (DetectedField f : detections) {
|
||||
defs.add(toDefinition(f));
|
||||
}
|
||||
FormUtils.addFields(document, defs);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, baseName(file) + ".pdf", tempFileManager);
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok(new DetectResponse(detections));
|
||||
}
|
||||
|
||||
private static String fieldType(ModelCatalogEntry spec, int classId) {
|
||||
List<String> types = spec.getClassFieldTypes();
|
||||
if (types != null && classId >= 0 && classId < types.size()) {
|
||||
return types.get(classId);
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
private static NewFormFieldDefinition toDefinition(DetectedField f) {
|
||||
DetectedField.RectPt r = f.rectInPdfPoints();
|
||||
return new NewFormFieldDefinition(
|
||||
null,
|
||||
null,
|
||||
f.type(),
|
||||
f.page(),
|
||||
(float) r.x(),
|
||||
(float) r.y(),
|
||||
(float) r.w(),
|
||||
(float) r.h(),
|
||||
Boolean.FALSE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private static String baseName(MultipartFile file) {
|
||||
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
if (original == null || original.isBlank()) {
|
||||
original = "document";
|
||||
}
|
||||
String stem =
|
||||
original.toLowerCase().endsWith(".pdf")
|
||||
? original.substring(0, original.length() - 4)
|
||||
: original;
|
||||
return stem + "_form";
|
||||
}
|
||||
|
||||
/** Shared JSON response (mirrors the browser pipeline output). */
|
||||
public record DetectResponse(List<DetectedField> detections) {}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package stirling.software.proprietary.formdetection.controller;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.formdetection.model.ModelStatusResponse;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
|
||||
/**
|
||||
* Admin-managed lifecycle for the Auto Form Detection model. Lives under the never-gated {@code
|
||||
* form-detection-model} endpoint key so install/status stay reachable while the feature itself (the
|
||||
* {@code form-detection} detect endpoint) is disabled until a model is ready.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/form-detection-model")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Auto Form Detection")
|
||||
public class FormDetectionModelController {
|
||||
|
||||
private final FormDetectionModelManager manager;
|
||||
|
||||
@GetMapping("/status")
|
||||
@Operation(summary = "Auto Form Detection model status, progress and catalog")
|
||||
public ResponseEntity<ModelStatusResponse> status() {
|
||||
return ResponseEntity.ok(manager.status());
|
||||
}
|
||||
|
||||
@PostMapping("/install")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Install (download + checksum-verify) a catalog model")
|
||||
public ResponseEntity<ModelStatusResponse> install(@RequestBody InstallRequest request) {
|
||||
if (request == null || StringUtils.isBlank(request.getModelId())) {
|
||||
ModelStatusResponse s = manager.status();
|
||||
s.setError("modelId is required");
|
||||
return ResponseEntity.badRequest().body(s);
|
||||
}
|
||||
try {
|
||||
manager.startInstall(request.getModelId());
|
||||
return ResponseEntity.accepted().body(manager.status());
|
||||
} catch (IllegalStateException e) {
|
||||
ModelStatusResponse s = manager.status();
|
||||
s.setError(e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(s);
|
||||
} catch (IllegalArgumentException e) {
|
||||
ModelStatusResponse s = manager.status();
|
||||
s.setError(e.getMessage());
|
||||
return ResponseEntity.badRequest().body(s);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Uninstall a model")
|
||||
public ResponseEntity<ModelStatusResponse> delete(
|
||||
@RequestParam(name = "modelId", required = false) String modelId) {
|
||||
try {
|
||||
manager.deleteModel(modelId);
|
||||
return ResponseEntity.ok(manager.status());
|
||||
} catch (IllegalStateException e) {
|
||||
ModelStatusResponse s = manager.status();
|
||||
s.setError(e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(s);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/config")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Update the feature on/off switch and execution mode (auto/browser/server)")
|
||||
public ResponseEntity<ModelStatusResponse> config(@RequestBody ConfigRequest request) {
|
||||
if (request == null) {
|
||||
return ResponseEntity.badRequest().body(manager.status());
|
||||
}
|
||||
try {
|
||||
if (request.getEnabled() != null) {
|
||||
manager.setEnabled(request.getEnabled());
|
||||
}
|
||||
if (StringUtils.isNotBlank(request.getExecutionMode())) {
|
||||
manager.setExecutionMode(request.getExecutionMode());
|
||||
}
|
||||
return ResponseEntity.ok(manager.status());
|
||||
} catch (IllegalArgumentException e) {
|
||||
ModelStatusResponse s = manager.status();
|
||||
s.setError(e.getMessage());
|
||||
return ResponseEntity.badRequest().body(s);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ConfigRequest {
|
||||
/** Master on/off; {@code null} leaves it unchanged. */
|
||||
private Boolean enabled;
|
||||
|
||||
/** auto|browser|server; blank/null leaves it unchanged. */
|
||||
private String executionMode;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class InstallRequest {
|
||||
private String modelId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package stirling.software.proprietary.formdetection.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.ResourceRegion;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRange;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
|
||||
/**
|
||||
* Streams the installed .onnx to browsers for in-browser inference. Supports resumable range
|
||||
* requests with a stable (content-addressed) ETag and a public Cache-Control so each browser
|
||||
* fetches the ~200MB model at most once. The file is streamed from disk by Spring's resource
|
||||
* converters - never buffered into heap.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/form-detection-model")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Auto Form Detection")
|
||||
public class FormDetectionModelServeController {
|
||||
|
||||
private final FormDetectionModelManager manager;
|
||||
|
||||
@GetMapping("/file")
|
||||
@Operation(summary = "Stream the installed model (.onnx); supports HTTP range requests")
|
||||
public ResponseEntity<Object> serveModel(@RequestHeader HttpHeaders headers)
|
||||
throws IOException {
|
||||
Optional<Path> active = manager.getActiveModelFile();
|
||||
if (active.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Path path = active.get();
|
||||
Resource resource = new FileSystemResource(path);
|
||||
long length = resource.contentLength();
|
||||
String etag =
|
||||
"\""
|
||||
+ manager.getActiveEtag()
|
||||
.orElseGet(() -> length + "-" + path.toFile().lastModified())
|
||||
+ "\"";
|
||||
// cachePublic + maxAge override the interceptor's blanket "no-store" on /api responses.
|
||||
CacheControl cacheControl = CacheControl.maxAge(Duration.ofDays(30)).cachePublic();
|
||||
|
||||
List<HttpRange> ranges;
|
||||
try {
|
||||
ranges = headers.getRange();
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + length)
|
||||
.build();
|
||||
}
|
||||
|
||||
if (ranges.isEmpty()) {
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||
.eTag(etag)
|
||||
.cacheControl(cacheControl)
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.contentLength(length)
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
// Serve a single region (the resumable-download case). Returning a bare List would lose its
|
||||
// generic element type when the method returns ResponseEntity<Object>, leaving the region
|
||||
// converter unable to match it.
|
||||
HttpRange range = ranges.get(0);
|
||||
long start = range.getRangeStart(length);
|
||||
long count = Math.min(range.getRangeEnd(length) - start + 1, length - start);
|
||||
ResourceRegion region = new ResourceRegion(resource, start, count);
|
||||
// No explicit content type: the region converter derives it. Presetting octet-stream makes
|
||||
// the ResourceRegion converter's content-negotiation reject the body.
|
||||
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
|
||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||
.eTag(etag)
|
||||
.cacheControl(cacheControl)
|
||||
.body(region);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package stirling.software.proprietary.formdetection.inference;
|
||||
|
||||
import java.nio.FloatBuffer;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
|
||||
import ai.onnxruntime.OnnxTensor;
|
||||
import ai.onnxruntime.OnnxValue;
|
||||
import ai.onnxruntime.OrtEnvironment;
|
||||
import ai.onnxruntime.OrtException;
|
||||
import ai.onnxruntime.OrtSession;
|
||||
|
||||
/**
|
||||
* Holds the ONNX Runtime session for the active model. Lazily (re)loads when the active model
|
||||
* changes, guards session swaps with a read/write lock, and bounds concurrent inferences to limit
|
||||
* memory. The session input is NCHW float32 {@code [1,3,N,N]}; the raw output is returned as-is for
|
||||
* {@link Yolo#decode} to interpret per the model spec.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnClass(name = "ai.onnxruntime.OrtEnvironment")
|
||||
@RequiredArgsConstructor
|
||||
public class OnnxFormDetector {
|
||||
|
||||
private final FormDetectionModelManager manager;
|
||||
|
||||
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
private final Semaphore concurrency =
|
||||
new Semaphore(Math.max(1, Runtime.getRuntime().availableProcessors() / 2));
|
||||
|
||||
private volatile OrtSession session;
|
||||
private volatile String loadedModelId;
|
||||
private volatile String inputName;
|
||||
|
||||
public Yolo.RawOutput infer(float[] chw, int inputSize) {
|
||||
ensureLoaded();
|
||||
concurrency.acquireUninterruptibly();
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
OrtEnvironment env = OrtEnvironment.getEnvironment();
|
||||
long[] shape = {1, 3, inputSize, inputSize};
|
||||
try (OnnxTensor tensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(chw), shape);
|
||||
OrtSession.Result results =
|
||||
session.run(Collections.singletonMap(inputName, tensor))) {
|
||||
OnnxValue value = results.get(0);
|
||||
Object raw = value.getValue();
|
||||
if (!(raw instanceof float[][][] out3) || out3.length == 0) {
|
||||
throw new IllegalStateException(
|
||||
"Unexpected ONNX output type: "
|
||||
+ (raw == null ? "null" : raw.getClass()));
|
||||
}
|
||||
float[][] m = out3[0];
|
||||
int d1 = m.length;
|
||||
int d2 = d1 > 0 ? m[0].length : 0;
|
||||
float[] flat = new float[d1 * d2];
|
||||
for (int i = 0; i < d1; i++) {
|
||||
System.arraycopy(m[i], 0, flat, i * d2, d2);
|
||||
}
|
||||
return new Yolo.RawOutput(flat, d1, d2);
|
||||
}
|
||||
} catch (OrtException e) {
|
||||
throw new IllegalStateException("ONNX inference failed: " + e.getMessage(), e);
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
concurrency.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Force the next inference to reload from disk (called after install/uninstall). */
|
||||
public void unload() {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
closeSession();
|
||||
loadedModelId = null;
|
||||
inputName = null;
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureLoaded() {
|
||||
String activeId = manager.getActiveEntry().map(ModelCatalogEntry::getId).orElse(null);
|
||||
if (activeId == null) {
|
||||
throw new IllegalStateException("No Auto Form Detection model installed");
|
||||
}
|
||||
if (activeId.equals(loadedModelId) && session != null) {
|
||||
return;
|
||||
}
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
if (activeId.equals(loadedModelId) && session != null) {
|
||||
return;
|
||||
}
|
||||
Path file =
|
||||
manager.getActiveModelFile()
|
||||
.orElseThrow(
|
||||
() -> new IllegalStateException("Active model file missing"));
|
||||
try {
|
||||
OrtEnvironment env = OrtEnvironment.getEnvironment();
|
||||
OrtSession.SessionOptions opts = new OrtSession.SessionOptions();
|
||||
try {
|
||||
opts.setIntraOpNumThreads(
|
||||
Math.max(1, Runtime.getRuntime().availableProcessors() / 2));
|
||||
} catch (OrtException ignored) {
|
||||
// best-effort tuning
|
||||
}
|
||||
closeSession();
|
||||
session = env.createSession(file.toString(), opts);
|
||||
inputName = session.getInputNames().iterator().next();
|
||||
loadedModelId = activeId;
|
||||
log.info("Loaded ONNX session for Auto Form Detection model '{}'", activeId);
|
||||
} catch (OrtException | RuntimeException | LinkageError e) {
|
||||
// Native library missing/incompatible for this OS+arch (e.g. a Linux-slimmed jar
|
||||
// run on Windows), or a model load failure. Degrade gracefully instead of letting
|
||||
// an UnsatisfiedLinkError escape - the detect endpoint reports unavailable and the
|
||||
// server keeps running.
|
||||
throw new IllegalStateException(
|
||||
"ONNX Runtime is unavailable on this platform/build: " + e.getMessage(), e);
|
||||
}
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void close() {
|
||||
unload();
|
||||
}
|
||||
|
||||
private void closeSession() {
|
||||
if (session != null) {
|
||||
try {
|
||||
session.close();
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package stirling.software.proprietary.formdetection.inference;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
|
||||
/**
|
||||
* Pure pre/post-processing for a YOLO-style detector, driven entirely by the {@link
|
||||
* ModelCatalogEntry} spec. The browser pipeline mirrors this exactly so both inference paths agree.
|
||||
*
|
||||
* <p>Coordinate spaces: {@code preprocess} maps the source bitmap into the model's NxN input;
|
||||
* {@code decode} reads raw model output (boxes in input-pixel space), thresholds, runs NMS, and
|
||||
* un-projects boxes back to the original bitmap-pixel space (top-left origin). Mapping to PDF
|
||||
* points is done separately by {@code CoordinateMapper}.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class Yolo {
|
||||
|
||||
private Yolo() {}
|
||||
|
||||
/** Normalised model input plus the transform needed to invert it. */
|
||||
public record Preprocessed(
|
||||
float[] chw,
|
||||
int inputSize,
|
||||
float scaleX,
|
||||
float scaleY,
|
||||
int padX,
|
||||
int padY,
|
||||
int srcW,
|
||||
int srcH) {}
|
||||
|
||||
/** Raw model output flattened to {@code data[i*d2 + j]} with dims {@code d1 x d2}. */
|
||||
public record RawOutput(float[] data, int d1, int d2) {}
|
||||
|
||||
/** A detection in original bitmap-pixel space, top-left origin. */
|
||||
public record Detection(int classId, float score, float x, float y, float w, float h) {}
|
||||
|
||||
/** Letterbox/stretch-resize, normalise and lay out as NCHW float32. */
|
||||
public static Preprocessed preprocess(byte[] rgba, int srcW, int srcH, ModelCatalogEntry spec) {
|
||||
int n = spec.getInputSize();
|
||||
boolean letterbox = !"stretch".equalsIgnoreCase(spec.getResizeMode());
|
||||
|
||||
float scaleX;
|
||||
float scaleY;
|
||||
int padX;
|
||||
int padY;
|
||||
int drawW;
|
||||
int drawH;
|
||||
if (letterbox) {
|
||||
float scale = Math.min((float) n / srcW, (float) n / srcH);
|
||||
drawW = Math.max(1, Math.round(srcW * scale));
|
||||
drawH = Math.max(1, Math.round(srcH * scale));
|
||||
padX = (n - drawW) / 2;
|
||||
padY = (n - drawH) / 2;
|
||||
scaleX = scale;
|
||||
scaleY = scale;
|
||||
} else {
|
||||
drawW = n;
|
||||
drawH = n;
|
||||
padX = 0;
|
||||
padY = 0;
|
||||
scaleX = (float) n / srcW;
|
||||
scaleY = (float) n / srcH;
|
||||
}
|
||||
|
||||
int[] pad = spec.getPadColor();
|
||||
Color fill =
|
||||
new Color(
|
||||
clampByte(pad != null && pad.length > 0 ? pad[0] : 114),
|
||||
clampByte(pad != null && pad.length > 1 ? pad[1] : 114),
|
||||
clampByte(pad != null && pad.length > 2 ? pad[2] : 114));
|
||||
|
||||
BufferedImage canvas = new BufferedImage(n, n, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = canvas.createGraphics();
|
||||
try {
|
||||
g.setRenderingHint(
|
||||
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.setColor(fill);
|
||||
g.fillRect(0, 0, n, n);
|
||||
g.drawImage(rgbaToImage(rgba, srcW, srcH), padX, padY, drawW, drawH, null);
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
|
||||
boolean bgr = "bgr".equalsIgnoreCase(spec.getChannelOrder());
|
||||
float[] mean = orZeros(spec.getNormMean());
|
||||
float[] std = orOnes(spec.getNormStd());
|
||||
int plane = n * n;
|
||||
float[] chw = new float[3 * plane];
|
||||
int[] px = canvas.getRGB(0, 0, n, n, null, 0, n);
|
||||
for (int i = 0; i < plane; i++) {
|
||||
int rgb = px[i];
|
||||
float r = ((rgb >> 16) & 0xFF) / 255f;
|
||||
float gg = ((rgb >> 8) & 0xFF) / 255f;
|
||||
float b = (rgb & 0xFF) / 255f;
|
||||
float c0 = bgr ? b : r;
|
||||
float c1 = gg;
|
||||
float c2 = bgr ? r : b;
|
||||
chw[i] = (c0 - mean[0]) / std[0];
|
||||
chw[plane + i] = (c1 - mean[1]) / std[1];
|
||||
chw[2 * plane + i] = (c2 - mean[2]) / std[2];
|
||||
}
|
||||
return new Preprocessed(chw, n, scaleX, scaleY, padX, padY, srcW, srcH);
|
||||
}
|
||||
|
||||
/** Decode raw output, threshold, NMS, and un-project to original bitmap pixels. */
|
||||
public static List<Detection> decode(
|
||||
RawOutput out, ModelCatalogEntry spec, Preprocessed pre, float scoreThreshold) {
|
||||
int numClasses = spec.getClassNames() == null ? 0 : spec.getClassNames().size();
|
||||
if (numClasses == 0) {
|
||||
return List.of();
|
||||
}
|
||||
boolean obj = spec.isHasObjectness();
|
||||
boolean ncFirst = !"anchors_first".equalsIgnoreCase(spec.getOutputLayout());
|
||||
int channels = ncFirst ? out.d1() : out.d2();
|
||||
int anchors = ncFirst ? out.d2() : out.d1();
|
||||
int expected = 4 + (obj ? 1 : 0) + numClasses;
|
||||
if (channels < expected) {
|
||||
log.warn(
|
||||
"ONNX output channel count {} < expected {} (4 + obj + {} classes); skipping",
|
||||
channels,
|
||||
expected,
|
||||
numClasses);
|
||||
return List.of();
|
||||
}
|
||||
int classOffset = 4 + (obj ? 1 : 0);
|
||||
float[] data = out.data();
|
||||
|
||||
List<Detection> dets = new ArrayList<>();
|
||||
for (int a = 0; a < anchors; a++) {
|
||||
float objScore = obj ? at(data, ncFirst, anchors, channels, 4, a) : 1f;
|
||||
int bestClass = -1;
|
||||
float bestScore = 0f;
|
||||
for (int c = 0; c < numClasses; c++) {
|
||||
float s = at(data, ncFirst, anchors, channels, classOffset + c, a) * objScore;
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestClass = c;
|
||||
}
|
||||
}
|
||||
if (bestClass < 0 || bestScore < scoreThreshold) {
|
||||
continue;
|
||||
}
|
||||
float cx = at(data, ncFirst, anchors, channels, 0, a);
|
||||
float cy = at(data, ncFirst, anchors, channels, 1, a);
|
||||
float w = at(data, ncFirst, anchors, channels, 2, a);
|
||||
float h = at(data, ncFirst, anchors, channels, 3, a);
|
||||
float x1 = cx - w / 2f;
|
||||
float y1 = cy - h / 2f;
|
||||
float ox = (x1 - pre.padX()) / pre.scaleX();
|
||||
float oy = (y1 - pre.padY()) / pre.scaleY();
|
||||
float ow = w / pre.scaleX();
|
||||
float oh = h / pre.scaleY();
|
||||
// clamp to the source bitmap
|
||||
float cxl = Math.max(0, Math.min(ox, pre.srcW()));
|
||||
float cyl = Math.max(0, Math.min(oy, pre.srcH()));
|
||||
ow = Math.max(0, Math.min(ow, pre.srcW() - cxl));
|
||||
oh = Math.max(0, Math.min(oh, pre.srcH() - cyl));
|
||||
if (ow <= 0 || oh <= 0) {
|
||||
continue;
|
||||
}
|
||||
dets.add(new Detection(bestClass, bestScore, cxl, cyl, ow, oh));
|
||||
}
|
||||
return nms(dets, spec.getNms(), spec.getIou());
|
||||
}
|
||||
|
||||
private static float at(
|
||||
float[] data, boolean ncFirst, int anchors, int channels, int c, int a) {
|
||||
return ncFirst ? data[c * anchors + a] : data[a * channels + c];
|
||||
}
|
||||
|
||||
private static List<Detection> nms(List<Detection> dets, String mode, float iouThreshold) {
|
||||
if (dets.size() < 2 || "none".equalsIgnoreCase(mode)) {
|
||||
return dets;
|
||||
}
|
||||
boolean classAgnostic = mode != null && mode.toLowerCase().contains("agnostic");
|
||||
List<Detection> sorted = new ArrayList<>(dets);
|
||||
sorted.sort((x, y) -> Float.compare(y.score(), x.score()));
|
||||
boolean[] removed = new boolean[sorted.size()];
|
||||
List<Detection> keep = new ArrayList<>();
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
if (removed[i]) {
|
||||
continue;
|
||||
}
|
||||
Detection di = sorted.get(i);
|
||||
keep.add(di);
|
||||
for (int j = i + 1; j < sorted.size(); j++) {
|
||||
if (removed[j]) {
|
||||
continue;
|
||||
}
|
||||
Detection dj = sorted.get(j);
|
||||
if (!classAgnostic && di.classId() != dj.classId()) {
|
||||
continue;
|
||||
}
|
||||
if (iou(di, dj) > iouThreshold) {
|
||||
removed[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
private static float iou(Detection a, Detection b) {
|
||||
float ax2 = a.x() + a.w();
|
||||
float ay2 = a.y() + a.h();
|
||||
float bx2 = b.x() + b.w();
|
||||
float by2 = b.y() + b.h();
|
||||
float ix1 = Math.max(a.x(), b.x());
|
||||
float iy1 = Math.max(a.y(), b.y());
|
||||
float ix2 = Math.min(ax2, bx2);
|
||||
float iy2 = Math.min(ay2, by2);
|
||||
float iw = Math.max(0, ix2 - ix1);
|
||||
float ih = Math.max(0, iy2 - iy1);
|
||||
float inter = iw * ih;
|
||||
float union = a.w() * a.h() + b.w() * b.h() - inter;
|
||||
return union <= 0 ? 0 : inter / union;
|
||||
}
|
||||
|
||||
private static BufferedImage rgbaToImage(byte[] rgba, int w, int h) {
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
|
||||
int[] px = new int[w * h];
|
||||
int pixels = Math.min(w * h, rgba.length / 4);
|
||||
for (int i = 0; i < pixels; i++) {
|
||||
int r = rgba[i * 4] & 0xFF;
|
||||
int g = rgba[i * 4 + 1] & 0xFF;
|
||||
int b = rgba[i * 4 + 2] & 0xFF;
|
||||
int a = rgba[i * 4 + 3] & 0xFF;
|
||||
px[i] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
img.setRGB(0, 0, w, h, px, 0, w);
|
||||
return img;
|
||||
}
|
||||
|
||||
private static int clampByte(int v) {
|
||||
return Math.max(0, Math.min(255, v));
|
||||
}
|
||||
|
||||
private static float[] orZeros(float[] v) {
|
||||
return v != null && v.length >= 3 ? v : new float[] {0f, 0f, 0f};
|
||||
}
|
||||
|
||||
private static float[] orOnes(float[] v) {
|
||||
return v != null && v.length >= 3 ? v : new float[] {1f, 1f, 1f};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.formdetection.model;
|
||||
|
||||
/**
|
||||
* One detected form field. This is the shared schema returned by both the server detect endpoint
|
||||
* and the in-browser pipeline so the two paths are interchangeable.
|
||||
*
|
||||
* @param type AcroForm field type (text|checkbox|radio|signature)
|
||||
* @param page zero-based page index
|
||||
* @param rectInPdfPoints rectangle in PDF points (bottom-left origin)
|
||||
* @param confidence detection confidence 0-1
|
||||
*/
|
||||
public record DetectedField(String type, int page, RectPt rectInPdfPoints, double confidence) {
|
||||
|
||||
/** Rectangle in PDF points, bottom-left origin (PDF user space). */
|
||||
public record RectPt(double x, double y, double w, double h) {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.formdetection.model;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Lifecycle state of the Auto Form Detection model install. */
|
||||
public enum FormDetectionStatus {
|
||||
NOT_INSTALLED,
|
||||
DOWNLOADING,
|
||||
VERIFYING,
|
||||
READY,
|
||||
FAILED;
|
||||
|
||||
/** Lowercase wire form sent to the frontend, e.g. {@code not_installed}. */
|
||||
public String wire() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package stirling.software.proprietary.formdetection.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* One installable form-detection model plus the data-driven pre/post-processing spec the inference
|
||||
* pipeline needs. The exact same numeric values are surfaced to the browser (via the model status
|
||||
* endpoint) so the in-browser and server inference paths stay equivalent.
|
||||
*
|
||||
* <p>NOTE: the pipeline-spec defaults below follow common Ultralytics-YOLO conventions. The precise
|
||||
* values for a given model (input size, resize mode, channel order, output layout, NMS, class
|
||||
* indices) MUST be verified against the actual exported {@code .onnx} before that entry's {@code
|
||||
* onnxUrl}/{@code sha256} are populated. An entry with a blank {@code onnxUrl} or {@code sha256} is
|
||||
* not installable, which keeps the distribution shippable without any bundled model.
|
||||
*/
|
||||
@Data
|
||||
public class ModelCatalogEntry {
|
||||
|
||||
// --- Identity / distribution -------------------------------------------------
|
||||
private String id;
|
||||
private String displayName;
|
||||
private String description;
|
||||
private String license;
|
||||
private long sizeBytes;
|
||||
|
||||
/** Direct download URL of the .onnx. Blank = not yet available (install is rejected). */
|
||||
private String onnxUrl;
|
||||
|
||||
/** Lower-hex SHA-256 of the .onnx. Blank = not yet available (install is rejected). */
|
||||
private String sha256;
|
||||
|
||||
// --- Pre-processing (parity-critical, mirrored by the browser) ---------------
|
||||
/** Square model input edge in pixels. */
|
||||
private int inputSize = 1024;
|
||||
|
||||
/** "letterbox" (aspect-preserving pad) or "stretch" (resize to square). */
|
||||
private String resizeMode = "letterbox";
|
||||
|
||||
/** RGB letterbox pad colour. */
|
||||
private int[] padColor = {114, 114, 114};
|
||||
|
||||
/** "rgb" or "bgr" channel order fed to the model. */
|
||||
private String channelOrder = "rgb";
|
||||
|
||||
/**
|
||||
* Per-channel mean subtracted after dividing the raw byte by 255 ({@code (raw/255 -
|
||||
* mean)/std}).
|
||||
*/
|
||||
private float[] normMean = {0f, 0f, 0f};
|
||||
|
||||
/** Per-channel std applied after mean subtraction. */
|
||||
private float[] normStd = {1f, 1f, 1f};
|
||||
|
||||
// --- Post-processing (parity-critical) ---------------------------------------
|
||||
/** "nc_first" => output [1, 4+nc, anchors]; "anchors_first" => [1, anchors, 4+nc]. */
|
||||
private String outputLayout = "nc_first";
|
||||
|
||||
/** True if an objectness score column precedes the class scores (YOLOv5 style). */
|
||||
private boolean hasObjectness = false;
|
||||
|
||||
/** Class index -> label. */
|
||||
private List<String> classNames = List.of("text", "choice", "signature");
|
||||
|
||||
/** Class index -> AcroForm field type (text|checkbox|radio|signature). */
|
||||
private List<String> classFieldTypes = List.of("text", "checkbox", "signature");
|
||||
|
||||
private float scoreThreshold = 0.25f;
|
||||
|
||||
/** "none", "classAgnostic" or "perClass". */
|
||||
private String nms = "perClass";
|
||||
|
||||
private float iou = 0.45f;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package stirling.software.proprietary.formdetection.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Snapshot returned by {@code GET /api/v1/ai/form-detection-model/status}. Includes the full
|
||||
* catalog so the browser can read the active model's parity-critical pipeline spec.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ModelStatusResponse {
|
||||
/** Wire state: not_installed | downloading | verifying | ready | failed. */
|
||||
private String status;
|
||||
|
||||
/** Download progress 0-100 (meaningful while downloading). */
|
||||
private int progress;
|
||||
|
||||
/** Id of the active/usable model, or blank when none. */
|
||||
private String activeModelId;
|
||||
|
||||
/** Model ids that currently have an .onnx file on disk. */
|
||||
private List<String> installed;
|
||||
|
||||
/** Last error message, or null. */
|
||||
private String error;
|
||||
|
||||
/** Whether the model directory is writable (admin install possible). */
|
||||
private boolean writable;
|
||||
|
||||
/** Full curated catalog (identity + pipeline spec). */
|
||||
private List<ModelCatalogEntry> catalog;
|
||||
|
||||
/** Master on/off for the whole feature (admin-controlled). */
|
||||
private boolean enabled;
|
||||
|
||||
/** Where detection runs: auto | browser | server. */
|
||||
private String executionMode;
|
||||
|
||||
/** True if the server-side ONNX engine is bundled in this build (else only browser works). */
|
||||
private boolean serverEngineAvailable;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package stirling.software.proprietary.formdetection.render;
|
||||
|
||||
import stirling.software.proprietary.formdetection.inference.Yolo;
|
||||
import stirling.software.proprietary.formdetection.model.DetectedField;
|
||||
|
||||
/**
|
||||
* Maps a detection (original bitmap pixels, top-left origin) to PDF points (bottom-left origin),
|
||||
* accounting for the render scale and the top-left vs bottom-left origin flip.
|
||||
*/
|
||||
public final class CoordinateMapper {
|
||||
|
||||
private CoordinateMapper() {}
|
||||
|
||||
public static DetectedField.RectPt toPdfPoints(
|
||||
Yolo.Detection d, PageRasterizer.RasterPage page) {
|
||||
float sx = page.scaleX() > 0 ? page.scaleX() : 1f;
|
||||
float sy = page.scaleY() > 0 ? page.scaleY() : 1f;
|
||||
|
||||
double wPt = d.w() / sx;
|
||||
double hPt = d.h() / sy;
|
||||
double xPt = d.x() / sx;
|
||||
// Flip Y: bitmap origin is top-left, PDF origin is bottom-left.
|
||||
double yPt = page.pageHeightPt() - (d.y() / sy) - hPt;
|
||||
|
||||
xPt = clamp(xPt, 0, page.pageWidthPt());
|
||||
yPt = clamp(yPt, 0, page.pageHeightPt());
|
||||
wPt = clamp(wPt, 0, page.pageWidthPt() - xPt);
|
||||
hPt = clamp(hPt, 0, page.pageHeightPt() - yPt);
|
||||
return new DetectedField.RectPt(xPt, yPt, wPt, hPt);
|
||||
}
|
||||
|
||||
private static double clamp(double v, double lo, double hi) {
|
||||
return v < lo ? lo : Math.min(v, hi);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package stirling.software.proprietary.formdetection.render;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.PdfPage;
|
||||
import stirling.software.jpdfium.model.PageSize;
|
||||
import stirling.software.jpdfium.model.RenderResult;
|
||||
|
||||
/**
|
||||
* Renders PDF pages to RGBA bitmaps via JPDFium (the same PDFium engine the browser pipeline uses,
|
||||
* for closer parity than PDFBox's Java2D renderer). Each page is rendered at a DPI chosen so its
|
||||
* long side is approximately the model input size, minimising any later resampling. The actual
|
||||
* pixels-per-point scale is computed from the rendered dimensions so coordinate mapping does not
|
||||
* depend on how {@code renderAt} interprets its argument.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PageRasterizer {
|
||||
|
||||
/** A rendered page: RGBA pixels plus the page size (points) and px-per-point scale. */
|
||||
public record RasterPage(
|
||||
int pageIndex,
|
||||
byte[] rgba,
|
||||
int widthPx,
|
||||
int heightPx,
|
||||
float pageWidthPt,
|
||||
float pageHeightPt,
|
||||
float scaleX,
|
||||
float scaleY) {}
|
||||
|
||||
public List<RasterPage> rasterize(byte[] pdfBytes, int inputSize) {
|
||||
List<RasterPage> pages = new ArrayList<>();
|
||||
try (PdfDocument doc = PdfDocument.open(pdfBytes)) {
|
||||
int count = doc.pageCount();
|
||||
for (int i = 0; i < count; i++) {
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
PageSize size = page.size();
|
||||
float maxSide = Math.max(size.width(), size.height());
|
||||
int dpi = maxSide <= 0 ? 150 : Math.round(72f * inputSize / maxSide);
|
||||
dpi = Math.max(36, Math.min(dpi, 300));
|
||||
RenderResult r = page.renderAt(dpi);
|
||||
float scaleX = size.width() > 0 ? r.width() / size.width() : dpi / 72f;
|
||||
float scaleY = size.height() > 0 ? r.height() / size.height() : dpi / 72f;
|
||||
pages.add(
|
||||
new RasterPage(
|
||||
i,
|
||||
r.rgba(),
|
||||
r.width(),
|
||||
r.height(),
|
||||
size.width(),
|
||||
size.height(),
|
||||
scaleX,
|
||||
scaleY));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package stirling.software.proprietary.formdetection.service;
|
||||
|
||||
import static java.nio.file.StandardOpenOption.CREATE;
|
||||
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
|
||||
import static java.nio.file.StandardOpenOption.WRITE;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.formdetection.catalog.ModelCatalogService;
|
||||
import stirling.software.proprietary.formdetection.model.FormDetectionStatus;
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
import stirling.software.proprietary.formdetection.model.ModelStatusResponse;
|
||||
|
||||
/**
|
||||
* Downloads, verifies and tracks the on-demand Auto Form Detection model. Concurrency-safe
|
||||
* (single-flight install), checksum-verified, and atomic-published to a mounted volume so the model
|
||||
* survives container restarts/updates. Mirrors the OCR tessdata admin pattern but adds the lock,
|
||||
* temp-file + atomic rename, and SHA-256 verification the spec requires.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FormDetectionModelManager {
|
||||
|
||||
/** Endpoint key gated until a model is ready (drives the disabled tool tile in the UI). */
|
||||
public static final String ENDPOINT_KEY = "form-detection";
|
||||
|
||||
private static final Pattern SAFE_ID = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}");
|
||||
private static final Pattern SHA256_HEX = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
/**
|
||||
* Whether the server-side ONNX engine is bundled in this build (the onnxruntime jar is only
|
||||
* included via {@code -PbundleOnnxRuntime=true}, e.g. the Docker server image). The frontend
|
||||
* uses this to disable the "Server" execution mode when it isn't available.
|
||||
*/
|
||||
private static final boolean SERVER_ENGINE_AVAILABLE = isOnnxRuntimePresent();
|
||||
|
||||
private static boolean isOnnxRuntimePresent() {
|
||||
try {
|
||||
Class.forName(
|
||||
"ai.onnxruntime.OrtEnvironment",
|
||||
false,
|
||||
FormDetectionModelManager.class.getClassLoader());
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ModelCatalogService catalog;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private final AtomicBoolean installing = new AtomicBoolean(false);
|
||||
private volatile FormDetectionStatus state = FormDetectionStatus.NOT_INSTALLED;
|
||||
private volatile int progress = 0;
|
||||
private volatile String error = null;
|
||||
private volatile String activeSha = null;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
sweepTempFiles();
|
||||
seedPreinstalledModels();
|
||||
Optional<Path> active = getActiveModelFile();
|
||||
if (active.isPresent()) {
|
||||
activeSha = getActiveEntry().map(ModelCatalogEntry::getSha256).orElse(null);
|
||||
state = FormDetectionStatus.READY;
|
||||
log.info("Auto Form Detection model '{}' is installed and ready", activeModelId());
|
||||
} else {
|
||||
state = FormDetectionStatus.NOT_INSTALLED;
|
||||
}
|
||||
applyEndpointState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate the {@code form-detection} endpoint (which drives the tool tile): off with reason CONFIG
|
||||
* when the feature is disabled by the admin, off with reason DEPENDENCY when no model is ready,
|
||||
* otherwise on. Execution mode (browser/server) does not affect this - the tile is active
|
||||
* either way and the frontend chooses where to run.
|
||||
*/
|
||||
private void applyEndpointState() {
|
||||
if (!isFeatureEnabled()) {
|
||||
endpointConfiguration.disableEndpoint(ENDPOINT_KEY, DisableReason.CONFIG);
|
||||
} else if (state == FormDetectionStatus.READY && getActiveModelFile().isPresent()) {
|
||||
endpointConfiguration.enableEndpoint(ENDPOINT_KEY);
|
||||
} else {
|
||||
endpointConfiguration.disableEndpoint(ENDPOINT_KEY, DisableReason.DEPENDENCY);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFeatureEnabled() {
|
||||
return applicationProperties.getFormDetection().isEnabled();
|
||||
}
|
||||
|
||||
/** Master on/off (admin). Persists and re-gates the endpoint immediately. */
|
||||
public synchronized void setEnabled(boolean enabled) {
|
||||
applicationProperties.getFormDetection().setEnabled(enabled);
|
||||
try {
|
||||
GeneralUtils.saveKeyToSettings("formDetection.enabled", enabled);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not persist formDetection.enabled (state kept in memory)", e);
|
||||
}
|
||||
applyEndpointState();
|
||||
}
|
||||
|
||||
/** Set where detection runs: auto|browser|server (admin). Persists. */
|
||||
public synchronized void setExecutionMode(String mode) {
|
||||
String m = mode == null ? "auto" : mode.trim().toLowerCase(Locale.ROOT);
|
||||
if (!m.equals("auto") && !m.equals("browser") && !m.equals("server")) {
|
||||
throw new IllegalArgumentException("executionMode must be auto, browser or server");
|
||||
}
|
||||
applicationProperties.getFormDetection().setExecutionMode(m);
|
||||
try {
|
||||
GeneralUtils.saveKeyToSettings("formDetection.executionMode", m);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not persist formDetection.executionMode (state kept in memory)", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and kick off a background download+verify+install. Returns immediately; callers poll
|
||||
* {@link #status()}.
|
||||
*
|
||||
* @throws IllegalArgumentException unknown/invalid model id or bad checksum format
|
||||
* @throws IllegalStateException no URL/checksum configured, or an install is already running
|
||||
*/
|
||||
public synchronized void startInstall(String modelId) {
|
||||
if (!SAFE_ID.matcher(modelId).matches()) {
|
||||
throw new IllegalArgumentException("Invalid model id: " + modelId);
|
||||
}
|
||||
ModelCatalogEntry entry =
|
||||
catalog.getById(modelId)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("Unknown model id: " + modelId));
|
||||
// URL + checksum come ONLY from the bundled catalog (trusted constants), never from the
|
||||
// request, so an admin cannot point the download at an arbitrary host (avoids SSRF).
|
||||
String url = entry.getOnnxUrl();
|
||||
String sha = entry.getSha256() == null ? null : entry.getSha256().toLowerCase(Locale.ROOT);
|
||||
if (StringUtils.isBlank(url) || StringUtils.isBlank(sha)) {
|
||||
throw new IllegalStateException(
|
||||
"Model '" + modelId + "' has no download URL/checksum configured yet");
|
||||
}
|
||||
String scheme = URI.create(url).getScheme();
|
||||
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
|
||||
throw new IllegalArgumentException("Model URL must be http(s): " + url);
|
||||
}
|
||||
if (!SHA256_HEX.matcher(sha).matches()) {
|
||||
throw new IllegalArgumentException("Checksum must be a 64-char hex SHA-256");
|
||||
}
|
||||
if (!installing.compareAndSet(false, true)) {
|
||||
throw new IllegalStateException("An install is already in progress");
|
||||
}
|
||||
state = FormDetectionStatus.DOWNLOADING;
|
||||
progress = 0;
|
||||
error = null;
|
||||
final String fUrl = url;
|
||||
final String fSha = sha;
|
||||
Thread.ofVirtual()
|
||||
.name("form-detection-install-" + modelId)
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
doInstall(modelId, entry, fUrl, fSha);
|
||||
} catch (Exception e) {
|
||||
log.error("Auto Form Detection install failed for {}", modelId, e);
|
||||
error = e.getMessage();
|
||||
// Keep a previously-installed model usable if the new one failed.
|
||||
state =
|
||||
getActiveModelFile().isPresent()
|
||||
? FormDetectionStatus.READY
|
||||
: FormDetectionStatus.FAILED;
|
||||
} finally {
|
||||
installing.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void doInstall(String modelId, ModelCatalogEntry entry, String url, String expectedSha)
|
||||
throws IOException {
|
||||
Path dir = modelDir();
|
||||
Files.createDirectories(dir);
|
||||
if (!isWritable(dir)) {
|
||||
throw new IOException("Model directory is not writable: " + dir);
|
||||
}
|
||||
Path base = dir.toRealPath();
|
||||
Path target = base.resolve(modelId + ".onnx").normalize();
|
||||
if (!target.startsWith(base)) {
|
||||
throw new IOException("Blocked path traversal for model id " + modelId);
|
||||
}
|
||||
|
||||
// Already downloaded and intact: skip the network fetch and just (re)activate it. Makes
|
||||
// switching between already-downloaded models instant instead of re-fetching tens of MB.
|
||||
if (Files.isRegularFile(target) && expectedSha.equals(sha256OfFile(target))) {
|
||||
log.info(
|
||||
"Model '{}' already present and verified; activating without re-download",
|
||||
modelId);
|
||||
activate(modelId, expectedSha);
|
||||
return;
|
||||
}
|
||||
|
||||
Path tmp = base.resolve(modelId + ".onnx.tmp");
|
||||
|
||||
MessageDigest digest;
|
||||
try {
|
||||
digest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (Exception e) {
|
||||
throw new IOException("SHA-256 unavailable", e);
|
||||
}
|
||||
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("User-Agent", "Stirling-PDF-App");
|
||||
conn.setRequestProperty("Accept", "application/octet-stream");
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(60000);
|
||||
int http = conn.getResponseCode();
|
||||
if (http != HttpURLConnection.HTTP_OK) {
|
||||
throw new IOException("Download failed: HTTP " + http + " from " + url);
|
||||
}
|
||||
long total =
|
||||
entry.getSizeBytes() > 0 ? entry.getSizeBytes() : conn.getContentLengthLong();
|
||||
try (InputStream in = conn.getInputStream();
|
||||
OutputStream out =
|
||||
Files.newOutputStream(tmp, CREATE, TRUNCATE_EXISTING, WRITE)) {
|
||||
byte[] buf = new byte[1 << 16];
|
||||
long read = 0;
|
||||
int n;
|
||||
while ((n = in.read(buf)) >= 0) {
|
||||
out.write(buf, 0, n);
|
||||
digest.update(buf, 0, n);
|
||||
read += n;
|
||||
if (total > 0) {
|
||||
progress = (int) Math.min(99, (read * 100) / total);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
state = FormDetectionStatus.VERIFYING;
|
||||
byte[] actual = digest.digest();
|
||||
byte[] expected = HexFormat.of().parseHex(expectedSha);
|
||||
if (!MessageDigest.isEqual(actual, expected)) {
|
||||
Files.deleteIfExists(tmp);
|
||||
throw new IOException(
|
||||
"Checksum mismatch (expected "
|
||||
+ expectedSha
|
||||
+ " got "
|
||||
+ HexFormat.of().formatHex(actual)
|
||||
+ ")");
|
||||
}
|
||||
|
||||
try {
|
||||
Files.move(
|
||||
tmp,
|
||||
target,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
activate(modelId, expectedSha);
|
||||
}
|
||||
|
||||
/** Mark a verified, on-disk model as the active one and (re)enable the feature. */
|
||||
private void activate(String modelId, String expectedSha) {
|
||||
applicationProperties.getFormDetection().setActiveModelId(modelId);
|
||||
try {
|
||||
GeneralUtils.saveKeyToSettings("formDetection.activeModelId", modelId);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not persist formDetection.activeModelId (state kept in memory)", e);
|
||||
}
|
||||
activeSha = expectedSha;
|
||||
progress = 100;
|
||||
state = FormDetectionStatus.READY;
|
||||
applyEndpointState();
|
||||
log.info("Auto Form Detection model '{}' installed and ready", modelId);
|
||||
}
|
||||
|
||||
/** SHA-256 of an existing model file as lowercase hex, or {@code null} if it cannot be read. */
|
||||
private String sha256OfFile(Path file) {
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buf = new byte[1 << 16];
|
||||
int n;
|
||||
while ((n = in.read(buf)) >= 0) {
|
||||
digest.update(buf, 0, n);
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not hash existing model file {}", file, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a model file; if it was the active one, disable the feature again. */
|
||||
public synchronized void deleteModel(String modelId) {
|
||||
if (installing.get()) {
|
||||
throw new IllegalStateException("Cannot uninstall while an install is in progress");
|
||||
}
|
||||
String id = StringUtils.isNotBlank(modelId) ? modelId : activeModelId();
|
||||
if (StringUtils.isBlank(id) || !SAFE_ID.matcher(id).matches()) {
|
||||
return;
|
||||
}
|
||||
Path base = modelDir().toAbsolutePath().normalize();
|
||||
Path file = base.resolve(id + ".onnx").normalize();
|
||||
if (!file.startsWith(base)) {
|
||||
return; // path-traversal guard (SAFE_ID already blocks it; this also satisfies CodeQL)
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete model file {}", file, e);
|
||||
}
|
||||
if (id.equals(activeModelId())) {
|
||||
applicationProperties.getFormDetection().setActiveModelId("");
|
||||
try {
|
||||
GeneralUtils.saveKeyToSettings("formDetection.activeModelId", "");
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not clear formDetection.activeModelId", e);
|
||||
}
|
||||
activeSha = null;
|
||||
}
|
||||
if (getActiveModelFile().isEmpty()) {
|
||||
state = FormDetectionStatus.NOT_INSTALLED;
|
||||
error = null;
|
||||
}
|
||||
applyEndpointState();
|
||||
}
|
||||
|
||||
public ModelStatusResponse status() {
|
||||
Path dir = modelDir();
|
||||
List<String> installed = new ArrayList<>();
|
||||
if (Files.isDirectory(dir)) {
|
||||
try (DirectoryStream<Path> s = Files.newDirectoryStream(dir, "*.onnx")) {
|
||||
for (Path p : s) {
|
||||
String fn = p.getFileName().toString();
|
||||
installed.add(fn.substring(0, fn.length() - ".onnx".length()));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not list installed models in {}", dir, e);
|
||||
}
|
||||
}
|
||||
return new ModelStatusResponse(
|
||||
state.wire(),
|
||||
progress,
|
||||
activeModelId(),
|
||||
installed,
|
||||
error,
|
||||
isWritable(dir),
|
||||
catalog.getAll(),
|
||||
isFeatureEnabled(),
|
||||
applicationProperties.getFormDetection().getExecutionMode(),
|
||||
SERVER_ENGINE_AVAILABLE);
|
||||
}
|
||||
|
||||
public Optional<Path> getActiveModelFile() {
|
||||
String id = activeModelId();
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path f = modelDir().resolve(id + ".onnx");
|
||||
return Files.isRegularFile(f) ? Optional.of(f) : Optional.empty();
|
||||
}
|
||||
|
||||
public Optional<ModelCatalogEntry> getActiveEntry() {
|
||||
return catalog.getById(activeModelId());
|
||||
}
|
||||
|
||||
public Optional<String> getActiveEtag() {
|
||||
return Optional.ofNullable(activeSha);
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return isFeatureEnabled()
|
||||
&& state == FormDetectionStatus.READY
|
||||
&& getActiveModelFile().isPresent();
|
||||
}
|
||||
|
||||
private String activeModelId() {
|
||||
return applicationProperties.getFormDetection().getActiveModelId();
|
||||
}
|
||||
|
||||
private Path modelDir() {
|
||||
return Paths.get(runtimePathConfig.getFormDetectionModelPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy any image-baked models (see {@code formDetection.preinstalledModelDir}) into the
|
||||
* writable model dir if not already present, and activate one when nothing is active yet. Lets
|
||||
* the Docker server image ship with FFDNet-S ready without an admin install. No-op when the dir
|
||||
* is unset or missing (desktop/local).
|
||||
*/
|
||||
private void seedPreinstalledModels() {
|
||||
String preDir = applicationProperties.getFormDetection().getPreinstalledModelDir();
|
||||
if (StringUtils.isBlank(preDir)) {
|
||||
return;
|
||||
}
|
||||
Path src = Paths.get(preDir);
|
||||
if (!Files.isDirectory(src)) {
|
||||
return;
|
||||
}
|
||||
Path dir = modelDir();
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
} catch (IOException e) {
|
||||
log.warn("Cannot create model dir to seed pre-installed models: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (!isWritable(dir)) {
|
||||
log.warn("Model dir {} not writable; skipping pre-installed model seeding", dir);
|
||||
return;
|
||||
}
|
||||
try (DirectoryStream<Path> models = Files.newDirectoryStream(src, "*.onnx")) {
|
||||
for (Path p : models) {
|
||||
String fn = p.getFileName().toString();
|
||||
String id = fn.substring(0, fn.length() - ".onnx".length());
|
||||
if (!SAFE_ID.matcher(id).matches() || catalog.getById(id).isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Path target = dir.resolve(id + ".onnx");
|
||||
if (!Files.exists(target)) {
|
||||
Files.copy(p, target, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
log.info("Seeded pre-installed Auto Form Detection model '{}'", id);
|
||||
}
|
||||
if (StringUtils.isBlank(activeModelId())) {
|
||||
applicationProperties.getFormDetection().setActiveModelId(id);
|
||||
try {
|
||||
GeneralUtils.saveKeyToSettings("formDetection.activeModelId", id);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not persist seeded activeModelId: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to seed pre-installed models from {}: {}", src, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void sweepTempFiles() {
|
||||
Path dir = modelDir();
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return;
|
||||
}
|
||||
try (DirectoryStream<Path> s = Files.newDirectoryStream(dir, "*.tmp")) {
|
||||
for (Path p : s) {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException ignored) {
|
||||
// best-effort sweep of interrupted downloads
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("No stale form-detection temp files to sweep", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWritable(Path dir) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
if (!Files.isWritable(dir)) {
|
||||
return false;
|
||||
}
|
||||
Path probe = Files.createTempFile(dir, "fd-write-test", ".tmp");
|
||||
Files.deleteIfExists(probe);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ 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,6 +23,8 @@ 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;
|
||||
@@ -32,6 +34,7 @@ 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;
|
||||
@@ -42,14 +45,18 @@ 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;
|
||||
@@ -58,12 +65,15 @@ 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
|
||||
@@ -83,6 +93,7 @@ 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;
|
||||
@@ -107,11 +118,13 @@ public class PolicyController {
|
||||
@RequestPart("json") PipelineDefinition definition,
|
||||
@Valid @ModelAttribute PolicyRunFiles files)
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
String runId =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
PolicyRunHandle handle =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
|
||||
recordEditorDocs(inputs);
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, handle.runId(), null));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -125,6 +138,7 @@ public class PolicyController {
|
||||
@RequestPart("json") PipelineDefinition definition,
|
||||
@Valid @ModelAttribute PolicyRunFiles files)
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
|
||||
@@ -133,6 +147,7 @@ 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()
|
||||
@@ -202,7 +217,7 @@ public class PolicyController {
|
||||
+ " assigned; returns the stored policy with its id.")
|
||||
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
|
||||
requirePolicyEditingAllowed();
|
||||
Policy owned = resolveOwnership(policy);
|
||||
Policy owned = withStoredOutputSecrets(resolveOwnership(policy));
|
||||
requireAccessibleSources(owned);
|
||||
try {
|
||||
policyValidator.validate(owned);
|
||||
@@ -213,7 +228,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(saved);
|
||||
return ResponseEntity.ok(withMaskedOutputSecrets(saved));
|
||||
}
|
||||
|
||||
@PutMapping("/order")
|
||||
@@ -282,6 +297,50 @@ 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
|
||||
@@ -306,9 +365,14 @@ public class PolicyController {
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List policies",
|
||||
description = "Lists the policies belonging to the caller's team.")
|
||||
description =
|
||||
"Lists the policies belonging to the caller's team. Secret-bearing output"
|
||||
+ " options are returned as a redaction sentinel, never their stored"
|
||||
+ " values.")
|
||||
public List<Policy> listPolicies() {
|
||||
return policyAccessGuard.visibleFrom(policyStore);
|
||||
return policyAccessGuard.visibleFrom(policyStore).stream()
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
@@ -337,11 +401,17 @@ public class PolicyController {
|
||||
}
|
||||
|
||||
@GetMapping("/{policyId}")
|
||||
@Operation(summary = "Get a policy by id")
|
||||
@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.")
|
||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||
return policyStore
|
||||
.get(policyId)
|
||||
.filter(policyAccessGuard::canAccess)
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
@@ -401,6 +471,7 @@ 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));
|
||||
@@ -412,9 +483,10 @@ 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."
|
||||
+ " Empty when the sources yielded no work to do.")
|
||||
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
|
||||
+ " 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) {
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
@@ -433,6 +505,42 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -21,7 +21,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.AutomationRunContext;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
@@ -148,6 +150,7 @@ public class PolicyEngine {
|
||||
runAsPrincipal(
|
||||
billingPrincipal,
|
||||
fileOwner,
|
||||
definition.name(),
|
||||
() -> runToCompletion(run, inputs, tracking, completion));
|
||||
|
||||
// One admission unit per run; steps run synchronously within it, so this gates heavy work
|
||||
@@ -199,63 +202,78 @@ public class PolicyEngine {
|
||||
PolicyProgressListener listener,
|
||||
CompletableFuture<PolicyRun> completion) {
|
||||
String runId = run.getRunId();
|
||||
try {
|
||||
run.markRunning();
|
||||
PolicyExecutionResult result =
|
||||
stepExecutor.execute(run.getDefinition(), inputs, listener);
|
||||
OutputSpec output = run.getDefinition().output();
|
||||
List<ResultFile> outputs =
|
||||
sinkFor(output)
|
||||
.deliver(
|
||||
new OutputDelivery(runId, run.getPolicyId()),
|
||||
result.files(),
|
||||
output);
|
||||
taskManager.setMultipleFileResults(runId, outputs);
|
||||
taskManager.setComplete(runId);
|
||||
run.complete(outputs);
|
||||
} catch (PolicyInputRequiredException e) {
|
||||
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the run
|
||||
// can resume after this worker thread is gone.
|
||||
WaitState wait = suspend(e);
|
||||
run.waitForInput(wait);
|
||||
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
String message = toolTimeoutMessage(e);
|
||||
log.error(
|
||||
"Policy run {} timed out on {}: {}",
|
||||
runId,
|
||||
e.getEndpointPath(),
|
||||
e.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} catch (RestClientResponseException e) {
|
||||
// A downstream tool call returned an error status. When it's a structured entitlement
|
||||
// response (401/402 with a JSON `error` sentinel), surface that code onto the run so
|
||||
// the
|
||||
// client can react — e.g. pop the usage-limit modal — instead of only seeing a generic
|
||||
// failure. We don't interpret the code here (that would couple this module to the saas
|
||||
// billing layer); we just pass it through for the client to map. Other statuses fall
|
||||
// through to the generic failure below.
|
||||
String code = DownstreamEntitlementError.extractCode(e);
|
||||
if (code != null) {
|
||||
log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code);
|
||||
String message = "Usage limit reached";
|
||||
run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e));
|
||||
taskManager.setError(runId, message);
|
||||
} else {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
|
||||
// One policy run = one automation run. Scope the run id on this worker thread (the async
|
||||
// hop already happened) so every tool sub-step dispatched via InternalApiClient groups into
|
||||
// a single charge, and two separate policy runs on the same document stay distinct charges.
|
||||
try (AutomationRunContext.Scope runScope = AutomationRunContext.open(runId)) {
|
||||
try {
|
||||
run.markRunning();
|
||||
PolicyExecutionResult result =
|
||||
stepExecutor.execute(run.getDefinition(), inputs, listener);
|
||||
OutputSpec output = run.getDefinition().output();
|
||||
List<ResultFile> outputs =
|
||||
sinkFor(output)
|
||||
.deliver(
|
||||
new OutputDelivery(runId, run.getPolicyId()),
|
||||
result.files(),
|
||||
output);
|
||||
taskManager.setMultipleFileResults(runId, outputs);
|
||||
taskManager.setComplete(runId);
|
||||
run.complete(outputs);
|
||||
} catch (PolicyInputRequiredException e) {
|
||||
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the
|
||||
// run
|
||||
// can resume after this worker thread is gone.
|
||||
WaitState wait = suspend(e);
|
||||
run.waitForInput(wait);
|
||||
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
String message = toolTimeoutMessage(e);
|
||||
log.error(
|
||||
"Policy run {} timed out on {}: {}",
|
||||
runId,
|
||||
e.getEndpointPath(),
|
||||
e.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} catch (RestClientResponseException e) {
|
||||
// A downstream tool call returned an error status. When it's a structured
|
||||
// entitlement
|
||||
// response (401/402 with a JSON `error` sentinel), surface that code onto the run
|
||||
// so
|
||||
// the
|
||||
// client can react — e.g. pop the usage-limit modal — instead of only seeing a
|
||||
// generic
|
||||
// failure. We don't interpret the code here (that would couple this module to the
|
||||
// saas
|
||||
// billing layer); we just pass it through for the client to map. Other statuses
|
||||
// fall
|
||||
// through to the generic failure below.
|
||||
String code = DownstreamEntitlementError.extractCode(e);
|
||||
if (code != null) {
|
||||
log.info(
|
||||
"Policy run {} blocked by downstream entitlement gate ({})",
|
||||
runId,
|
||||
code);
|
||||
String message = "Usage limit reached";
|
||||
run.failWithCode(
|
||||
message, code, DownstreamEntitlementError.extractSubscribed(e));
|
||||
taskManager.setError(runId, message);
|
||||
} else {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} finally {
|
||||
// Always resolve so stream/await callers unblock.
|
||||
completion.complete(run);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} finally {
|
||||
// Always resolve so stream/await callers unblock.
|
||||
completion.complete(run);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,18 +379,26 @@ public class PolicyEngine {
|
||||
* dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is.
|
||||
* Restores the previous MDC value afterward (defensive — worker threads aren't pooled).
|
||||
*/
|
||||
private static void runAsPrincipal(String billingPrincipal, String fileOwner, Runnable body) {
|
||||
private static void runAsPrincipal(
|
||||
String billingPrincipal, String fileOwner, String policyName, Runnable body) {
|
||||
// Billing identity (MDC auditPrincipal) and output-file ownership (JobContext owner) are
|
||||
// set
|
||||
// independently: usage is charged to billingPrincipal, but stored output files are owned by
|
||||
// fileOwner — the user who triggered an org-wide policy — so they can fetch their results.
|
||||
// Either may be null (e.g. login disabled, or a trigger-fired run); each is applied only
|
||||
// when present and restored afterward (defensive — worker threads aren't pooled).
|
||||
// when present and restored afterward (defensive — worker threads aren't pooled). The
|
||||
// policy
|
||||
// name rides MDC too so each tool step's loopback dispatch (InternalApiClient) can forward
|
||||
// it as a header, letting the audit tie the step back to its policy.
|
||||
String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
|
||||
String previousPolicyName = MDC.get(InternalApiClient.POLICY_NAME_MDC_KEY);
|
||||
String previousOwner = JobContext.getOwner();
|
||||
if (billingPrincipal != null && !billingPrincipal.isBlank()) {
|
||||
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal);
|
||||
}
|
||||
if (policyName != null && !policyName.isBlank()) {
|
||||
MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, policyName);
|
||||
}
|
||||
if (fileOwner != null && !fileOwner.isBlank()) {
|
||||
JobContext.setOwner(fileOwner);
|
||||
}
|
||||
@@ -384,6 +410,11 @@ public class PolicyEngine {
|
||||
} else {
|
||||
MDC.remove(AUDIT_PRINCIPAL_MDC_KEY);
|
||||
}
|
||||
if (previousPolicyName != null) {
|
||||
MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, previousPolicyName);
|
||||
} else {
|
||||
MDC.remove(InternalApiClient.POLICY_NAME_MDC_KEY);
|
||||
}
|
||||
JobContext.setOwner(previousOwner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.EditorSource;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
@@ -44,7 +45,7 @@ public class PolicyRunner {
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
/** Full-listing sweep: resolve every source, then reconcile the ledger. */
|
||||
public List<String> run(Policy policy) {
|
||||
public SweepOutcome run(Policy policy) {
|
||||
return run(policy, SweepKind.FULL);
|
||||
}
|
||||
|
||||
@@ -52,10 +53,10 @@ public class PolicyRunner {
|
||||
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
|
||||
* one failure does not affect the others. No sources means one run with no input (generator
|
||||
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
|
||||
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
|
||||
* manual trigger can report back which runs to follow.
|
||||
* rest. Returns the ids of the runs it started plus what the sweep skipped, so a manual trigger
|
||||
* can report which runs to follow or why nothing ran.
|
||||
*/
|
||||
public List<String> run(Policy policy, SweepKind sweep) {
|
||||
public SweepOutcome run(Policy policy, SweepKind sweep) {
|
||||
long sweepStart = System.currentTimeMillis();
|
||||
PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger);
|
||||
List<String> runIds = new ArrayList<>();
|
||||
@@ -95,13 +96,19 @@ public class PolicyRunner {
|
||||
policy.id());
|
||||
}
|
||||
}
|
||||
return runIds;
|
||||
return context.outcome(runIds);
|
||||
}
|
||||
|
||||
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
|
||||
/**
|
||||
* Run a stored policy on caller-supplied files (e.g. an editor upload), bypassing its sources.
|
||||
* The supplied documents are still counted against the virtual {@link EditorSource}, scoped to
|
||||
* the policy's team, so the Sources overview reports the whole team's editor throughput.
|
||||
*/
|
||||
public PolicyRunHandle runWith(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return policyEngine.runPolicy(policy, inputs, listener);
|
||||
PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, listener);
|
||||
docCounter.record(EditorSource.counterKey(policy.teamId()), inputs.primary().size());
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** Run an ad-hoc pipeline with no stored policy (AI/Automate one-offs). */
|
||||
|
||||
@@ -86,4 +86,32 @@ final class PolicySweep implements ResolveContext {
|
||||
synchronized Set<String> presentIdentities() {
|
||||
return Set.copyOf(present);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise the sweep from state already in hand (no extra ledger reads): the prefetched rows
|
||||
* were loaded before claiming, and successful claims flipped their entries to PROCESSING, so
|
||||
* what remains DONE or ERROR is exactly what this sweep skipped.
|
||||
*/
|
||||
synchronized SweepOutcome outcome(List<String> runIds) {
|
||||
int alreadyProcessed = 0;
|
||||
int parked = 0;
|
||||
int processing = 0;
|
||||
for (String identity : present) {
|
||||
ClaimState state = prefetched.get(identity);
|
||||
if (state == null) {
|
||||
continue;
|
||||
}
|
||||
switch (state.status()) {
|
||||
case DONE -> alreadyProcessed++;
|
||||
case ERROR -> parked++;
|
||||
case PROCESSING, INTERRUPTED -> processing++;
|
||||
}
|
||||
}
|
||||
return new SweepOutcome(
|
||||
runIds,
|
||||
present.size(),
|
||||
alreadyProcessed,
|
||||
parked,
|
||||
Math.max(0, processing - runIds.size()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What one policy sweep found and started, so a manual trigger can explain an empty result instead
|
||||
* of a blanket "nothing to do": how many files the sources listed, how many were skipped because
|
||||
* they are already processed at their current version, how many are parked by a failed run (not
|
||||
* retried until they change or history is cleared), and how many are still in flight from an
|
||||
* earlier sweep. Counts are zero for {@link SweepKind#LIGHT} sweeps, which do not take a full
|
||||
* listing.
|
||||
*/
|
||||
public record SweepOutcome(
|
||||
List<String> runIds, int filesListed, int alreadyProcessed, int parked, int inFlight) {
|
||||
|
||||
public SweepOutcome {
|
||||
runIds = runIds == null ? List.of() : List.copyOf(runIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.AbstractResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
/**
|
||||
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
|
||||
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
|
||||
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
|
||||
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
|
||||
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
|
||||
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
|
||||
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
|
||||
* processed object is deleted once every policy that claimed it has settled successfully and it is
|
||||
* still the version that ran; failures stay in place and are not retried until they change) or
|
||||
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
|
||||
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
|
||||
* hidden-file rule.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3InputSource implements InputSource {
|
||||
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(InputSpec spec) {
|
||||
return spec != null && TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
|
||||
* a bucket the supplied credentials cannot list.
|
||||
*/
|
||||
@Override
|
||||
public void validate(InputSpec spec) {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
try {
|
||||
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
|
||||
} catch (SdkException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"cannot access s3://"
|
||||
+ config.bucket()
|
||||
+ "/"
|
||||
+ config.prefix()
|
||||
+ ": "
|
||||
+ e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
// A listing failure propagates so the sweep reads it as "could not list" (which vetoes
|
||||
// presence cleanup), never as "verifiably no objects".
|
||||
List<S3Object> objects = listObjects(client, config);
|
||||
|
||||
if (config.snapshot()) {
|
||||
return objects.stream()
|
||||
.map(
|
||||
object ->
|
||||
ResolvedInput.of(
|
||||
PolicyInputs.of(
|
||||
List.of(
|
||||
objectResource(
|
||||
client, config, object)))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
ctx.reportPresent(
|
||||
objects.stream()
|
||||
.map(object -> S3Identities.identity(config.bucket(), object.key()))
|
||||
.toList());
|
||||
|
||||
List<ResolvedInput> work = new ArrayList<>();
|
||||
for (S3Object object : objects) {
|
||||
String identity = S3Identities.identity(config.bucket(), object.key());
|
||||
String gate = S3Identities.gate(object.eTag(), object.size(), object.lastModified());
|
||||
if (!ctx.claim(identity, gate, null)) {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
PolicyInputs.of(List.of(objectResource(client, config, object))),
|
||||
success ->
|
||||
completeConsumed(
|
||||
ctx,
|
||||
client,
|
||||
config,
|
||||
object.key(),
|
||||
identity,
|
||||
gate,
|
||||
success)));
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle at the version this run claimed, then remove the object only when it still carries
|
||||
* that version and every policy that claimed it has settled DONE, mirroring the folder source's
|
||||
* consensus delete. A failed run settles ERROR and never deletes; the DONE row of an object
|
||||
* that could not be deleted still stops reprocessing.
|
||||
*/
|
||||
private void completeConsumed(
|
||||
ResolveContext ctx,
|
||||
S3Client client,
|
||||
S3Config config,
|
||||
String key,
|
||||
String identity,
|
||||
String claimGate,
|
||||
boolean success) {
|
||||
ctx.settle(identity, claimGate, null, success);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
HeadObjectResponse head =
|
||||
client.headObject(
|
||||
HeadObjectRequest.builder().bucket(config.bucket()).key(key).build());
|
||||
String currentGate =
|
||||
S3Identities.gate(head.eTag(), head.contentLength(), head.lastModified());
|
||||
if (currentGate.equals(claimGate) && ctx.allSettledDone(identity)) {
|
||||
client.deleteObject(
|
||||
DeleteObjectRequest.builder().bucket(config.bucket()).key(key).build());
|
||||
}
|
||||
} catch (NoSuchKeyException alreadyGone) {
|
||||
// Removed by the user or a co-watching policy's own consensus delete: nothing to do.
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return;
|
||||
}
|
||||
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
|
||||
} catch (SdkException e) {
|
||||
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Every ingestible object under the configured prefix, across all listing pages. */
|
||||
private static List<S3Object> listObjects(S3Client client, S3Config config) {
|
||||
List<S3Object> objects = new ArrayList<>();
|
||||
String continuationToken = null;
|
||||
do {
|
||||
ListObjectsV2Request.Builder request = listRequest(config);
|
||||
if (continuationToken != null) {
|
||||
request.continuationToken(continuationToken);
|
||||
}
|
||||
ListObjectsV2Response page = client.listObjectsV2(request.build());
|
||||
for (S3Object object : page.contents()) {
|
||||
if (ingestible(object)) {
|
||||
objects.add(object);
|
||||
}
|
||||
}
|
||||
continuationToken = page.nextContinuationToken();
|
||||
} while (continuationToken != null);
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static ListObjectsV2Request.Builder listRequest(S3Config config) {
|
||||
ListObjectsV2Request.Builder request =
|
||||
ListObjectsV2Request.builder().bucket(config.bucket());
|
||||
if (!config.prefix().isEmpty()) {
|
||||
request.prefix(config.prefix());
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder-placeholder keys (ending "/") and keys with a dot-prefixed segment are skipped, so a
|
||||
* hidden convention (e.g. a future output sink's staging prefix) is never re-ingested.
|
||||
*/
|
||||
private static boolean ingestible(S3Object object) {
|
||||
String key = object.key();
|
||||
if (key.isEmpty() || key.endsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
for (String segment : key.split("/")) {
|
||||
if (segment.startsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Resource objectResource(S3Client client, S3Config config, S3Object object) {
|
||||
return new S3ObjectResource(client, config.bucket(), object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the object on demand, pinned to the ETag observed at listing time so a run never
|
||||
* reads a different version than the sweep claimed (a swapped object fails the read with a
|
||||
* precondition error and the new version is claimed by a later sweep).
|
||||
*/
|
||||
private static final class S3ObjectResource extends AbstractResource {
|
||||
|
||||
private final S3Client client;
|
||||
private final String bucket;
|
||||
private final String key;
|
||||
private final String eTag;
|
||||
private final Long size;
|
||||
|
||||
private S3ObjectResource(S3Client client, String bucket, S3Object object) {
|
||||
this.client = client;
|
||||
this.bucket = bucket;
|
||||
this.key = object.key();
|
||||
this.eTag = object.eTag();
|
||||
this.size = object.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
GetObjectRequest.Builder request = GetObjectRequest.builder().bucket(bucket).key(key);
|
||||
if (eTag != null && !eTag.isBlank()) {
|
||||
request.ifMatch(eTag);
|
||||
}
|
||||
try {
|
||||
return client.getObject(request.build());
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new FileNotFoundException(getDescription() + " no longer exists");
|
||||
} catch (SdkException e) {
|
||||
throw new IOException(
|
||||
"Could not read " + getDescription() + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Listed just now; readers get a precise error from {@link #getInputStream} instead. */
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() {
|
||||
return size == null ? -1 : size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return key.substring(key.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "S3 object " + S3Identities.identity(bucket, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -82,7 +81,7 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
Resource resource = outputs.get(i);
|
||||
String name = safeName(resource.getFilename(), i);
|
||||
String name = OutputNames.safeName(resource.getFilename(), i);
|
||||
Path staged = tmpDir.resolve(UUID.randomUUID().toString());
|
||||
String contentHash = stage(resource, staged, delivery.policyId() != null);
|
||||
long size = Files.size(staged);
|
||||
@@ -198,29 +197,14 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
return Path.of(directory.toString());
|
||||
}
|
||||
|
||||
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
|
||||
private static String safeName(String filename, int index) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "output-" + index;
|
||||
}
|
||||
String name = FilenameUtils.getName(filename);
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return "output-" + index;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// Non-colliding path, appending " (n)" before the extension.
|
||||
private static Path uniqueTarget(Path dir, String filename) {
|
||||
Path candidate = dir.resolve(filename);
|
||||
if (!Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
String base = FilenameUtils.getBaseName(filename);
|
||||
String ext = FilenameUtils.getExtension(filename);
|
||||
String suffix = ext.isEmpty() ? "" : "." + ext;
|
||||
for (int n = 1; ; n++) {
|
||||
Path next = dir.resolve(base + " (" + n + ")" + suffix);
|
||||
Path next = dir.resolve(OutputNames.numbered(filename, n));
|
||||
if (!Files.exists(next)) {
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
|
||||
/** Output file naming shared by the sinks: sanitised base names and collision suffixes. */
|
||||
final class OutputNames {
|
||||
|
||||
private OutputNames() {}
|
||||
|
||||
/** Strip any directory component / "../" so a crafted output name cannot escape the target. */
|
||||
static String safeName(String filename, int index) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "output-" + index;
|
||||
}
|
||||
String name = FilenameUtils.getName(filename);
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return "output-" + index;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** The nth alternative for a taken name, appending " (n)" before the extension. */
|
||||
static String numbered(String filename, int n) {
|
||||
String base = FilenameUtils.getBaseName(filename);
|
||||
String ext = FilenameUtils.getExtension(filename);
|
||||
String suffix = ext.isEmpty() ? "" : "." + ext;
|
||||
return base + " (" + n + ")" + suffix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/**
|
||||
* Uploads a run's outputs to the bucket and key prefix given in the {@link OutputSpec} (same
|
||||
* connection options as the S3 input source; "prefix" is the destination folder). The
|
||||
* record-before-visible obligation is met without a rename step: a single-part PUT's ETag is the
|
||||
* MD5 of its content on plain and SSE-S3 buckets, so the ledger row is recorded at that predicted
|
||||
* gate BEFORE the upload, and the object is claimed under exactly the gate the next listing
|
||||
* returns. Stores where the returned ETag differs (e.g. SSE-KMS) are re-recorded at the actual gate
|
||||
* immediately after the PUT - a narrow race those buckets accept rather than a broken loop. Names
|
||||
* never overwrite: uploads are conditional on the key not existing ({@code If-None-Match: *}),
|
||||
* re-picking "name (n).ext" on collision exactly like the folder sink; stores without
|
||||
* conditional-write support fall back to an existence check per candidate.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3OutputSink implements PolicyOutputSink {
|
||||
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(OutputSpec spec) {
|
||||
return spec != null && TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* Config shape and endpoint guard only - no network probe, since write-only credentials
|
||||
* (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a
|
||||
* listing probe would wrongly reject them.
|
||||
*/
|
||||
@Override
|
||||
public void validate(OutputSpec spec) {
|
||||
connectionPool.clientFor(S3Config.from(spec.options()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResultFile> deliver(
|
||||
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
Resource resource = outputs.get(i);
|
||||
String name = OutputNames.safeName(resource.getFilename(), i);
|
||||
Path staged = Files.createTempFile("s3-output-", ".tmp");
|
||||
try {
|
||||
String predictedGate = stage(resource, staged, delivery.policyId() != null);
|
||||
long size = Files.size(staged);
|
||||
String key = upload(delivery, client, config, name, staged, predictedGate);
|
||||
String contentType =
|
||||
MediaTypeFactory.getMediaType(name)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.toString();
|
||||
results.add(
|
||||
ResultFile.builder()
|
||||
.fileId(UUID.randomUUID().toString())
|
||||
.fileName(S3Identities.identity(config.bucket(), key))
|
||||
.contentType(contentType)
|
||||
.fileSize(size)
|
||||
.build());
|
||||
log.debug(
|
||||
"Wrote policy run {} output to {}",
|
||||
delivery.runId(),
|
||||
S3Identities.identity(config.bucket(), key));
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(staged);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not remove S3 staging file {}: {}", staged, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spool the output to a local staging file (S3 needs a known content length, and the body must
|
||||
* be re-readable across collision retries). For a recorded delivery the MD5 - the predicted
|
||||
* single-part ETag - is digested in the same pass; ad-hoc runs record nothing and skip it.
|
||||
*/
|
||||
private static String stage(Resource resource, Path staged, boolean recorded)
|
||||
throws IOException {
|
||||
if (!recorded) {
|
||||
try (InputStream is = resource.getInputStream();
|
||||
OutputStream out = Files.newOutputStream(staged)) {
|
||||
is.transferTo(out);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
MessageDigest digest = newMd5();
|
||||
try (InputStream is = resource.getInputStream();
|
||||
DigestOutputStream out =
|
||||
new DigestOutputStream(Files.newOutputStream(staged), digest)) {
|
||||
is.transferTo(out);
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The S3 shape of the folder sink's record-then-rename loop. The ledger row must exist before
|
||||
* the object is visible, so it is recorded at the predicted gate before the PUT; losing the
|
||||
* chosen key to a concurrent writer (the conditional PUT fails) forgets the just-recorded row -
|
||||
* whatever object actually owns that key must stay claimable at any version - then re-picks. A
|
||||
* PUT that never made the object visible also forgets its row.
|
||||
*/
|
||||
private String upload(
|
||||
OutputDelivery delivery,
|
||||
S3Client client,
|
||||
S3Config config,
|
||||
String name,
|
||||
Path staged,
|
||||
String predictedGate)
|
||||
throws IOException {
|
||||
String keyPrefix = keyPrefix(config);
|
||||
boolean conditionalPuts = true;
|
||||
for (int attempt = 0; ; attempt++) {
|
||||
String key = keyPrefix + (attempt == 0 ? name : OutputNames.numbered(name, attempt));
|
||||
String identity = S3Identities.identity(config.bucket(), key);
|
||||
if (!conditionalPuts && exists(client, config.bucket(), key)) {
|
||||
continue;
|
||||
}
|
||||
if (delivery.policyId() != null) {
|
||||
processedLedger.recordOutput(delivery.policyId(), identity, predictedGate, null);
|
||||
}
|
||||
PutObjectRequest.Builder put =
|
||||
PutObjectRequest.builder().bucket(config.bucket()).key(key);
|
||||
if (conditionalPuts) {
|
||||
put.ifNoneMatch("*");
|
||||
}
|
||||
try {
|
||||
PutObjectResponse response =
|
||||
client.putObject(put.build(), RequestBody.fromFile(staged));
|
||||
reRecordIfGateDiffers(delivery, identity, predictedGate, response);
|
||||
return key;
|
||||
} catch (S3Exception e) {
|
||||
forgetRecorded(delivery, identity, predictedGate);
|
||||
if (conditionalPuts && e.statusCode() == 412) {
|
||||
// Known edge: if our own PUT succeeded server-side but the response was lost
|
||||
// and the SDK retried, that retry 412s here too - we then upload under the
|
||||
// next name, leaving the first object row-less (claimable, single duplicate).
|
||||
// Requires a response-lost network flake at exactly this moment; accepted.
|
||||
log.debug("Output key {} taken concurrently; re-picking", identity);
|
||||
continue;
|
||||
}
|
||||
if (conditionalPuts && e.statusCode() == 501) {
|
||||
// Store without conditional-write support: retry this candidate with a plain
|
||||
// existence check instead.
|
||||
log.debug(
|
||||
"Conditional PUT unsupported by {}; falling back to existence checks",
|
||||
config.bucket());
|
||||
conditionalPuts = false;
|
||||
attempt--;
|
||||
continue;
|
||||
}
|
||||
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
|
||||
} catch (SdkException e) {
|
||||
forgetRecorded(delivery, identity, predictedGate);
|
||||
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On buckets where a PUT's ETag is not the content MD5 (e.g. SSE-KMS), re-record at the gate
|
||||
* listings will actually return. The row is briefly at the wrong gate while the object is
|
||||
* already visible - the narrow race such stores trade for a working self-output skip.
|
||||
*/
|
||||
private void reRecordIfGateDiffers(
|
||||
OutputDelivery delivery,
|
||||
String identity,
|
||||
String predictedGate,
|
||||
PutObjectResponse response) {
|
||||
if (delivery.policyId() == null) {
|
||||
return;
|
||||
}
|
||||
String actualGate = S3Identities.gate(response.eTag(), null, null);
|
||||
if (!actualGate.equals(predictedGate)) {
|
||||
log.debug(
|
||||
"PUT ETag for {} differs from content MD5 (encrypted bucket?); re-recording",
|
||||
identity);
|
||||
processedLedger.recordOutput(delivery.policyId(), identity, actualGate, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void forgetRecorded(OutputDelivery delivery, String identity, String predictedGate) {
|
||||
if (delivery.policyId() != null) {
|
||||
processedLedger.forgetOutput(delivery.policyId(), identity, predictedGate);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean exists(S3Client client, String bucket, String key) {
|
||||
try {
|
||||
client.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
return true;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return false;
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** The configured prefix as a key-path prefix: "processed" and "processed/" mean the same. */
|
||||
private static String keyPrefix(S3Config config) {
|
||||
String prefix = config.prefix();
|
||||
if (prefix.isEmpty() || prefix.endsWith("/")) {
|
||||
return prefix;
|
||||
}
|
||||
return prefix + "/";
|
||||
}
|
||||
|
||||
private static MessageDigest newMd5() {
|
||||
try {
|
||||
return MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("MD5 unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
|
||||
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
|
||||
* is input-only and ignored by the sink.
|
||||
*/
|
||||
public record S3Config(
|
||||
String bucket,
|
||||
String region,
|
||||
String prefix,
|
||||
String endpoint,
|
||||
String accessKeyId,
|
||||
String secretAccessKey,
|
||||
boolean snapshot) {
|
||||
|
||||
private static final String BUCKET_OPTION = "bucket";
|
||||
private static final String REGION_OPTION = "region";
|
||||
private static final String PREFIX_OPTION = "prefix";
|
||||
private static final String ENDPOINT_OPTION = "endpoint";
|
||||
private static final String ACCESS_KEY_ID_OPTION = "accessKeyId";
|
||||
private static final String SECRET_ACCESS_KEY_OPTION = "secretAccessKey";
|
||||
private static final String MODE_OPTION = "mode";
|
||||
private static final String MODE_CONSUME = "consume";
|
||||
private static final String MODE_SNAPSHOT = "snapshot";
|
||||
|
||||
public static S3Config from(Map<String, Object> options) {
|
||||
String bucket = trimmed(options.get(BUCKET_OPTION));
|
||||
if (bucket == null) {
|
||||
throw new IllegalArgumentException("s3 config requires a 'bucket' option");
|
||||
}
|
||||
String region = trimmed(options.get(REGION_OPTION));
|
||||
String prefix = trimmed(options.get(PREFIX_OPTION));
|
||||
if (prefix != null && prefix.startsWith("/")) {
|
||||
prefix = prefix.substring(1);
|
||||
}
|
||||
String endpoint = validEndpoint(trimmed(options.get(ENDPOINT_OPTION)));
|
||||
String accessKeyId = trimmed(options.get(ACCESS_KEY_ID_OPTION));
|
||||
String secretAccessKey = trimmed(options.get(SECRET_ACCESS_KEY_OPTION));
|
||||
if (accessKeyId == null || secretAccessKey == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 config requires an 'accessKeyId' and 'secretAccessKey'");
|
||||
}
|
||||
String mode = trimmed(options.get(MODE_OPTION));
|
||||
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
|
||||
throw new IllegalArgumentException("s3 config 'mode' must be 'consume' or 'snapshot'");
|
||||
}
|
||||
return new S3Config(
|
||||
bucket,
|
||||
region == null ? "us-east-1" : region,
|
||||
prefix == null ? "" : prefix,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
MODE_SNAPSHOT.equals(mode));
|
||||
}
|
||||
|
||||
private static String validEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(endpoint);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException("s3 config 'endpoint' is not a valid URL", e);
|
||||
}
|
||||
if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 config 'endpoint' must be an http(s) URL, e.g. https://s3.example.com");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private static String trimmed(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/** Never prints the credentials, so an accidental log line cannot leak them. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return "S3Config[bucket="
|
||||
+ bucket
|
||||
+ ", region="
|
||||
+ region
|
||||
+ ", prefix="
|
||||
+ prefix
|
||||
+ ", endpoint="
|
||||
+ endpoint
|
||||
+ ", snapshot="
|
||||
+ snapshot
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3ClientBuilder;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
|
||||
/**
|
||||
* Long-lived {@link S3Client}s for policy S3 sources and sinks, one per distinct {@link S3Config},
|
||||
* closed at shutdown. An edited spec simply maps to a new entry, and a stale entry costs nothing
|
||||
* (the URL-connection HTTP client holds no pooled sockets or threads). Clients sign exclusively
|
||||
* with the spec's own credentials - there is deliberately no fallback to the server's AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. Endpoints are
|
||||
* guarded against private addresses before a client is ever built, since they come from portal
|
||||
* users rather than the operator.
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3ConnectionPool {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Function<S3Config, S3Client> clientFactory;
|
||||
private final Map<S3Config, S3Client> clients = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
public S3ConnectionPool(ApplicationProperties applicationProperties) {
|
||||
this(applicationProperties, S3ConnectionPool::buildClient);
|
||||
}
|
||||
|
||||
/** Factory-injecting constructor for tests. */
|
||||
public S3ConnectionPool(
|
||||
ApplicationProperties applicationProperties,
|
||||
Function<S3Config, S3Client> clientFactory) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
public S3Client clientFor(S3Config config) {
|
||||
return clients.computeIfAbsent(
|
||||
config,
|
||||
c -> {
|
||||
requirePermittedEndpoint(c);
|
||||
return clientFactory.apply(c);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-supplied endpoint must not reach loopback, link-local, or private addresses unless the
|
||||
* operator has opted in via {@code policies.allowPrivateS3Endpoints}.
|
||||
*/
|
||||
private void requirePermittedEndpoint(S3Config config) {
|
||||
if (config.endpoint() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create(config.endpoint()),
|
||||
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
|
||||
"S3 source endpoint",
|
||||
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
|
||||
+ " MinIO).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static S3Client buildClient(S3Config config) {
|
||||
S3ClientBuilder builder =
|
||||
S3Client.builder()
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.of(config.region()))
|
||||
// Path-style addressing whenever a custom endpoint is set: S3-compatible
|
||||
// stores rarely support virtual-hosted bucket DNS.
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(config.endpoint() != null)
|
||||
.build())
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(
|
||||
config.accessKeyId(), config.secretAccessKey())));
|
||||
if (config.endpoint() != null) {
|
||||
builder.endpointOverride(URI.create(config.endpoint()));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void closeClients() {
|
||||
clients.values().forEach(S3Client::close);
|
||||
clients.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The S3 backend's identity and version scheme, shared by {@code S3InputSource} and {@code
|
||||
* S3OutputSink} so outputs are recorded under exactly the identity and gate the next listing
|
||||
* derives. Identity is {@code s3://bucket/key}; the gate is the ETag every listing returns for free
|
||||
* (multipart ETags are not content hashes, so any ETag change simply reads as a new version).
|
||||
*/
|
||||
public final class S3Identities {
|
||||
|
||||
private S3Identities() {}
|
||||
|
||||
public static String identity(String bucket, String key) {
|
||||
return "s3://" + bucket + "/" + key;
|
||||
}
|
||||
|
||||
/** ETag stripped of its quotes; falls back to size:lastModified for stores that omit it. */
|
||||
public static String gate(String eTag, Long size, Instant lastModified) {
|
||||
if (eTag != null && !eTag.isBlank()) {
|
||||
return eTag.replace("\"", "");
|
||||
}
|
||||
return (size == null ? -1 : size)
|
||||
+ ":"
|
||||
+ (lastModified == null ? 0 : lastModified.toEpochMilli());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* The Editor as a virtual, always-present source. Unlike a persisted {@link Source} it is neither
|
||||
* stored nor configurable: it stands for the documents a team processes by running policies from
|
||||
* the in-app editor (the {@code POST /api/v1/policies/{id}/run} path). Its throughput is tracked
|
||||
* through {@link SourceDocCounter} under a synthetic, team-scoped key, so each team sees only its
|
||||
* own editor activity and the client is only ever handed the opaque {@link #ID}, never a team.
|
||||
*/
|
||||
public final class EditorSource {
|
||||
|
||||
/** The single, stable id and type the client sees for the editor row. */
|
||||
public static final String ID = "editor";
|
||||
|
||||
public static final String TYPE = "editor";
|
||||
|
||||
private EditorSource() {}
|
||||
|
||||
/**
|
||||
* The per-team {@link SourceDocCounter} key. A {@code null} team (login disabled / self-hosted
|
||||
* single user) shares one global bucket; otherwise counts are partitioned by team so a team's
|
||||
* total aggregates every member's editor runs and no other team's.
|
||||
*/
|
||||
public static String counterKey(Long teamId) {
|
||||
return teamId == null ? ID : ID + ":" + teamId;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ public class SourceAccessGuard {
|
||||
|
||||
/** Team a new source is stamped with: the creator's team. {@code null} when login disabled. */
|
||||
public Long teamForNewSource() {
|
||||
return currentTeamId();
|
||||
}
|
||||
|
||||
/**
|
||||
* The current user's team (what scopes their sources), or {@code null} when login is disabled.
|
||||
*/
|
||||
public Long currentTeamId() {
|
||||
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -29,6 +30,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A
|
||||
@@ -65,11 +67,16 @@ public class SourceController {
|
||||
}
|
||||
|
||||
@GetMapping("/{sourceId}")
|
||||
@Operation(summary = "Get a source by id")
|
||||
@Operation(
|
||||
summary = "Get a source by id",
|
||||
description =
|
||||
"Secret-bearing options are returned as a redaction sentinel, never their"
|
||||
+ " stored values; an edit that sends the sentinel back keeps them.")
|
||||
public ResponseEntity<Source> get(@PathVariable String sourceId) {
|
||||
return sourceStore
|
||||
.get(sourceId)
|
||||
.filter(sourceAccessGuard::canAccess)
|
||||
.map(SourceController::withMaskedSecrets)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
@@ -81,6 +88,10 @@ public class SourceController {
|
||||
"The trailing 30-day per-day document series (oldest first) for the source's"
|
||||
+ " sparkline.")
|
||||
public ResponseEntity<List<Long>> documentCounts(@PathVariable String sourceId) {
|
||||
// The editor is virtual: its series is tracked per team, not against a persisted source.
|
||||
if (EditorSource.ID.equals(sourceId)) {
|
||||
return ResponseEntity.ok(overviewService.editorDailySeries());
|
||||
}
|
||||
return sourceStore
|
||||
.get(sourceId)
|
||||
.filter(sourceAccessGuard::canAccess)
|
||||
@@ -97,7 +108,8 @@ public class SourceController {
|
||||
+ " matching source type.")
|
||||
public ResponseEntity<Source> save(@RequestBody Source source) {
|
||||
requireSourceEditingAllowed();
|
||||
Source owned = resolveOwnership(source);
|
||||
requireNotEditor(source.id(), source.type());
|
||||
Source owned = withStoredSecrets(resolveOwnership(source));
|
||||
try {
|
||||
validateConfig(owned);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -107,7 +119,7 @@ public class SourceController {
|
||||
// An edited folder source can change which directory needs watching, so re-sync trigger
|
||||
// registrations now instead of waiting for the next reconcile.
|
||||
policyTriggerManager.notifyPoliciesChanged();
|
||||
return ResponseEntity.ok(saved);
|
||||
return ResponseEntity.ok(withMaskedSecrets(saved));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{sourceId}")
|
||||
@@ -118,6 +130,7 @@ public class SourceController {
|
||||
+ " so the connection can't be pulled out from under a live policy.")
|
||||
public ResponseEntity<Void> delete(@PathVariable String sourceId) {
|
||||
requireSourceEditingAllowed();
|
||||
requireNotEditor(sourceId, null);
|
||||
Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null);
|
||||
if (source == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -169,6 +182,42 @@ public class SourceController {
|
||||
teamId);
|
||||
}
|
||||
|
||||
private static Source withOptions(Source source, Map<String, Object> options) {
|
||||
return new Source(
|
||||
source.id(),
|
||||
source.name(),
|
||||
source.type(),
|
||||
options,
|
||||
source.enabled(),
|
||||
source.owner(),
|
||||
source.teamId());
|
||||
}
|
||||
|
||||
/** Secrets never leave the server: reads return the redaction sentinel in their place. */
|
||||
private static Source withMaskedSecrets(Source source) {
|
||||
return withOptions(source, SecretMasker.mask(source.options()));
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit that round-trips a masked read sends secrets back as the sentinel; restore them from
|
||||
* the stored source so saving without re-typing keeps them (validation then runs against the
|
||||
* real values).
|
||||
*/
|
||||
private Source withStoredSecrets(Source incoming) {
|
||||
if (incoming.id() == null || incoming.id().isBlank()) {
|
||||
return incoming;
|
||||
}
|
||||
return sourceStore
|
||||
.get(incoming.id())
|
||||
.map(
|
||||
existing ->
|
||||
withOptions(
|
||||
incoming,
|
||||
SecretMasker.restoreRedacted(
|
||||
incoming.options(), existing.options())))
|
||||
.orElse(incoming);
|
||||
}
|
||||
|
||||
/** Validate the config against the bean that handles the source's type, as the engine will. */
|
||||
private void validateConfig(Source source) {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
@@ -195,6 +244,18 @@ public class SourceController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor is a built-in, virtual source: it is always present and cannot be created, edited,
|
||||
* or deleted like a persisted connection. Reject any attempt to touch it by id or type.
|
||||
*/
|
||||
private static void requireNotEditor(String id, String type) {
|
||||
if (EditorSource.ID.equals(id) || EditorSource.TYPE.equals(type)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"The editor is a built-in source and cannot be created, edited, or deleted");
|
||||
}
|
||||
}
|
||||
|
||||
/** Names of the caller's visible policies that reference the given source. */
|
||||
private List<String> referencingPolicyNames(String sourceId) {
|
||||
return policyAccessGuard.visibleFrom(policyStore).stream()
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.source;
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
@@ -11,6 +12,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
|
||||
|
||||
/**
|
||||
* JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative
|
||||
* on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code
|
||||
@@ -45,6 +48,9 @@ public class SourceEntity implements Serializable {
|
||||
@Column(name = "enabled")
|
||||
private boolean enabled;
|
||||
|
||||
// Encrypted at rest: source options carry user-supplied credentials (e.g. an S3 secret
|
||||
// access key). Lenient so rows written before encryption shipped still load.
|
||||
@Convert(converter = LenientEncryptedStringConverter.class)
|
||||
@Column(name = "source_json", columnDefinition = "text")
|
||||
private String sourceJson;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* Builds the Sources overview: every persisted source the caller's team owns, shown exactly once,
|
||||
@@ -40,7 +41,7 @@ public class SourceOverviewService {
|
||||
Map<String, DocStats> docStats =
|
||||
docCounter.statsFor(sources.stream().map(Source::id).toList());
|
||||
|
||||
List<SourceView> views =
|
||||
List<SourceView> persisted =
|
||||
sources.stream()
|
||||
.map(
|
||||
source ->
|
||||
@@ -55,7 +56,13 @@ public class SourceOverviewService {
|
||||
.thenComparing(SourceView::name))
|
||||
.toList();
|
||||
|
||||
return new SourcesResponse(buildKpis(views), views);
|
||||
// The editor is a built-in source: always present and pinned first. The KPI strip counts
|
||||
// only the connections a team configures, so the editor is left out of the KPIs.
|
||||
List<SourceView> views = new ArrayList<>();
|
||||
views.add(editorView(policies));
|
||||
views.addAll(persisted);
|
||||
|
||||
return new SourcesResponse(buildKpis(persisted), views);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +73,49 @@ public class SourceOverviewService {
|
||||
return docCounter.dailySeriesFor(sourceId);
|
||||
}
|
||||
|
||||
/** The 30-day daily editor document series (oldest first) for the caller's team. */
|
||||
public List<Long> editorDailySeries() {
|
||||
return docCounter.dailySeriesFor(
|
||||
EditorSource.counterKey(sourceAccessGuard.currentTeamId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-present editor row. It has no stored config; its documents are those the team has
|
||||
* processed by running policies from the editor, and it is "used by" every policy that targets
|
||||
* the editor as its source.
|
||||
*/
|
||||
private SourceView editorView(List<Policy> policies) {
|
||||
String key = EditorSource.counterKey(sourceAccessGuard.currentTeamId());
|
||||
DocStats docs = docCounter.statsFor(List.of(key)).getOrDefault(key, DocStats.ZERO);
|
||||
List<SourceView.PolicyRef> refs =
|
||||
policies.stream()
|
||||
.filter(SourceOverviewService::runsFromEditor)
|
||||
.map(policy -> new SourceView.PolicyRef(policy.id(), policy.name()))
|
||||
.toList();
|
||||
return new SourceView(
|
||||
EditorSource.ID,
|
||||
"Editor",
|
||||
EditorSource.TYPE,
|
||||
"active",
|
||||
refs.size(),
|
||||
refs,
|
||||
List.of(),
|
||||
docs.total(),
|
||||
docs.last24h(),
|
||||
docs.last30d());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a policy runs from the editor. Editor membership is carried in the policy's output
|
||||
* metadata ({@code output.options.sources}) - a client-side list the editor writes when a
|
||||
* policy targets it - rather than as a persisted {@code sourceId}, because the editor is
|
||||
* virtual and has no stored source to reference.
|
||||
*/
|
||||
private static boolean runsFromEditor(Policy policy) {
|
||||
Object sources = policy.output().options().get("sources");
|
||||
return sources instanceof List<?> list && list.contains(EditorSource.ID);
|
||||
}
|
||||
|
||||
/** Policies referencing each source id, across the caller's visible policies. */
|
||||
private static Map<String, List<Policy>> referencesBySource(List<Policy> policies) {
|
||||
Map<String, List<Policy>> bySource = new HashMap<>();
|
||||
@@ -104,13 +154,18 @@ public class SourceOverviewService {
|
||||
return referenceCount == 0 ? "unused" : "active";
|
||||
}
|
||||
|
||||
/** Generic key/value view of the source's config - works for any source type. */
|
||||
/**
|
||||
* Generic key/value view of the source's config - works for any source type. Secret-bearing
|
||||
* options (e.g. an S3 secret access key) are redacted, not omitted, so the overview still shows
|
||||
* that a credential is configured.
|
||||
*/
|
||||
private static List<SourceView.DetailRow> configRows(Source source) {
|
||||
return source.options().entrySet().stream()
|
||||
Map<String, Object> masked = SecretMasker.mask(source.options());
|
||||
return source.options().keySet().stream()
|
||||
.map(
|
||||
entry ->
|
||||
key ->
|
||||
new SourceView.DetailRow(
|
||||
humanize(entry.getKey()), String.valueOf(entry.getValue())))
|
||||
humanize(key), String.valueOf(masked.get(key))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store;
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
@@ -11,6 +12,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
|
||||
|
||||
/**
|
||||
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
|
||||
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
|
||||
@@ -55,6 +58,9 @@ public class PolicyEntity implements Serializable {
|
||||
@Column(name = "sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
// Encrypted at rest: output options carry user-supplied credentials (e.g. an S3 secret
|
||||
// access key). Lenient so rows written before encryption shipped still load.
|
||||
@Convert(converter = LenientEncryptedStringConverter.class)
|
||||
@Column(name = "policy_json", columnDefinition = "text")
|
||||
private String policyJson;
|
||||
}
|
||||
|
||||
@@ -99,11 +99,17 @@ public class ScheduleTrigger implements PolicyTrigger {
|
||||
// Baseline a newly-seen policy to now so it does not fire immediately.
|
||||
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
|
||||
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
|
||||
if (!next.toInstant().isAfter(now)) {
|
||||
lastFiredByPolicy.put(policy.id(), now);
|
||||
log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name());
|
||||
policyRunner.run(policy);
|
||||
if (next.toInstant().isAfter(now)) {
|
||||
continue;
|
||||
}
|
||||
ZonedDateTime later = config.schedule().nextAfter(next);
|
||||
while (!later.toInstant().isAfter(now)) {
|
||||
next = later;
|
||||
later = config.schedule().nextAfter(later);
|
||||
}
|
||||
lastFiredByPolicy.put(policy.id(), next.toInstant());
|
||||
log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name());
|
||||
policyRunner.run(policy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,20 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
||||
Page<PersistentAuditEvent> findByPrincipalIn(
|
||||
@Param("principals") List<String> principals, Pageable pageable);
|
||||
|
||||
// Noise-excluding scans for the portal: keep the page window full of meaningful events so the
|
||||
// list doesn't shrink as read/polling noise (UI_DATA/HTTP_REQUEST) grows in the recent window.
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes")
|
||||
Page<PersistentAuditEvent> findByTypeNotIn(
|
||||
@Param("excludedTypes") List<String> excludedTypes, Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes AND e.principal"
|
||||
+ " IN :principals")
|
||||
Page<PersistentAuditEvent> findByTypeNotInAndPrincipalIn(
|
||||
@Param("excludedTypes") List<String> excludedTypes,
|
||||
@Param("principals") List<String> principals,
|
||||
Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
Page<PersistentAuditEvent> findByTypeInAndTimestampBetween(
|
||||
@@ -276,4 +290,25 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
||||
@Param("source") String source,
|
||||
@Param("excludeType") String excludeType,
|
||||
@Param("since") Instant since);
|
||||
|
||||
// Team-scoped (SaaS) variants: same free-UI counts, constrained to a team's member principals.
|
||||
@Query(
|
||||
"SELECT COUNT(e) FROM PersistentAuditEvent e "
|
||||
+ "WHERE e.type IN :types AND e.source = :source "
|
||||
+ "AND e.principal IN :principals AND e.timestamp > :since")
|
||||
long countByTypeInAndSourceAndPrincipalInAndTimestampAfter(
|
||||
@Param("types") List<String> types,
|
||||
@Param("source") String source,
|
||||
@Param("principals") List<String> principals,
|
||||
@Param("since") Instant since);
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e "
|
||||
+ "WHERE e.source = :source AND e.type <> :excludeType "
|
||||
+ "AND e.principal IN :principals AND e.timestamp > :since")
|
||||
long countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter(
|
||||
@Param("source") String source,
|
||||
@Param("excludeType") String excludeType,
|
||||
@Param("principals") List<String> principals,
|
||||
@Param("since") Instant since);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
@@ -27,6 +28,7 @@ import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.AutomationRunContext;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
@@ -156,33 +158,41 @@ public class AiWorkflowService {
|
||||
throws IOException {
|
||||
validateRequest(request);
|
||||
|
||||
// Key by opaque file id, not filename. Filenames aren't guaranteed unique across an
|
||||
// upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files
|
||||
// by id in every response shape that asks Java to look a file up again.
|
||||
Map<String, MultipartFile> filesById = new LinkedHashMap<>();
|
||||
List<AiFile> files = new ArrayList<>();
|
||||
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
|
||||
MultipartFile multipartFile = fileInput.getFileInput();
|
||||
AiFile aiFile =
|
||||
new AiFile(
|
||||
fileIdStrategy.idFor(multipartFile),
|
||||
multipartFile.getOriginalFilename());
|
||||
filesById.put(aiFile.getId(), multipartFile);
|
||||
files.add(aiFile);
|
||||
}
|
||||
// One AI orchestration = one automation run. Scope a run id (on whichever thread runs
|
||||
// orchestrate — request thread for sync, stream-executor for streaming) so every tool
|
||||
// sub-step it dispatches via PolicyExecutor → InternalApiClient groups into one charge.
|
||||
try (AutomationRunContext.Scope ignored =
|
||||
AutomationRunContext.open(UUID.randomUUID().toString())) {
|
||||
|
||||
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
|
||||
initialRequest.setUserMessage(request.getUserMessage().trim());
|
||||
initialRequest.setFiles(files);
|
||||
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
|
||||
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
||||
// Key by opaque file id, not filename. Filenames aren't guaranteed unique across an
|
||||
// upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files
|
||||
// by id in every response shape that asks Java to look a file up again.
|
||||
Map<String, MultipartFile> filesById = new LinkedHashMap<>();
|
||||
List<AiFile> files = new ArrayList<>();
|
||||
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
|
||||
MultipartFile multipartFile = fileInput.getFileInput();
|
||||
AiFile aiFile =
|
||||
new AiFile(
|
||||
fileIdStrategy.idFor(multipartFile),
|
||||
multipartFile.getOriginalFilename());
|
||||
filesById.put(aiFile.getId(), multipartFile);
|
||||
files.add(aiFile);
|
||||
}
|
||||
|
||||
WorkflowState state = new WorkflowState.Pending(initialRequest);
|
||||
while (state instanceof WorkflowState.Pending pending) {
|
||||
state = advance(pending.request(), filesById, listener);
|
||||
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
|
||||
initialRequest.setUserMessage(request.getUserMessage().trim());
|
||||
initialRequest.setFiles(files);
|
||||
initialRequest.setConversationHistory(
|
||||
new ArrayList<>(request.getConversationHistory()));
|
||||
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
||||
|
||||
WorkflowState state = new WorkflowState.Pending(initialRequest);
|
||||
while (state instanceof WorkflowState.Pending pending) {
|
||||
state = advance(pending.request(), filesById, listener);
|
||||
}
|
||||
return ((WorkflowState.Terminal) state).response();
|
||||
}
|
||||
return ((WorkflowState.Terminal) state).response();
|
||||
}
|
||||
|
||||
private WorkflowState advance(
|
||||
|
||||
@@ -37,9 +37,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.accountlink.BillableOperationClassifier;
|
||||
import stirling.software.proprietary.audit.AuditContext;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -469,6 +471,73 @@ public class AuditService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge controller-supplied context into the audit data, read in the aspect's {@code finally}
|
||||
* after the controller body has run. A policy run stamps its name and step endpoints as request
|
||||
* attributes ({@link AuditContext}); the internal loopback dispatch that executes each pipeline
|
||||
* step carries the automation marker header ({@link InternalApiClient#AUTOMATION_HEADER}).
|
||||
* Surfacing these lets the portal label a run as its policy instead of the raw {@code /run}
|
||||
* endpoint, and flag its sub-steps as automation rather than direct user actions.
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param req The current request, or null when not in a web context
|
||||
*/
|
||||
public void addAutomationContext(Map<String, Object> data, HttpServletRequest req) {
|
||||
if (req == null) {
|
||||
return;
|
||||
}
|
||||
Object policyName = req.getAttribute(AuditContext.REQ_ATTR_POLICY_NAME);
|
||||
if (policyName == null) {
|
||||
// A pipeline step's loopback dispatch carries its parent policy name as a header, so
|
||||
// the
|
||||
// step audit ties back to the policy that ran it (not a bare direct call).
|
||||
String header = req.getHeader(InternalApiClient.POLICY_NAME_HEADER);
|
||||
if (header != null && !header.isBlank()) {
|
||||
policyName = header;
|
||||
}
|
||||
}
|
||||
if (policyName != null) {
|
||||
// Both sources are caller-controlled (the header is spoofable and, unlike the sender in
|
||||
// InternalApiClient, uncapped); strip newlines and cap before persisting to audit JSON.
|
||||
String safe = capLabel(String.valueOf(policyName));
|
||||
if (!safe.isEmpty()) {
|
||||
data.put("policyName", safe);
|
||||
}
|
||||
}
|
||||
Object steps = req.getAttribute(AuditContext.REQ_ATTR_POLICY_STEPS);
|
||||
if (steps instanceof List<?> list && !list.isEmpty()) {
|
||||
// Caller-supplied and unbounded; cap count and each entry so a crafted run can't
|
||||
// bloat the audit JSON the portal cache loads and parses in bulk.
|
||||
List<String> safeSteps =
|
||||
list.stream()
|
||||
.limit(MAX_POLICY_STEPS)
|
||||
.map(s -> capLabel(String.valueOf(s)))
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
if (!safeSteps.isEmpty()) {
|
||||
data.put("policySteps", safeSteps);
|
||||
}
|
||||
}
|
||||
if ("true".equalsIgnoreCase(req.getHeader(InternalApiClient.AUTOMATION_HEADER))) {
|
||||
data.put("automation", Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Max characters kept for a persisted policy label; mirrors the InternalApiClient send cap. */
|
||||
private static final int MAX_LABEL_LEN = 200;
|
||||
|
||||
/** Max step endpoints kept on a run's audit event; the portal only shows the first few. */
|
||||
private static final int MAX_POLICY_STEPS = 50;
|
||||
|
||||
/** Single-line, length-capped label safe to persist to audit JSON and render in the portal. */
|
||||
private static String capLabel(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String safe = value.replaceAll("[\\r\\n]", " ").trim();
|
||||
return safe.length() > MAX_LABEL_LEN ? safe.substring(0, MAX_LABEL_LEN) : safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add method arguments to the audit data
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.PortalAuditEventRow;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
@@ -24,12 +25,21 @@ public class PortalAuditReadService {
|
||||
/** Newest rows to scan; each surface filters this down to what it shows. */
|
||||
private static final int SCAN_LIMIT = 400;
|
||||
|
||||
/**
|
||||
* Read/polling noise excluded at the query level so the scan window stays full of meaningful
|
||||
* events. Otherwise a busy scope's recent rows fill with these and the visible list shrinks as
|
||||
* traffic grows - the "audit getting smaller over time" a user would see. No portal surface
|
||||
* shows these types anyway (the infra tab and documents feed both drop them).
|
||||
*/
|
||||
private static final List<String> NOISE_TYPES =
|
||||
List.of(AuditEventType.UI_DATA.name(), AuditEventType.HTTP_REQUEST.name());
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
|
||||
/** Recent whole-server events (admins). */
|
||||
@Cacheable(value = CACHE_NAME, key = "'server'")
|
||||
public List<PortalAuditEventRow> serverEvents() {
|
||||
return toRows(auditRepository.findAll(recentPage()).getContent());
|
||||
return toRows(auditRepository.findByTypeNotIn(NOISE_TYPES, recentPage()).getContent());
|
||||
}
|
||||
|
||||
/** Recent events by the given principals (team scope). Empty principals yield an empty list. */
|
||||
@@ -38,7 +48,10 @@ public class PortalAuditReadService {
|
||||
if (principals.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent());
|
||||
return toRows(
|
||||
auditRepository
|
||||
.findByTypeNotInAndPrincipalIn(NOISE_TYPES, principals, recentPage())
|
||||
.getContent());
|
||||
}
|
||||
|
||||
private static PageRequest recentPage() {
|
||||
|
||||
@@ -61,8 +61,17 @@ public class PortalDocumentsService {
|
||||
continue;
|
||||
}
|
||||
String path = asString(data.get("path"));
|
||||
String source = sourceLabel(asString(data.get("__origin")));
|
||||
String product = "API integration".equals(source) ? "API" : "Editor";
|
||||
// Pipeline steps run over an internal loopback (API-key auth), so origin alone reads as
|
||||
// "API". The automation marker distinguishes a policy-run step from real API traffic.
|
||||
boolean automation = isAutomation(data);
|
||||
String policyName = asString(data.get("policyName"));
|
||||
String source =
|
||||
automation
|
||||
? (policyName != null && !policyName.isBlank()
|
||||
? "Policy: " + policyName
|
||||
: "Policy automation")
|
||||
: sourceLabel(asString(data.get("__origin")));
|
||||
String product = automation ? "Automation" : productLabel(source);
|
||||
String action = prettyTool(path);
|
||||
boolean failed = isFailure(data);
|
||||
Instant ts = event.timestamp();
|
||||
@@ -174,6 +183,15 @@ public class PortalDocumentsService {
|
||||
return "Web upload";
|
||||
}
|
||||
|
||||
private static String productLabel(String source) {
|
||||
return "API integration".equals(source) ? "API" : "Editor";
|
||||
}
|
||||
|
||||
private static boolean isAutomation(Map<String, Object> data) {
|
||||
Object v = data.get("automation");
|
||||
return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v));
|
||||
}
|
||||
|
||||
private static String docType(String contentType, String name) {
|
||||
String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT);
|
||||
if (ct.contains("pdf") || name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
|
||||
|
||||
@@ -54,6 +54,7 @@ public class PortalInfraAuditService {
|
||||
.limit(RETURN_LIMIT)
|
||||
.toList();
|
||||
|
||||
int policy = (int) events.stream().filter(e -> "policy".equals(e.getCategory())).count();
|
||||
int processing =
|
||||
(int) events.stream().filter(e -> "processing".equals(e.getCategory())).count();
|
||||
int elevation =
|
||||
@@ -63,6 +64,7 @@ public class PortalInfraAuditService {
|
||||
InfraAuditSummary summary =
|
||||
InfraAuditSummary.builder()
|
||||
.totalEvents(events.size())
|
||||
.policy(policy)
|
||||
.processing(processing)
|
||||
.elevation(elevation)
|
||||
.config(config)
|
||||
@@ -84,20 +86,32 @@ public class PortalInfraAuditService {
|
||||
private InfraAuditEventDto toDto(PortalAuditEventRow event) {
|
||||
Map<String, Object> data = parseData(event);
|
||||
String path = asString(data.get("path"));
|
||||
String category = categoryFor(event.type(), path);
|
||||
String policyName = asString(data.get("policyName"));
|
||||
boolean automation = isAutomation(data);
|
||||
// Classify a dispatch by its real run-path URI, not a policyName: the latter can be spoofed
|
||||
// via the X-Stirling-Policy-Name header to make a direct call pose as a policy row.
|
||||
boolean policyDispatch = isPolicyRunPath(path) && !automation;
|
||||
// A dispatch is its own "policy" category so the UI badges it as a policy run, not a
|
||||
// generic processing op; its internal steps keep their real tool category.
|
||||
String category = policyDispatch ? "policy" : categoryFor(event.type(), path);
|
||||
|
||||
return InfraAuditEventDto.builder()
|
||||
.id(String.valueOf(event.id()))
|
||||
.timestamp(event.timestamp() == null ? "" : TS_FORMAT.format(event.timestamp()))
|
||||
.category(category)
|
||||
.action(actionFor(event.type(), path))
|
||||
.action(actionFor(event.type(), path, policyName, automation))
|
||||
.actor(event.principal())
|
||||
.target(targetFor(category, path, data))
|
||||
.target(targetFor(category, path, data, policyDispatch))
|
||||
.status(statusFor(event.type(), category, data))
|
||||
.latencyMs(asLong(data.get("latencyMs")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static boolean isAutomation(Map<String, Object> data) {
|
||||
Object v = data.get("automation");
|
||||
return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v));
|
||||
}
|
||||
|
||||
private Map<String, Object> parseData(PortalAuditEventRow event) {
|
||||
if (event.data() == null || event.data().isEmpty()) {
|
||||
return Map.of();
|
||||
@@ -140,7 +154,37 @@ public class PortalInfraAuditService {
|
||||
|| p.contains("redact");
|
||||
}
|
||||
|
||||
private static String actionFor(String type, String path) {
|
||||
/**
|
||||
* Label for a row. A genuine policy dispatch (a {@code /policies/.../run} request) shows the
|
||||
* policy it ran; an internal pipeline step (automation marker) is flagged so it isn't read as a
|
||||
* direct action. The name is only shown as the action on a real run URI, so a spoofed
|
||||
* X-Stirling-Policy-Name header on a direct tool call can't overwrite its true action.
|
||||
*/
|
||||
private static String actionFor(
|
||||
String type, String path, String policyName, boolean automation) {
|
||||
if (!automation && isPolicyRunPath(path)) {
|
||||
// A run with no name (ad-hoc pipeline) still reads better than the "run" endpoint.
|
||||
return policyName != null ? policyName : "Policy run";
|
||||
}
|
||||
String base = baseActionFor(type, path);
|
||||
if (automation) {
|
||||
return policyName != null
|
||||
? base + " (policy: " + policyName + ")"
|
||||
: base + " (automation)";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pipeline-run endpoints: {@code /policies/run}, {@code /run/stream}, {@code /{id}/run}.
|
||||
*/
|
||||
private static boolean isPolicyRunPath(String path) {
|
||||
return path != null
|
||||
&& path.contains("/policies/")
|
||||
&& (path.endsWith("/run") || path.endsWith("/run/stream"));
|
||||
}
|
||||
|
||||
private static String baseActionFor(String type, String path) {
|
||||
AuditEventType t = AuditEventType.fromString(type);
|
||||
if (t == null) {
|
||||
return prettyTool(path);
|
||||
@@ -191,7 +235,32 @@ public class PortalInfraAuditService {
|
||||
return sb.isEmpty() ? "PDF operation" : sb.toString();
|
||||
}
|
||||
|
||||
private static String targetFor(String category, String path, Map<String, Object> data) {
|
||||
/** "Auto Redact, Compress PDF" from the run's step endpoints; first three, then "+N more". */
|
||||
private static String prettyStepList(Object steps) {
|
||||
if (!(steps instanceof List<?> list) || list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int shown = Math.min(3, list.size());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < shown; i++) {
|
||||
if (i > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(prettyTool(asString(list.get(i))));
|
||||
}
|
||||
if (list.size() > shown) {
|
||||
sb.append(" +").append(list.size() - shown).append(" more");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String targetFor(
|
||||
String category, String path, Map<String, Object> data, boolean policyDispatch) {
|
||||
if (policyDispatch) {
|
||||
// The run touches no single file at this level; show the tools the policy runs instead.
|
||||
String steps = prettyStepList(data.get("policySteps"));
|
||||
return steps != null ? steps : "Pipeline";
|
||||
}
|
||||
if ("auth".equals(category)) {
|
||||
// Auth events don't act on a resource; the session is the closest thing.
|
||||
return "Web session";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -13,10 +14,15 @@ import stirling.software.common.util.RegexPatternUtils;
|
||||
@Slf4j
|
||||
public final class SecretMasker {
|
||||
|
||||
/** The placeholder masked values are replaced with; reads as "a secret is set". */
|
||||
public static final String REDACTED = "********";
|
||||
|
||||
private static final Pattern SENSITIVE =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPattern(
|
||||
"(?i)\\b(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
// secret[_-]?access[_-]?key precedes plain secret so camelCase keys
|
||||
// like secretAccessKey (no word boundary after "secret") still match.
|
||||
"(?i)\\b(password|token|secret[_-]?access[_-]?key|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
|
||||
private SecretMasker() {}
|
||||
|
||||
@@ -47,8 +53,28 @@ public final class SecretMasker {
|
||||
|
||||
private static Object deepMaskValue(String key, Object value) {
|
||||
if (key != null && SENSITIVE.matcher(key).find()) {
|
||||
return "***REDACTED***";
|
||||
return REDACTED;
|
||||
}
|
||||
return deepMask(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore top-level values the caller sent back as the {@link #REDACTED} sentinel from the
|
||||
* stored map, so a masked read can round-trip through an edit without re-typing secrets. A
|
||||
* sentinel with no stored counterpart is left as-is (it fails whatever validates it, rather
|
||||
* than silently passing an unset secret).
|
||||
*/
|
||||
public static Map<String, Object> restoreRedacted(
|
||||
Map<String, Object> incoming, Map<String, Object> stored) {
|
||||
if (incoming == null || stored == null) {
|
||||
return incoming;
|
||||
}
|
||||
Map<String, Object> merged = new LinkedHashMap<>(incoming);
|
||||
merged.replaceAll(
|
||||
(key, value) ->
|
||||
REDACTED.equals(value) && stored.containsKey(key)
|
||||
? stored.get(key)
|
||||
: value);
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"id": "ffdnet-s",
|
||||
"displayName": "CommonForms FFDNet-S (Small)",
|
||||
"description": "Small, fast detector with lower memory use - a good default for most forms. Finds text inputs, checkboxes, and signature fields.",
|
||||
"license": "CommonForms dataset CC-BY-4.0; Ultralytics-YOLO lineage AGPL-3.0. Downloaded on demand, never bundled.",
|
||||
"sizeBytes": 38370092,
|
||||
"onnxUrl": "https://huggingface.co/jbarrow/FFDNet-S-cpu/resolve/main/FFDNet-S.onnx",
|
||||
"sha256": "93bccf47c048f9f947f9b1b52d002edf144a8a583dae39f164d9e5725321acc0",
|
||||
"inputSize": 1216,
|
||||
"resizeMode": "stretch",
|
||||
"padColor": [114, 114, 114],
|
||||
"channelOrder": "bgr",
|
||||
"normMean": [0.0, 0.0, 0.0],
|
||||
"normStd": [1.0, 1.0, 1.0],
|
||||
"outputLayout": "nc_first",
|
||||
"hasObjectness": false,
|
||||
"classNames": ["text", "choice", "signature"],
|
||||
"classFieldTypes": ["text", "checkbox", "signature"],
|
||||
"scoreThreshold": 0.3,
|
||||
"nms": "perClass",
|
||||
"iou": 0.45
|
||||
},
|
||||
{
|
||||
"id": "ffdnet-l",
|
||||
"displayName": "CommonForms FFDNet-L (Large)",
|
||||
"description": "Larger, higher-accuracy detector (~25M parameters) with better recall on dense or complex forms - uses more memory. Finds text inputs, checkboxes, and signature fields.",
|
||||
"license": "CommonForms dataset CC-BY-4.0; Ultralytics-YOLO lineage AGPL-3.0. Downloaded on demand, never bundled.",
|
||||
"sizeBytes": 101944542,
|
||||
"onnxUrl": "https://huggingface.co/jbarrow/FFDNet-L-cpu/resolve/main/FFDNet-L.onnx",
|
||||
"sha256": "e00c59edd9a5275dab5847d38f042c8ecc827063650c8aac22b0e486c414cd35",
|
||||
"inputSize": 1216,
|
||||
"resizeMode": "stretch",
|
||||
"padColor": [114, 114, 114],
|
||||
"channelOrder": "bgr",
|
||||
"normMean": [0.0, 0.0, 0.0],
|
||||
"normStd": [1.0, 1.0, 1.0],
|
||||
"outputLayout": "nc_first",
|
||||
"hasObjectness": false,
|
||||
"classNames": ["text", "choice", "signature"],
|
||||
"classFieldTypes": ["text", "checkbox", "signature"],
|
||||
"scoreThreshold": 0.3,
|
||||
"nms": "perClass",
|
||||
"iou": 0.45
|
||||
}
|
||||
]
|
||||
@@ -2,6 +2,7 @@ package stirling.software.proprietary.access.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
@@ -258,6 +259,24 @@ class ResourceAccessServiceTest {
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamLeadDefaultOnTeamResourceDeniesForeignTeamsLead() {
|
||||
// Team-owned resource: the default admits only the owning team's leads. A lead of
|
||||
// some other team must be denied even though an unscoped "is any team leader"
|
||||
// check would admit them (lenient stub: the scoped path must never consult it).
|
||||
stubGrants();
|
||||
User foreignLead = user(6);
|
||||
lenient().when(teamLeadLookup.isAnyTeamLeader(foreignLead)).thenReturn(true);
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE,
|
||||
RID,
|
||||
PrincipalRef.team(7L),
|
||||
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS,
|
||||
foreignLead))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
// ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ----
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package stirling.software.proprietary.formdetection.catalog;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class ModelCatalogServiceTest {
|
||||
|
||||
@Test
|
||||
void loadsBundledCatalogWithSpecDefaults() {
|
||||
ModelCatalogService service = new ModelCatalogService(JsonMapper.builder().build());
|
||||
service.load();
|
||||
|
||||
List<ModelCatalogEntry> all = service.getAll();
|
||||
assertTrue(all.size() >= 2, "catalog should ship with at least two entries");
|
||||
assertTrue(service.getById("ffdnet-s").isPresent());
|
||||
|
||||
ModelCatalogEntry l = service.getById("ffdnet-l").orElseThrow();
|
||||
assertEquals(3, l.getClassNames().size());
|
||||
assertEquals(3, l.getClassFieldTypes().size());
|
||||
assertTrue(l.getInputSize() > 0);
|
||||
|
||||
// Model-free distribution: the jar bundles no weights. Every entry instead carries a
|
||||
// download URL and a SHA-256 so the model is fetched and integrity-verified on demand.
|
||||
for (ModelCatalogEntry e : all) {
|
||||
assertNotNull(e.getOnnxUrl(), e.getId() + " must declare a download URL");
|
||||
assertFalse(e.getOnnxUrl().isBlank(), e.getId() + " must declare a download URL");
|
||||
assertNotNull(e.getSha256(), e.getId() + " must declare a SHA-256 checksum");
|
||||
assertFalse(e.getSha256().isBlank(), e.getId() + " must declare a SHA-256 checksum");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownIdReturnsEmpty() {
|
||||
ModelCatalogService service = new ModelCatalogService(JsonMapper.builder().build());
|
||||
service.load();
|
||||
assertTrue(service.getById("does-not-exist").isEmpty());
|
||||
assertTrue(service.getById(null).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package stirling.software.proprietary.formdetection.controller;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.formdetection.inference.OnnxFormDetector;
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
import stirling.software.proprietary.formdetection.render.PageRasterizer;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
|
||||
class FormDetectionControllerTest {
|
||||
|
||||
private MockMvc mvc(
|
||||
FormDetectionModelManager manager,
|
||||
OnnxFormDetector detector,
|
||||
PageRasterizer rasterizer) {
|
||||
FormDetectionController controller =
|
||||
new FormDetectionController(
|
||||
manager,
|
||||
detector,
|
||||
rasterizer,
|
||||
Mockito.mock(CustomPDFDocumentFactory.class),
|
||||
Mockito.mock(TempFileManager.class));
|
||||
return MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
private MockMultipartFile pdf() {
|
||||
return new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectReturns503WhenModelNotReady() throws Exception {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.isReady()).thenReturn(false);
|
||||
|
||||
mvc(manager, Mockito.mock(OnnxFormDetector.class), Mockito.mock(PageRasterizer.class))
|
||||
.perform(multipart("/api/v1/ai/form-detection/detect").file(pdf()))
|
||||
.andExpect(status().isServiceUnavailable())
|
||||
.andExpect(jsonPath("$.reason").value("DEPENDENCY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectReturnsEmptyDetectionsForBlankRender() throws Exception {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.isReady()).thenReturn(true);
|
||||
Mockito.when(manager.getActiveEntry()).thenReturn(Optional.of(new ModelCatalogEntry()));
|
||||
|
||||
PageRasterizer rasterizer = Mockito.mock(PageRasterizer.class);
|
||||
Mockito.when(rasterizer.rasterize(Mockito.any(), Mockito.anyInt()))
|
||||
.thenReturn(List.of()); // no pages -> no detections, detector never called
|
||||
|
||||
mvc(manager, Mockito.mock(OnnxFormDetector.class), rasterizer)
|
||||
.perform(multipart("/api/v1/ai/form-detection/detect").file(pdf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.detections").isArray())
|
||||
.andExpect(jsonPath("$.detections").isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package stirling.software.proprietary.formdetection.controller;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import stirling.software.proprietary.formdetection.model.ModelStatusResponse;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
|
||||
class FormDetectionModelControllerTest {
|
||||
|
||||
private ModelStatusResponse notInstalled() {
|
||||
return new ModelStatusResponse(
|
||||
"not_installed", 0, "", List.of(), null, true, List.of(), true, "auto", true);
|
||||
}
|
||||
|
||||
private MockMvc mvc(FormDetectionModelManager manager) {
|
||||
return MockMvcBuilders.standaloneSetup(new FormDetectionModelController(manager)).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusReturnsJson() throws Exception {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.status()).thenReturn(notInstalled());
|
||||
|
||||
mvc(manager)
|
||||
.perform(get("/api/v1/ai/form-detection-model/status"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("not_installed"))
|
||||
.andExpect(jsonPath("$.writable").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void installWithBlankModelIdReturns400() throws Exception {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.status()).thenReturn(notInstalled());
|
||||
|
||||
mvc(manager)
|
||||
.perform(
|
||||
post("/api/v1/ai/form-detection-model/install")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"modelId\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void installValidReturns202() throws Exception {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.status()).thenReturn(notInstalled());
|
||||
|
||||
mvc(manager)
|
||||
.perform(
|
||||
post("/api/v1/ai/form-detection-model/install")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"modelId\":\"ffdnet-s\"}"))
|
||||
.andExpect(status().isAccepted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void installWhileBusyReturns409() throws Exception {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.status()).thenReturn(notInstalled());
|
||||
Mockito.doThrow(new IllegalStateException("An install is already in progress"))
|
||||
.when(manager)
|
||||
.startInstall(Mockito.eq("ffdnet-s"));
|
||||
|
||||
mvc(manager)
|
||||
.perform(
|
||||
post("/api/v1/ai/form-detection-model/install")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"modelId\":\"ffdnet-s\"}"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteReturnsStatus() throws Exception {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.status()).thenReturn(notInstalled());
|
||||
|
||||
mvc(manager)
|
||||
.perform(delete("/api/v1/ai/form-detection-model"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("not_installed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package stirling.software.proprietary.formdetection.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.ResourceRegion;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRange;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
|
||||
/**
|
||||
* Tests the serve controller's logic directly (status, headers, region bounds). Serializing a
|
||||
* ResourceRegion needs the full Spring resource converters, which standaloneSetup does not wire, so
|
||||
* exercising the method directly is both simpler and converter-independent.
|
||||
*/
|
||||
class FormDetectionModelServeControllerTest {
|
||||
|
||||
private FormDetectionModelManager managerWith(Path model) {
|
||||
FormDetectionModelManager manager = Mockito.mock(FormDetectionModelManager.class);
|
||||
Mockito.when(manager.getActiveModelFile())
|
||||
.thenReturn(model == null ? Optional.empty() : Optional.of(model));
|
||||
Mockito.when(manager.getActiveEtag()).thenReturn(Optional.of("a".repeat(64)));
|
||||
return manager;
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesFullModelWithPublicCacheAndAcceptRanges(@TempDir Path dir) throws Exception {
|
||||
Path model = dir.resolve("m.onnx");
|
||||
Files.write(model, "abcdefghij".getBytes());
|
||||
|
||||
ResponseEntity<Object> resp =
|
||||
new FormDetectionModelServeController(managerWith(model))
|
||||
.serveModel(new HttpHeaders());
|
||||
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
assertEquals("bytes", resp.getHeaders().getFirst(HttpHeaders.ACCEPT_RANGES));
|
||||
assertNotNull(resp.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL));
|
||||
assertTrue(resp.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL).contains("public"));
|
||||
assertNotNull(resp.getHeaders().getETag());
|
||||
assertTrue(resp.getBody() instanceof Resource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesRangeRequestAsSingleRegion(@TempDir Path dir) throws Exception {
|
||||
Path model = dir.resolve("m.onnx");
|
||||
Files.write(model, "abcdefghij".getBytes());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setRange(List.of(HttpRange.createByteRange(0, 3)));
|
||||
|
||||
ResponseEntity<Object> resp =
|
||||
new FormDetectionModelServeController(managerWith(model)).serveModel(headers);
|
||||
|
||||
assertEquals(HttpStatus.PARTIAL_CONTENT, resp.getStatusCode());
|
||||
assertEquals("bytes", resp.getHeaders().getFirst(HttpHeaders.ACCEPT_RANGES));
|
||||
assertTrue(resp.getBody() instanceof ResourceRegion, "body should be a single region");
|
||||
ResourceRegion region = (ResourceRegion) resp.getBody();
|
||||
assertEquals(0L, region.getPosition());
|
||||
assertEquals(4L, region.getCount()); // bytes 0-3 inclusive
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns404WhenNoModelInstalled() throws Exception {
|
||||
ResponseEntity<Object> resp =
|
||||
new FormDetectionModelServeController(managerWith(null))
|
||||
.serveModel(new HttpHeaders());
|
||||
assertEquals(HttpStatus.NOT_FOUND, resp.getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package stirling.software.proprietary.formdetection.inference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
|
||||
class YoloTest {
|
||||
|
||||
private ModelCatalogEntry spec() {
|
||||
ModelCatalogEntry e = new ModelCatalogEntry();
|
||||
e.setInputSize(10);
|
||||
e.setResizeMode("letterbox");
|
||||
e.setChannelOrder("rgb");
|
||||
e.setOutputLayout("nc_first");
|
||||
e.setHasObjectness(false);
|
||||
e.setClassNames(List.of("text", "choice"));
|
||||
e.setClassFieldTypes(List.of("text", "checkbox"));
|
||||
e.setNms("perClass");
|
||||
e.setIou(0.5f);
|
||||
return e;
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeThresholdsAndSuppressesOverlaps() {
|
||||
ModelCatalogEntry spec = spec();
|
||||
// identity transform (scale 1, no pad), 10x10 source
|
||||
Yolo.Preprocessed pre = new Yolo.Preprocessed(new float[0], 10, 1f, 1f, 0, 0, 10, 10);
|
||||
|
||||
// nc_first layout [channels=6][anchors=3], data[c*anchors + a]
|
||||
// box A (cx5,cy5,w4,h4) twice (overlapping) + box B (cx8,cy8,w2,h2)
|
||||
float[] data = {
|
||||
5,
|
||||
5,
|
||||
8, // cx
|
||||
5,
|
||||
5,
|
||||
8, // cy
|
||||
4,
|
||||
4,
|
||||
2, // w
|
||||
4,
|
||||
4,
|
||||
2, // h
|
||||
0.9f,
|
||||
0.8f,
|
||||
0.7f, // text score
|
||||
0.1f,
|
||||
0.1f,
|
||||
0.1f // choice score
|
||||
};
|
||||
Yolo.RawOutput out = new Yolo.RawOutput(data, 6, 3);
|
||||
|
||||
List<Yolo.Detection> dets = Yolo.decode(out, spec, pre, 0.5f);
|
||||
|
||||
// a0 (box A, 0.9) kept; a1 (box A', 0.8) suppressed by NMS; a2 (box B, 0.7) kept
|
||||
assertEquals(2, dets.size());
|
||||
|
||||
Yolo.Detection a = dets.get(0);
|
||||
assertEquals(0, a.classId());
|
||||
assertEquals(0.9f, a.score(), 1e-5);
|
||||
assertEquals(3f, a.x(), 1e-4);
|
||||
assertEquals(3f, a.y(), 1e-4);
|
||||
assertEquals(4f, a.w(), 1e-4);
|
||||
assertEquals(4f, a.h(), 1e-4);
|
||||
|
||||
Yolo.Detection b = dets.get(1);
|
||||
assertEquals(0.7f, b.score(), 1e-5);
|
||||
assertEquals(7f, b.x(), 1e-4);
|
||||
assertEquals(7f, b.y(), 1e-4);
|
||||
assertEquals(2f, b.w(), 1e-4);
|
||||
assertEquals(2f, b.h(), 1e-4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeIsDeterministic() {
|
||||
ModelCatalogEntry spec = spec();
|
||||
Yolo.Preprocessed pre = new Yolo.Preprocessed(new float[0], 10, 1f, 1f, 0, 0, 10, 10);
|
||||
float[] data = {5, 5, 8, 5, 5, 8, 4, 4, 2, 4, 4, 2, 0.9f, 0.8f, 0.7f, 0.1f, 0.1f, 0.1f};
|
||||
Yolo.RawOutput out = new Yolo.RawOutput(data, 6, 3);
|
||||
assertEquals(
|
||||
Yolo.decode(out, spec, pre, 0.5f).toString(),
|
||||
Yolo.decode(out, spec, pre, 0.5f).toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdDropsLowScores() {
|
||||
ModelCatalogEntry spec = spec();
|
||||
Yolo.Preprocessed pre = new Yolo.Preprocessed(new float[0], 10, 1f, 1f, 0, 0, 10, 10);
|
||||
float[] data = {5, 5, 8, 5, 5, 8, 4, 4, 2, 4, 4, 2, 0.9f, 0.8f, 0.7f, 0.1f, 0.1f, 0.1f};
|
||||
Yolo.RawOutput out = new Yolo.RawOutput(data, 6, 3);
|
||||
// threshold above every score -> nothing survives
|
||||
assertEquals(0, Yolo.decode(out, spec, pre, 0.95f).size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package stirling.software.proprietary.formdetection.render;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.formdetection.inference.Yolo;
|
||||
import stirling.software.proprietary.formdetection.model.DetectedField;
|
||||
|
||||
class CoordinateMapperTest {
|
||||
|
||||
@Test
|
||||
void mapsBitmapPixelsToPdfPointsWithYFlip() {
|
||||
// 200x300pt page rendered at 2 px/pt (400x600 px)
|
||||
PageRasterizer.RasterPage page =
|
||||
new PageRasterizer.RasterPage(0, new byte[0], 400, 600, 200f, 300f, 2f, 2f);
|
||||
// detection at top-left (10,20) px, 40x60 px
|
||||
Yolo.Detection d = new Yolo.Detection(0, 0.9f, 10f, 20f, 40f, 60f);
|
||||
|
||||
DetectedField.RectPt r = CoordinateMapper.toPdfPoints(d, page);
|
||||
|
||||
assertEquals(5.0, r.x(), 1e-4); // 10/2
|
||||
assertEquals(20.0, r.w(), 1e-4); // 40/2
|
||||
assertEquals(30.0, r.h(), 1e-4); // 60/2
|
||||
// Y flip: pageHeight - (yTopPx/scale) - hPt = 300 - 10 - 30
|
||||
assertEquals(260.0, r.y(), 1e-4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clampsToPageBounds() {
|
||||
PageRasterizer.RasterPage page =
|
||||
new PageRasterizer.RasterPage(0, new byte[0], 200, 200, 100f, 100f, 2f, 2f);
|
||||
// box partly off the right/bottom edge in px
|
||||
Yolo.Detection d = new Yolo.Detection(0, 0.5f, 180f, 0f, 60f, 40f);
|
||||
|
||||
DetectedField.RectPt r = CoordinateMapper.toPdfPoints(d, page);
|
||||
|
||||
// x = 90pt, width clamped to 100-90 = 10pt
|
||||
assertEquals(90.0, r.x(), 1e-4);
|
||||
assertEquals(10.0, r.w(), 1e-4);
|
||||
// stays within the page
|
||||
org.junit.jupiter.api.Assertions.assertEquals(true, r.x() + r.w() <= 100.0 + 1e-6);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(true, r.y() >= -1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package stirling.software.proprietary.formdetection.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.formdetection.catalog.ModelCatalogService;
|
||||
import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
|
||||
|
||||
class FormDetectionModelManagerTest {
|
||||
|
||||
private HttpServer server;
|
||||
private byte[] modelBytes;
|
||||
private String modelSha;
|
||||
private int port;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws Exception {
|
||||
modelBytes = "fake-onnx-model-content-1234567890".getBytes();
|
||||
modelSha =
|
||||
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(modelBytes));
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
port = server.getAddress().getPort();
|
||||
server.createContext(
|
||||
"/model.onnx",
|
||||
ex -> {
|
||||
ex.sendResponseHeaders(200, modelBytes.length);
|
||||
ex.getResponseBody().write(modelBytes);
|
||||
ex.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private ModelCatalogEntry entry(String url, String sha) {
|
||||
ModelCatalogEntry e = new ModelCatalogEntry();
|
||||
e.setId("test-model");
|
||||
e.setOnnxUrl(url);
|
||||
e.setSha256(sha);
|
||||
e.setSizeBytes(modelBytes.length);
|
||||
return e;
|
||||
}
|
||||
|
||||
private FormDetectionModelManager manager(
|
||||
Path dir, ModelCatalogEntry entry, EndpointConfiguration ep) {
|
||||
RuntimePathConfig paths = Mockito.mock(RuntimePathConfig.class);
|
||||
Mockito.when(paths.getFormDetectionModelPath()).thenReturn(dir.toString());
|
||||
ModelCatalogService catalog = Mockito.mock(ModelCatalogService.class);
|
||||
Mockito.when(catalog.getById("test-model")).thenReturn(Optional.of(entry));
|
||||
Mockito.when(catalog.getById(Mockito.argThat(s -> !"test-model".equals(s))))
|
||||
.thenReturn(Optional.empty());
|
||||
Mockito.when(catalog.getAll()).thenReturn(List.of(entry));
|
||||
return new FormDetectionModelManager(paths, catalog, new ApplicationProperties(), ep);
|
||||
}
|
||||
|
||||
private void awaitState(FormDetectionModelManager m, String wire, long timeoutMs)
|
||||
throws InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (wire.equals(m.status().getStatus())) {
|
||||
return;
|
||||
}
|
||||
Thread.sleep(25);
|
||||
}
|
||||
fail("Timed out waiting for state '" + wire + "', was '" + m.status().getStatus() + "'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void installsDownloadsVerifiesAndPublishesAtomically(@TempDir Path dir) throws Exception {
|
||||
EndpointConfiguration ep = Mockito.mock(EndpointConfiguration.class);
|
||||
FormDetectionModelManager m =
|
||||
manager(dir, entry("http://127.0.0.1:" + port + "/model.onnx", modelSha), ep);
|
||||
|
||||
m.startInstall("test-model");
|
||||
awaitState(m, "ready", 5000);
|
||||
|
||||
Path onnx = dir.resolve("test-model.onnx");
|
||||
assertTrue(Files.exists(onnx), "model file should be published");
|
||||
assertArrayEquals(modelBytes, Files.readAllBytes(onnx));
|
||||
assertFalse(Files.exists(dir.resolve("test-model.onnx.tmp")), "temp file should be gone");
|
||||
assertTrue(m.isReady());
|
||||
Mockito.verify(ep).enableEndpoint("form-detection");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsChecksumMismatchAndLeavesNoFile(@TempDir Path dir) throws Exception {
|
||||
EndpointConfiguration ep = Mockito.mock(EndpointConfiguration.class);
|
||||
FormDetectionModelManager m =
|
||||
manager(dir, entry("http://127.0.0.1:" + port + "/model.onnx", "0".repeat(64)), ep);
|
||||
|
||||
m.startInstall("test-model");
|
||||
awaitState(m, "failed", 5000);
|
||||
|
||||
assertFalse(Files.exists(dir.resolve("test-model.onnx")), "no model on mismatch");
|
||||
assertFalse(Files.exists(dir.resolve("test-model.onnx.tmp")), "temp cleaned up");
|
||||
assertFalse(m.isReady());
|
||||
Mockito.verify(ep, Mockito.never()).enableEndpoint("form-detection");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondConcurrentInstallIsRejected(@TempDir Path dir) throws Exception {
|
||||
CountDownLatch gate = new CountDownLatch(1);
|
||||
server.createContext(
|
||||
"/gated.onnx",
|
||||
ex -> {
|
||||
try {
|
||||
gate.await(3, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
ex.sendResponseHeaders(200, modelBytes.length);
|
||||
ex.getResponseBody().write(modelBytes);
|
||||
ex.close();
|
||||
});
|
||||
FormDetectionModelManager m =
|
||||
manager(
|
||||
dir,
|
||||
entry("http://127.0.0.1:" + port + "/gated.onnx", modelSha),
|
||||
Mockito.mock(EndpointConfiguration.class));
|
||||
|
||||
m.startInstall("test-model"); // begins, blocks in handler
|
||||
// installing flag is set synchronously before the worker thread spawns
|
||||
assertThrows(IllegalStateException.class, () -> m.startInstall("test-model"));
|
||||
gate.countDown();
|
||||
awaitState(m, "ready", 5000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankUrl(@TempDir Path dir) {
|
||||
FormDetectionModelManager m =
|
||||
manager(dir, entry("", ""), Mockito.mock(EndpointConfiguration.class));
|
||||
assertThrows(IllegalStateException.class, () -> m.startInstall("test-model"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownModelId(@TempDir Path dir) {
|
||||
FormDetectionModelManager m =
|
||||
manager(
|
||||
dir,
|
||||
entry("http://127.0.0.1:" + port + "/model.onnx", modelSha),
|
||||
Mockito.mock(EndpointConfiguration.class));
|
||||
assertThrows(IllegalArgumentException.class, () -> m.startInstall("unknown"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LenientEncryptedStringConverterTest {
|
||||
|
||||
private final LenientEncryptedStringConverter converter = new LenientEncryptedStringConverter();
|
||||
|
||||
@BeforeAll
|
||||
static void initKey() throws Exception {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(256);
|
||||
SecretKey key = generator.generateKey();
|
||||
CredentialEncryption.initialiseForTesting(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsThroughCiphertext() {
|
||||
String json = "{\"bucket\":\"inbox\",\"secretAccessKey\":\"shh\"}";
|
||||
|
||||
String stored = converter.convertToDatabaseColumn(json);
|
||||
|
||||
assertThat(stored).isNotEqualTo(json).doesNotContain("shh");
|
||||
assertThat(converter.convertToEntityAttribute(stored)).isEqualTo(json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyPlaintextRowsPassThroughOnRead() {
|
||||
String legacy = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
|
||||
|
||||
assertThat(converter.convertToEntityAttribute(legacy)).isEqualTo(legacy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullsPassThrough() {
|
||||
assertThat(converter.convertToDatabaseColumn(null)).isNull();
|
||||
assertThat(converter.convertToEntityAttribute(null)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@@ -17,6 +18,7 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -34,16 +36,21 @@ 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.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunView;
|
||||
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.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("PolicyController")
|
||||
@@ -54,6 +61,7 @@ class PolicyControllerTest {
|
||||
@Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore;
|
||||
@Mock private SourceStore sourceStore;
|
||||
@Mock private SourceAccessGuard sourceAccessGuard;
|
||||
@Mock private SourceDocCounter docCounter;
|
||||
@Mock private PolicyValidator policyValidator;
|
||||
@Mock private PolicyAccessGuard policyAccessGuard;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
@@ -87,6 +95,7 @@ class PolicyControllerTest {
|
||||
policyStore,
|
||||
sourceStore,
|
||||
sourceAccessGuard,
|
||||
docCounter,
|
||||
policyValidator,
|
||||
policyAccessGuard,
|
||||
policyManagementAuthority,
|
||||
@@ -128,6 +137,17 @@ class PolicyControllerTest {
|
||||
return new Policy(id, "name", "owner", true, null, List.of(), List.of(), null, teamId);
|
||||
}
|
||||
|
||||
private static Policy s3OutputPolicy(String id, String secret) {
|
||||
OutputSpec output =
|
||||
new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "outbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", secret));
|
||||
return new Policy(id, "name", "owner", true, null, List.of(), List.of(), output, 1L);
|
||||
}
|
||||
|
||||
private static PolicyRunHandle handle(String runId) {
|
||||
PolicyRun run = new PolicyRun(runId, null, definitionWithStep());
|
||||
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
|
||||
@@ -150,6 +170,18 @@ class PolicyControllerTest {
|
||||
assertThat(response.getBody().getJobId()).isEqualTo("run-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("feeds the editor source, scoped to the caller's team")
|
||||
void adHocRunFeedsTheEditorSource() throws Exception {
|
||||
when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP)))
|
||||
.thenReturn(handle("run-1"));
|
||||
when(sourceAccessGuard.currentTeamId()).thenReturn(3L);
|
||||
|
||||
controller.run(definitionWithStep(), new PolicyRunFiles());
|
||||
|
||||
verify(docCounter).record(EditorSource.counterKey(3L), 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a pipeline with no steps")
|
||||
void rejectsEmptyPipeline() {
|
||||
@@ -263,6 +295,27 @@ class PolicyControllerTest {
|
||||
verify(policyTriggerManager).notifyPoliciesChanged();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("saving the sentinel back keeps the stored output secret")
|
||||
void saveRestoresOutputSecrets() {
|
||||
applicationProperties.getSecurity().setEnableLogin(false);
|
||||
Policy existing = s3OutputPolicy("p1", "shh");
|
||||
when(policyStore.get("p1")).thenReturn(Optional.of(existing));
|
||||
when(policyAccessGuard.canAccess(existing)).thenReturn(true);
|
||||
when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
ResponseEntity<Policy> response =
|
||||
controller.savePolicy(s3OutputPolicy("p1", SecretMasker.REDACTED));
|
||||
|
||||
ArgumentCaptor<Policy> stored = ArgumentCaptor.forClass(Policy.class);
|
||||
verify(policyStore).save(stored.capture());
|
||||
assertThat(stored.getValue().output().options().get("secretAccessKey"))
|
||||
.isEqualTo("shh");
|
||||
// The save response is masked again; only the store sees the real value.
|
||||
assertThat(response.getBody().output().options().get("secretAccessKey"))
|
||||
.isEqualTo(SecretMasker.REDACTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("forbidden when login enabled and caller cannot edit")
|
||||
void forbidden() {
|
||||
@@ -363,6 +416,20 @@ class PolicyControllerTest {
|
||||
assertThat(response.getBody().id()).isEqualTo("a");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getPolicy returns output secrets as the redaction sentinel")
|
||||
void getMasksOutputSecrets() {
|
||||
Policy p = s3OutputPolicy("a", "shh");
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
|
||||
Policy read = controller.getPolicy("a").getBody();
|
||||
|
||||
assertThat(read.output().options().get("secretAccessKey"))
|
||||
.isEqualTo(SecretMasker.REDACTED);
|
||||
assertThat(read.output().options().get("bucket")).isEqualTo("outbox");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getPolicy returns 404 when not accessible")
|
||||
void getNotAccessible() {
|
||||
@@ -536,17 +603,18 @@ class PolicyControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trigger runs an accessible policy against its sources and returns run ids")
|
||||
@DisplayName("trigger runs an accessible policy against its sources and returns the sweep")
|
||||
void triggersRun() {
|
||||
Policy p = policy("a", 1L);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
|
||||
SweepOutcome outcome = new SweepOutcome(List.of("run-a", "run-b"), 3, 1, 0, 0);
|
||||
when(policyRunner.run(p)).thenReturn(outcome);
|
||||
|
||||
ResponseEntity<List<String>> response = controller.trigger("a");
|
||||
ResponseEntity<SweepOutcome> response = controller.trigger("a");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
assertThat(response.getBody()).containsExactly("run-a", "run-b");
|
||||
assertThat(response.getBody()).isEqualTo(outcome);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -26,10 +27,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
|
||||
import stirling.software.proprietary.policy.input.InputSource;
|
||||
import stirling.software.proprietary.policy.input.ResolveContext;
|
||||
import stirling.software.proprietary.policy.input.ResolvedInput;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
@@ -39,6 +42,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.EditorSource;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
@@ -56,6 +60,7 @@ class PolicyRunnerTest {
|
||||
@Mock private ProcessedLedger processedLedger;
|
||||
|
||||
private final SourceStore sourceStore = new InProcessSourceStore();
|
||||
private final InProcessSourceDocCounter docCounter = new InProcessSourceDocCounter();
|
||||
private PolicyRunner runner;
|
||||
|
||||
@BeforeEach
|
||||
@@ -65,7 +70,7 @@ class PolicyRunnerTest {
|
||||
policyEngine,
|
||||
List.of(folderSource),
|
||||
sourceStore,
|
||||
new InProcessSourceDocCounter(),
|
||||
docCounter,
|
||||
processedLedger);
|
||||
}
|
||||
|
||||
@@ -85,6 +90,42 @@ class PolicyRunnerTest {
|
||||
verify(processedLedger).deleteUnseen(eq("p1"), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsWhatTheSweepSkippedSoAnEmptyTriggerExplainsItself() throws Exception {
|
||||
InProcessProcessedLedger ledger = new InProcessProcessedLedger();
|
||||
PolicyRunner reporting =
|
||||
new PolicyRunner(
|
||||
policyEngine,
|
||||
List.of(folderSource),
|
||||
sourceStore,
|
||||
new InProcessSourceDocCounter(),
|
||||
ledger);
|
||||
InputSpec spec = InputSpec.folder("/in");
|
||||
Policy policy = policy(List.of(spec));
|
||||
// One file already processed at its current version, one parked by a failed run.
|
||||
ledger.claim("p1", "/in/done.pdf", "g1", null);
|
||||
ledger.settle("p1", "/in/done.pdf", "g1", null, true);
|
||||
ledger.claim("p1", "/in/failed.pdf", "g2", null);
|
||||
ledger.settle("p1", "/in/failed.pdf", "g2", null, false);
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
ResolveContext ctx = invocation.getArgument(1);
|
||||
ctx.reportPresent(List.of("/in/done.pdf", "/in/failed.pdf"));
|
||||
// Both are at their settled versions, so neither claims.
|
||||
return List.of();
|
||||
});
|
||||
|
||||
SweepOutcome outcome = reporting.run(policy);
|
||||
|
||||
assertTrue(outcome.runIds().isEmpty());
|
||||
assertEquals(2, outcome.filesListed());
|
||||
assertEquals(1, outcome.alreadyProcessed());
|
||||
assertEquals(1, outcome.parked());
|
||||
assertEquals(0, outcome.inFlight());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pullsEverySourceAndRunsOnePerUnitOfWork() throws Exception {
|
||||
InputSpec spec = InputSpec.folder("/in");
|
||||
@@ -257,6 +298,33 @@ class PolicyRunnerTest {
|
||||
verifyNoInteractions(folderSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runWithRecordsSuppliedDocsAgainstTheEditorSourceForThePolicyTeam() {
|
||||
Policy policy =
|
||||
new Policy(
|
||||
"p1",
|
||||
"p",
|
||||
"owner",
|
||||
true,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline(),
|
||||
7L);
|
||||
PolicyInputs inputs =
|
||||
PolicyInputs.of(
|
||||
List.of(
|
||||
new ByteArrayResource("a".getBytes()),
|
||||
new ByteArrayResource("b".getBytes())));
|
||||
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.runWith(policy, inputs, PolicyProgressListener.NOOP);
|
||||
|
||||
String key = EditorSource.counterKey(7L);
|
||||
assertEquals(2, docCounter.statsFor(List.of(key)).get(key).total());
|
||||
}
|
||||
|
||||
/** Persists each spec as a source and returns a policy referencing them by id. */
|
||||
private Policy policy(List<InputSpec> sources) {
|
||||
List<String> sourceIds =
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
/**
|
||||
* End-to-end {@link S3InputSource} test against a real S3 API (MinIO), through the production
|
||||
* client factory: listing, claiming, streaming, consensus delete, and save-time validation.
|
||||
*/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3InputSourceMinioTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
@Container
|
||||
static MinIOContainer minio =
|
||||
new MinIOContainer("minio/minio:latest")
|
||||
.withUserName(ACCESS_KEY)
|
||||
.withPassword(SECRET_KEY);
|
||||
|
||||
private static S3Client adminClient;
|
||||
private static int bucketCounter;
|
||||
|
||||
private String bucket;
|
||||
private S3InputSource source;
|
||||
private InProcessProcessedLedger ledger;
|
||||
private RecordingContext ctx;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
if (adminClient == null) {
|
||||
adminClient =
|
||||
S3Client.builder()
|
||||
.endpointOverride(java.net.URI.create(minio.getS3URL()))
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.build();
|
||||
}
|
||||
bucket = "policy-inbox-" + ++bucketCounter;
|
||||
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
|
||||
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
source = new S3InputSource(new S3ConnectionPool(properties));
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumeListsStreamsAndDeletesByConsensus() throws IOException {
|
||||
put("incoming/doc.pdf", "pdf bytes");
|
||||
put("incoming/other.txt", "text");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx);
|
||||
|
||||
assertThat(work).hasSize(2);
|
||||
assertThat(ctx.present)
|
||||
.containsExactlyInAnyOrder(
|
||||
"s3://" + bucket + "/incoming/doc.pdf",
|
||||
"s3://" + bucket + "/incoming/other.txt");
|
||||
assertThat(read(work.get(0))).isIn("pdf bytes", "text");
|
||||
// In flight: nothing to claim on a second sweep.
|
||||
assertThat(source.resolve(spec(Map.of("prefix", "incoming/")), ctx)).isEmpty();
|
||||
|
||||
work.forEach(unit -> unit.onComplete().accept(true));
|
||||
assertThat(exists("incoming/doc.pdf")).isFalse();
|
||||
assertThat(exists("incoming/other.txt")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedObjectStaysInTheBucket() throws IOException {
|
||||
put("doc.pdf", "data");
|
||||
|
||||
source.resolve(spec(Map.of()), ctx).get(0).onComplete().accept(false);
|
||||
|
||||
assertThat(exists("doc.pdf")).isTrue();
|
||||
assertThat(source.resolve(spec(Map.of()), ctx)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectOverwrittenMidRunSurvivesTheDeleteAndRunsAgain() throws IOException {
|
||||
put("doc.pdf", "v1");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(Map.of()), ctx);
|
||||
put("doc.pdf", "v2 with a different etag");
|
||||
work.get(0).onComplete().accept(true);
|
||||
|
||||
assertThat(exists("doc.pdf")).isTrue();
|
||||
assertThat(source.resolve(spec(Map.of()), ctx)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefixLimitsWhatIsRead() throws IOException {
|
||||
put("incoming/doc.pdf", "data");
|
||||
put("archive/old.pdf", "data");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx);
|
||||
|
||||
assertThat(work).hasSize(1);
|
||||
assertThat(ctx.present).containsExactly("s3://" + bucket + "/incoming/doc.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateAcceptsAReachableBucketAndRejectsBadCredentials() {
|
||||
source.validate(spec(Map.of()));
|
||||
|
||||
Map<String, Object> wrongSecret = new HashMap<>(baseOptions());
|
||||
wrongSecret.put("secretAccessKey", "not-the-secret");
|
||||
assertThatThrownBy(() -> source.validate(new InputSpec("s3", wrongSecret)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("cannot access");
|
||||
|
||||
Map<String, Object> missingBucket = new HashMap<>(baseOptions());
|
||||
missingBucket.put("bucket", "no-such-bucket-here");
|
||||
assertThatThrownBy(() -> source.validate(new InputSpec("s3", missingBucket)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("cannot access");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
|
||||
S3InputSource guarded =
|
||||
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
|
||||
|
||||
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("policies.allowPrivateS3Endpoints");
|
||||
}
|
||||
|
||||
private Map<String, Object> baseOptions() {
|
||||
return Map.of(
|
||||
"bucket", bucket,
|
||||
"endpoint", minio.getS3URL(),
|
||||
"accessKeyId", ACCESS_KEY,
|
||||
"secretAccessKey", SECRET_KEY);
|
||||
}
|
||||
|
||||
private InputSpec spec(Map<String, Object> extra) {
|
||||
Map<String, Object> options = new HashMap<>(baseOptions());
|
||||
options.putAll(extra);
|
||||
return new InputSpec("s3", options);
|
||||
}
|
||||
|
||||
private void put(String key, String content) {
|
||||
adminClient.putObject(
|
||||
PutObjectRequest.builder().bucket(bucket).key(key).build(),
|
||||
RequestBody.fromString(content, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private boolean exists(String key) {
|
||||
try {
|
||||
adminClient.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
return true;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String read(ResolvedInput unit) throws IOException {
|
||||
try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) {
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final List<String> present = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(POLICY, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {
|
||||
present.addAll(identities);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
import software.amazon.awssdk.http.AbortableInputStream;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3InputSource}: consume mode tracks objects in place through the ledger and
|
||||
* removes them by consensus, snapshot stays stateless, and discovery skips folder placeholders and
|
||||
* dot-prefixed keys.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class S3InputSourceTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String BUCKET = "inbox-bucket";
|
||||
|
||||
@Mock private S3Client s3Client;
|
||||
|
||||
private S3InputSource source;
|
||||
private InProcessProcessedLedger ledger;
|
||||
private RecordingContext ctx;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumeRemovesTheObjectOnceProcessed() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
headReturns("doc.pdf", "\"etag-1\"");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(), ctx);
|
||||
|
||||
assertEquals(1, work.size());
|
||||
assertEquals(1, work.get(0).inputs().primary().size());
|
||||
// In flight: a second sweep does not pick it up again.
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
|
||||
work.get(0).onComplete().accept(true);
|
||||
verify(s3Client).deleteObject(any(DeleteObjectRequest.class));
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectReplacedMidRunSurvivesTheDelete() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
// The object is overwritten while the run is executing.
|
||||
headReturns("doc.pdf", "\"etag-2\"");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(), ctx);
|
||||
work.get(0).onComplete().accept(true);
|
||||
|
||||
// The delete is version-guarded: the replacement is not the object that ran, so it stays
|
||||
// and is claimed as fresh work instead of being marked processed.
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
listingReturns(object("doc.pdf", "\"etag-2\""));
|
||||
assertEquals(1, source.resolve(spec(), ctx).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSharedObjectIsRemovedOnlyOnceEveryPolicyHasProcessedIt() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
headReturns("doc.pdf", "\"etag-1\"");
|
||||
RecordingContext other = new RecordingContext("p2");
|
||||
|
||||
List<ResolvedInput> mine = source.resolve(spec(), ctx);
|
||||
List<ResolvedInput> theirs = source.resolve(spec(), other);
|
||||
assertEquals(1, mine.size());
|
||||
assertEquals(1, theirs.size());
|
||||
|
||||
mine.get(0).onComplete().accept(true);
|
||||
// The other policy's claim is still in flight, so the first finisher must not delete.
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
|
||||
theirs.get(0).onComplete().accept(true);
|
||||
verify(s3Client).deleteObject(any(DeleteObjectRequest.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedObjectStaysAndIsNotRetriedUntilItChanges() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
|
||||
source.resolve(spec(), ctx).get(0).onComplete().accept(false);
|
||||
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
|
||||
// A new upload carries a new ETag, which reads as a new version and retries.
|
||||
listingReturns(object("doc.pdf", "\"etag-2\""));
|
||||
assertEquals(1, source.resolve(spec(), ctx).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotReadsStatelesslyEverySweep() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
InputSpec spec = new InputSpec("s3", options(Map.of("mode", "snapshot")));
|
||||
|
||||
List<ResolvedInput> first = source.resolve(spec, ctx);
|
||||
first.get(0).onComplete().accept(true);
|
||||
List<ResolvedInput> second = source.resolve(spec, ctx);
|
||||
|
||||
assertEquals(1, first.size());
|
||||
assertEquals(1, second.size());
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
assertTrue(ctx.present.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void folderPlaceholdersAndDotPrefixedKeysAreSkipped() throws IOException {
|
||||
listingReturns(
|
||||
object("doc.pdf", "\"etag-1\""),
|
||||
object("incoming/", "\"etag-2\""),
|
||||
object(".stirling/tmp/staged.pdf", "\"etag-3\""),
|
||||
object("incoming/.hidden.pdf", "\"etag-4\""));
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(), ctx);
|
||||
|
||||
assertEquals(1, work.size());
|
||||
assertEquals(List.of("s3://" + BUCKET + "/doc.pdf"), ctx.present);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listingPagesAreAllRead() throws IOException {
|
||||
ListObjectsV2Response firstPage =
|
||||
ListObjectsV2Response.builder()
|
||||
.contents(object("a.pdf", "\"etag-a\""))
|
||||
.nextContinuationToken("next")
|
||||
.build();
|
||||
ListObjectsV2Response secondPage =
|
||||
ListObjectsV2Response.builder().contents(object("b.pdf", "\"etag-b\"")).build();
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenReturn(firstPage, secondPage);
|
||||
|
||||
assertEquals(2, source.resolve(spec(), ctx).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aListingFailurePropagatesSoTheSweepVetoesCleanup() {
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenThrow(SdkClientException.create("connection refused"));
|
||||
|
||||
assertThrows(SdkClientException.class, () -> source.resolve(spec(), ctx));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resourceStreamsTheObjectAndNamesItByKeyBasename() throws IOException {
|
||||
listingReturns(object("incoming/doc.pdf", "\"etag-1\""));
|
||||
byte[] payload = "data".getBytes(StandardCharsets.UTF_8);
|
||||
when(s3Client.getObject(any(GetObjectRequest.class)))
|
||||
.thenReturn(
|
||||
new ResponseInputStream<>(
|
||||
GetObjectResponse.builder().build(),
|
||||
AbortableInputStream.create(new ByteArrayInputStream(payload))));
|
||||
|
||||
var resource = source.resolve(spec(), ctx).get(0).inputs().primary().get(0);
|
||||
|
||||
assertEquals("doc.pdf", resource.getFilename());
|
||||
// Content length comes from the listing, not a download.
|
||||
assertEquals(4, resource.contentLength());
|
||||
try (var stream = resource.getInputStream()) {
|
||||
assertEquals("data", new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingETagFallsBackToSizeAndLastModified() throws IOException {
|
||||
Instant modified = Instant.parse("2026-01-01T00:00:00Z");
|
||||
listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build());
|
||||
|
||||
assertEquals(1, source.resolve(spec(), ctx).size());
|
||||
// The same gate on the next sweep reads as already claimed.
|
||||
listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build());
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateRejectsBadConfig() {
|
||||
// No bucket.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("s3", Map.of())));
|
||||
// Credentials are required, never the server's own identity - together and individually.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("s3", Map.of("bucket", BUCKET))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
source.validate(
|
||||
new InputSpec(
|
||||
"s3", Map.of("bucket", BUCKET, "accessKeyId", "AKIA"))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("s3", options(Map.of("mode", "sideways")))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
source.validate(
|
||||
new InputSpec(
|
||||
"s3", options(Map.of("endpoint", "ftp://example.com")))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateRejectsAnUnreachableBucket() {
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenThrow(SdkClientException.create("connection refused"));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> source.validate(spec()));
|
||||
}
|
||||
|
||||
private static InputSpec spec() {
|
||||
return new InputSpec("s3", options(Map.of()));
|
||||
}
|
||||
|
||||
/** The required options (bucket + credentials) plus any extras under test. */
|
||||
private static Map<String, Object> options(Map<String, Object> extra) {
|
||||
Map<String, Object> options = new HashMap<>(extra);
|
||||
options.put("bucket", BUCKET);
|
||||
options.put("accessKeyId", "AKIAEXAMPLE");
|
||||
options.put("secretAccessKey", "shh");
|
||||
return options;
|
||||
}
|
||||
|
||||
private static S3Object object(String key, String eTag) {
|
||||
return S3Object.builder()
|
||||
.key(key)
|
||||
.eTag(eTag)
|
||||
.size(4L)
|
||||
.lastModified(Instant.parse("2026-01-01T00:00:00Z"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private void listingReturns(S3Object... objects) {
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenReturn(ListObjectsV2Response.builder().contents(objects).build());
|
||||
}
|
||||
|
||||
private void headReturns(String key, String eTag) {
|
||||
when(s3Client.headObject(any(HeadObjectRequest.class)))
|
||||
.thenReturn(
|
||||
HeadObjectResponse.builder()
|
||||
.eTag(eTag)
|
||||
.contentLength(4L)
|
||||
.lastModified(Instant.parse("2026-01-01T00:00:00Z"))
|
||||
.build());
|
||||
}
|
||||
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final String policyId;
|
||||
private final List<String> present = new ArrayList<>();
|
||||
|
||||
private RecordingContext() {
|
||||
this(POLICY);
|
||||
}
|
||||
|
||||
private RecordingContext(String policyId) {
|
||||
this.policyId = policyId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(policyId, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(policyId, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {
|
||||
present.addAll(identities);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.proprietary.policy.input.ResolveContext;
|
||||
import stirling.software.proprietary.policy.input.ResolvedInput;
|
||||
import stirling.software.proprietary.policy.input.S3InputSource;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
/**
|
||||
* End-to-end {@link S3OutputSink} test against a real S3 API (MinIO): uploads, collision renaming,
|
||||
* and - composed with {@link S3InputSource} - the loop-safety guarantee that a policy writing into
|
||||
* a bucket it also watches never re-ingests its own outputs, while a second policy still can.
|
||||
*/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3OutputSinkMinioTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
@Container
|
||||
static MinIOContainer minio =
|
||||
new MinIOContainer("minio/minio:latest")
|
||||
.withUserName(ACCESS_KEY)
|
||||
.withPassword(SECRET_KEY);
|
||||
|
||||
private static S3Client adminClient;
|
||||
private static int bucketCounter;
|
||||
|
||||
private String bucket;
|
||||
private S3OutputSink sink;
|
||||
private S3InputSource source;
|
||||
private InProcessProcessedLedger ledger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
if (adminClient == null) {
|
||||
adminClient =
|
||||
S3Client.builder()
|
||||
.endpointOverride(URI.create(minio.getS3URL()))
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.build();
|
||||
}
|
||||
bucket = "policy-outbox-" + ++bucketCounter;
|
||||
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
S3ConnectionPool pool = new S3ConnectionPool(properties);
|
||||
ledger = new InProcessProcessedLedger();
|
||||
sink = new S3OutputSink(pool, ledger);
|
||||
source = new S3InputSource(pool);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadsOutputsUnderThePrefix() throws IOException {
|
||||
List<ResultFile> results =
|
||||
sink.deliver(
|
||||
new OutputDelivery("run-1", POLICY),
|
||||
List.of(output("doc.pdf", "pdf bytes")),
|
||||
outputSpec("processed/"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/processed/doc.pdf");
|
||||
assertThat(objectContent("processed/doc.pdf")).isEqualTo("pdf bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExistingKeyIsNeverOverwritten() throws IOException {
|
||||
adminClient.putObject(
|
||||
PutObjectRequest.builder().bucket(bucket).key("doc.pdf").build(),
|
||||
RequestBody.fromString("theirs", StandardCharsets.UTF_8));
|
||||
|
||||
List<ResultFile> results =
|
||||
sink.deliver(
|
||||
new OutputDelivery("run-1", POLICY),
|
||||
List.of(output("doc.pdf", "ours")),
|
||||
outputSpec(""));
|
||||
|
||||
assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/doc (1).pdf");
|
||||
assertThat(objectContent("doc.pdf")).isEqualTo("theirs");
|
||||
assertThat(objectContent("doc (1).pdf")).isEqualTo("ours");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPolicyWritingIntoItsWatchedBucketSkipsItsOwnOutputsButAnotherPolicyChains()
|
||||
throws IOException {
|
||||
sink.deliver(
|
||||
new OutputDelivery("run-1", POLICY),
|
||||
List.of(output("result.pdf", "produced")),
|
||||
outputSpec(""));
|
||||
|
||||
// The producing policy's sweep sees its own output at the recorded gate and skips it.
|
||||
assertThat(source.resolve(inputSpec(), new RecordingContext(POLICY))).isEmpty();
|
||||
|
||||
// A different policy watching the same bucket has no row and processes it - chaining.
|
||||
List<ResolvedInput> chained = source.resolve(inputSpec(), new RecordingContext("p2"));
|
||||
assertThat(chained).hasSize(1);
|
||||
try (InputStream stream = chained.get(0).inputs().primary().get(0).getInputStream()) {
|
||||
assertThat(new String(stream.readAllBytes(), StandardCharsets.UTF_8))
|
||||
.isEqualTo("produced");
|
||||
}
|
||||
}
|
||||
|
||||
private OutputSpec outputSpec(String prefix) {
|
||||
return new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", bucket,
|
||||
"prefix", prefix,
|
||||
"endpoint", minio.getS3URL(),
|
||||
"accessKeyId", ACCESS_KEY,
|
||||
"secretAccessKey", SECRET_KEY));
|
||||
}
|
||||
|
||||
private InputSpec inputSpec() {
|
||||
return new InputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", bucket,
|
||||
"endpoint", minio.getS3URL(),
|
||||
"accessKeyId", ACCESS_KEY,
|
||||
"secretAccessKey", SECRET_KEY));
|
||||
}
|
||||
|
||||
private String objectContent(String key) throws IOException {
|
||||
try (ResponseInputStream<GetObjectResponse> stream =
|
||||
adminClient.getObject(GetObjectRequest.builder().bucket(bucket).key(key).build())) {
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static Resource output(String name, String content) {
|
||||
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final String policyId;
|
||||
|
||||
private RecordingContext(String policyId) {
|
||||
this.policyId = policyId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(policyId, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(policyId, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {}
|
||||
}
|
||||
}
|
||||