Compare commits

..
Author SHA1 Message Date
EthanHealy01andGitHub ab1874b39f Merge branch 'main' into feature/policiesVisualiserComponent 2026-07-14 12:18:15 +01:00
James BruntonandGitHub 776749277c Redesign policies to use typed mappings properly (#7017)
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.

Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.

<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
2026-07-14 09:58:04 +00:00
James BruntonandGitHub 41b1b89fcb Fix Policies page showing the Editor as a source twice (#7022)
# Description of Changes
The Policies page currently hard-codes the Editor to be available as a
source, but we now also have a virtual Editor source on the backend,
which the Policies page also renders. This removes the now-unnecessary
hard-coded Editor source.

## Before

<img width="842" height="640" alt="image"
src="https://github.com/user-attachments/assets/d78b33a3-fed4-4bb0-a02f-489ca2ae0614"
/>

## After

<img width="785" height="586" alt="image"
src="https://github.com/user-attachments/assets/37aa664f-74f2-41c7-b89a-9b483bffc3a2"
/>
2026-07-14 09:43:07 +00:00
James BruntonandGitHub 4d4e994562 Fix crash in Processor when loading tool settings with tooltips (#7015)
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
2026-07-14 09:09:23 +00:00
2b118556f3 Merge hotfix/v2.14.2 into main (#7023)
Merges the `hotfix/v2.14.2` branch into `main`.
on the hotfix branch:

### What this actually changes on `main`
- **Version bump 2.14.1 → 2.14.2** `build.gradle`, `tauri.conf.json`,
both AUR `PKGBUILD`s, and the two `serverExperienceSimulations.ts`
test-config files.
- **Fix Postgres user settings for some users** removes `@Lob` from
`User.java that broke settings for some Postgres users.
- **Release workflow: stop msiexec hang in Windows signature verify**

---------

Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: LFdev <146497073+LFd3v@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-07-13 23:45:19 +01:00
Anthony Stirling fbaff56d1c Merge hotfix/v2.14.2 into main (v2.14.2 bump, Postgres user settings fix, msiexec release fix) 2026-07-13 20:23:14 +01:00
EthanHealy01andGitHub fc210bc761 Merge branch 'main' into feature/policiesVisualiserComponent 2026-07-13 18:45:05 +01:00
EthanHealy01 e3802c56b2 fix colors 2026-07-13 17:37:13 +01:00
Anthony StirlingandGitHub a1b15e0570 Portal: dark disabled buttons and role column width (#7004)
# Description of Changes

Fixes disabled buttons rendering as plain grey in dark mode, and widens
the Users role column so "Organisation Owner" no longer clips.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 15:00:01 +00:00
James BruntonandGitHub 76549288a9 Redesign S3 connections to use connection resolver (#6965)
# Description of Changes
Redesign S3 connections based on feedback from #6948. Also redesigns the
UI for Sources to make them more like the Pipelines page which improves
UX quite a bit. There's still plenty more UI/UX work for Sources and S3
but moving in the right direction.
2026-07-13 14:44:41 +00:00
Anthony StirlingandGitHub b5d0c4a5ed Portal Home: SVG quick-action icons (#6998)
# Description of Changes

Replaces the ASCII quick-action glyphs on the Home hero with crisp
stroke SVG icons.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after
<img width="2880" height="2726" alt="after-home-dark"
src="https://github.com/user-attachments/assets/ab4c3611-25a6-4b2c-a993-99ce2f7b7558"
/>
<img width="2880" height="2726" alt="after-home-light"
src="https://github.com/user-attachments/assets/b0b342b9-4436-4791-b0a6-92837c3ec355"
/>
<img width="2880" height="2726" alt="before-home-dark"
src="https://github.com/user-attachments/assets/93d73393-5a81-4f34-a483-90671a6ae79e"
/>
<img width="2880" height="2726" alt="before-home-light"
src="https://github.com/user-attachments/assets/119389cc-78ef-4a43-9f9a-d551b03c9733"
/>


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 13:26:07 +00:00
Anthony StirlingandGitHub 8bfcf6eb7e Portal: theme-aware code blocks and hero-navy token (#7003)
# Description of Changes

Makes the code-snippet boxes theme-aware (a light palette in light mode)
and moves the hero navy into a design token without changing the colour
itself.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<img width="2136" height="272" alt="after-codeblock-light"
src="https://github.com/user-attachments/assets/ebdd4a7d-2d1a-429a-971b-0b04e93854fe"
/>
<img width="1800" height="740" alt="before-codeblock-light"
src="https://github.com/user-attachments/assets/6819231a-10d5-4730-9b7d-3c36dc1c8170"
/>

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 13:26:00 +00:00
James BruntonandGitHub b019f9b570 Fix missing and broken translations in Processor (#7016)
# Description of Changes
<img width="385" height="92" alt="image"
src="https://github.com/user-attachments/assets/7f4b1921-72e8-4c18-a4de-4b9736a5a2f1"
/>

Started from trying to fix this, but became a larger piece of work to
find missing/broken translations in the Processor and fix as many as I
could.
2026-07-13 12:55:36 +00:00
EthanHealy01 c59571f17c pre commit fix and other fixes 2026-07-13 11:40:41 +01:00
EthanHealy01 7cdf72c5f9 break flow into subfiles, it was massive 2026-07-13 02:17:52 +01:00
Anthony Stirling 38ccea074c Version bump 2026-07-08 10:50:37 +01:00
Anthony Stirling a7307ff393 Fix Postgres user settings for some users 2026-07-08 10:50:36 +01:00
Anthony Stirling 72729e99c1 fix(release): stop msiexec hang in Windows signature verify; don't force latest or regen release notes 2026-07-07 23:35:12 +01:00
Anthony Stirling 5fba2720f0 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:45:56 +01:00
01a1ef8c44 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 22:15:01 +01:00
LudyandAnthony Stirling 8535c7e9ac feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 22:15:01 +01:00
ConnorYohandAnthony Stirling cca3f42623 Set App version to v2.14.1 (#6891)
Upped version in build.gradle then ran build so version falls through
2026-07-07 12:15:01 +01:00
James BruntonandAnthony Stirling 1b7ffcdbac Fix tooltip positioning on Add Page Numbers (#6885)
# Description of Changes
## Before

<img width="483" height="227" alt="image"
src="https://github.com/user-attachments/assets/4bf86eec-a9cc-4f63-84f0-4eb2bd535bab"
/>

## After

<img width="732" height="235" alt="image"
src="https://github.com/user-attachments/assets/101d2ea4-36e8-4e8f-990a-d72b33fa0ac2"
/>
2026-07-07 12:09:51 +01:00
LudyandAnthony Stirling 67a0ca6110 fix(frontend): respect analytics config before initializing PostHog (#6812)
# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.

- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.

Closes #6358

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-07 12:09:51 +01:00
Anthony Stirling 8e4b2e2fc6 Disable update check and notification in SaaS mode (#6863)
# Description of Changes

In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:

- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.

**What changed**

- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.

**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-07 12:09:51 +01:00
Anthony Stirling 3c93457021 Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) 2026-07-07 12:09:51 +01:00
174 changed files with 5996 additions and 6329 deletions
-7
View File
@@ -87,13 +87,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.1
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.1
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+23 -6
View File
@@ -510,6 +510,7 @@ jobs:
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
timeout-minutes: 15
shell: pwsh
run: |
$allSigned = $true
@@ -531,11 +532,26 @@ jobs:
# Extract MSI and verify the inner exe (the file that actually gets installed).
# This is the critical check - AV flags the installed exe at runtime.
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
# _MSIExecute mutex and hangs forever on hosted runners when another
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
$msi = $msiFiles[0].FullName
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
if ($proc.ExitCode -eq 0) {
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
choco install lessmsi -y --no-progress --limit-output | Out-Null
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
if (-not $proc.WaitForExit(120000)) {
try { $proc.Kill() } catch {}
Write-Host "[ERROR] MSI extraction timed out after 120s"
$allSigned = $false
} elseif ($proc.ExitCode -ne 0) {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
} else {
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
if ($innerExe) {
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
@@ -548,9 +564,6 @@ jobs:
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
$allSigned = $false
}
} else {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
}
if (-not $allSigned) {
@@ -800,7 +813,11 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
# Don't regenerate/append notes on re-runs, and don't force this into the
# "Latest" slot - leave the release body and latest marker as they are.
generate_release_notes: false
append_body: false
make_latest: false
fail_on_unmatched_files: true
# Installers + updater payloads + manifest. .sig contents are embedded
# in latest.json so the .sig files themselves are not uploaded.
@@ -536,7 +536,6 @@ 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,9 +40,6 @@ 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
@@ -133,22 +130,6 @@ 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,7 +77,6 @@ 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();
@@ -311,37 +310,6 @@ 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,
@@ -944,81 +944,6 @@ 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;
@@ -1,85 +0,0 @@
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());
}
}
}
@@ -367,13 +367,6 @@ 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
-20
View File
@@ -3,14 +3,6 @@ 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
}
@@ -74,18 +66,6 @@ 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}"
@@ -1,64 +0,0 @@
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));
}
}
@@ -1,174 +0,0 @@
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) {}
}
@@ -1,119 +0,0 @@
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;
}
}
@@ -1,99 +0,0 @@
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);
}
}
@@ -1,153 +0,0 @@
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;
}
}
}
@@ -1,252 +0,0 @@
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};
}
}
@@ -1,16 +0,0 @@
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) {}
}
@@ -1,17 +0,0 @@
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);
}
}
@@ -1,75 +0,0 @@
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;
}
@@ -1,46 +0,0 @@
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;
}
@@ -1,35 +0,0 @@
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);
}
}
@@ -1,65 +0,0 @@
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;
}
}
@@ -1,509 +0,0 @@
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;
}
}
}
@@ -43,6 +43,10 @@ public class IntegrationConfigService {
private final OwnershipService ownership;
private final SecretMasker secretMasker;
private final ResourceGrantRepository grantRepository;
// Bean-discovered extension points: features that understand a type contribute its config
// schema and report what still references a config, without this module depending on them.
private final List<IntegrationConfigValidator> validators;
private final List<IntegrationConfigUsageCheck> usageChecks;
// ---- commands ----
@@ -66,13 +70,21 @@ public class IntegrationConfigService {
? DefaultAccessPolicy.EXPLICIT_ONLY
: request.defaultAccess());
// TEAM scope may omit the team id: default to the caller's own team so clients (the
// portal) need not know it. assignOwnership still enforces admin-or-leader of that team.
Long ownerTeamId = request.ownerTeamId();
if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) {
ownerTeamId = currentUser.getTeam().getId();
}
ownership.assignOwnership(
cfg,
scope,
request.ownerTeamId(),
ownerTeamId,
currentUser,
() -> lockedServerExists(cfg.getIntegrationType()));
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
Map<String, Object> config = secretMasker.sanitize(request.config());
validateConfig(cfg.getIntegrationType(), config);
cfg.setConfig(writeJson(config));
return repository.save(cfg);
}
@@ -101,8 +113,10 @@ public class IntegrationConfigService {
cfg.setDefaultAccess(request.defaultAccess());
}
if (request.config() != null) {
cfg.setConfig(
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
Map<String, Object> merged =
secretMasker.merge(readJson(cfg.getConfig()), request.config());
validateConfig(cfg.getIntegrationType(), merged);
cfg.setConfig(writeJson(merged));
}
return repository.save(cfg);
}
@@ -113,6 +127,15 @@ public class IntegrationConfigService {
if (!ownership.canManage(TYPE, cfg, currentUser)) {
throw forbidden("You cannot manage this integration");
}
// Refuse to pull a connection out from under whatever still references it.
List<String> usages =
usageChecks.stream()
.flatMap(check -> check.usagesOf(cfg.getId()).stream())
.toList();
if (!usages.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages));
}
// Drop grants sharing this config so they do not dangle as dead rows.
grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId()));
repository.delete(cfg);
@@ -188,6 +211,19 @@ public class IntegrationConfigService {
// ---- integration-specific glue ----
/** Runs every registered validator for the type; unknown types save free-form. */
private void validateConfig(IntegrationType type, Map<String, Object> config) {
for (IntegrationConfigValidator validator : validators) {
if (validator.type() == type) {
try {
validator.validate(config == null ? Map.of() : config);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
}
}
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
private boolean lockedServerExists(IntegrationType type) {
return repository.findByScope(OwnerScope.SERVER).stream()
@@ -0,0 +1,15 @@
package stirling.software.proprietary.integration.service;
import java.util.List;
/**
* Reports what still references an integration config, so deletion can be refused instead of
* pulling a connection out from under a live consumer. Implementations are beans discovered by
* {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that
* reference a connection).
*/
public interface IntegrationConfigUsageCheck {
/** Human-readable labels of everything still using the config; empty when unreferenced. */
List<String> usagesOf(long configId);
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.integration.service;
import java.util.Map;
import stirling.software.proprietary.integration.model.IntegrationType;
/**
* Validates one integration type's config map at save time. Implementations are beans discovered by
* {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3
* backend) owns its schema without the integration module depending on it. Types with no registered
* validator save free-form.
*/
public interface IntegrationConfigValidator {
/** The type this validator understands. */
IntegrationType type();
/**
* Validates the config as it will be stored (secrets already sanitized/merged, so values are
* real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config.
*/
void validate(Map<String, Object> config);
}
@@ -120,6 +120,7 @@ public class PolicyController {
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
PolicyRunHandle handle =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
@@ -140,6 +141,7 @@ public class PolicyController {
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
SseEmitter emitter =
@@ -530,6 +532,24 @@ public class PolicyController {
}
}
/**
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
* request thread). The worker thread that later delivers carries no security context, so an S3
* output's connection-access check would be skipped there; without this gate a caller could
* reference another tenant's connection by id and write to it (confused deputy). Stored
* policies are covered by save-time {@link PolicyValidator#validate} instead.
*/
private void validateAdHocOutput(PipelineDefinition definition) {
if (definition.output() == null) {
return;
}
try {
policyValidator.validateOutput(definition.output());
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
/**
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
* feed the same virtual editor source as stored editor policies, counted against the caller's
@@ -52,7 +52,19 @@ public class PolicyValidator {
InputSpec spec = source.toInputSpec();
inputSourceFor(spec).validate(spec);
}
outputSinkFor(policy.output()).validate(policy.output());
validateOutput(policy.output());
}
/**
* Validate an output spec against its sink. Must be called on a request thread (caller's
* principal present) so an S3 output's connection is authorization-checked against the caller -
* ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker
* thread that later delivers has no principal, so this is their only access gate.
*
* @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible
*/
public void validateOutput(OutputSpec output) {
outputSinkFor(output).validate(output);
}
private PolicyTrigger triggerFor(TriggerConfig config) {
@@ -18,6 +18,7 @@ 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.S3ConnectionResolver;
import stirling.software.proprietary.policy.s3.S3Identities;
import software.amazon.awssdk.core.exception.SdkException;
@@ -36,15 +37,14 @@ 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.
* Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning
* bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix"
* (only keys starting with it are read) and "mode" are per-source, where mode 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
@@ -55,6 +55,7 @@ public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
@Override
public String type() {
@@ -67,12 +68,12 @@ public class S3InputSource implements InputSource {
}
/**
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
* a bucket the supplied credentials cannot list.
* Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private
* endpoint without the operator opt-in, or a bucket the connection cannot list.
*/
@Override
public void validate(InputSpec spec) {
S3Config config = S3Config.from(spec.options());
S3Config config = connectionResolver.resolve(spec.options());
try {
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
} catch (SdkException e) {
@@ -89,7 +90,7 @@ public class S3InputSource implements InputSource {
@Override
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
S3Config config = S3Config.from(spec.options());
S3Config config = connectionResolver.resolve(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".
@@ -27,6 +27,7 @@ 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.S3ConnectionResolver;
import stirling.software.proprietary.policy.s3.S3Identities;
import software.amazon.awssdk.core.exception.SdkException;
@@ -59,6 +60,7 @@ public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
private final ProcessedLedger processedLedger;
@Override
@@ -72,19 +74,19 @@ public class S3OutputSink implements PolicyOutputSink {
}
/**
* 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.
* Connection resolution (including the saving user's right to use it) 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()));
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
}
@Override
public List<ResultFile> deliver(
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
S3Config config = S3Config.from(spec.options());
S3Config config = connectionResolver.resolve(spec.options());
S3Client client = connectionPool.clientFor(config);
List<ResultFile> results = new ArrayList<>();
@@ -0,0 +1,212 @@
package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.security.repository.TeamRepository;
import tools.jackson.databind.ObjectMapper;
/**
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
* sources and policy outputs written before connections shipped carry bucket/credentials in their
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
* the application layer, so no SQL migration can read it.
*
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
* connections; a concurrent multi-node boot can at worst create a redundant connection row, never
* corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a
* team, server-scoped otherwise (single-operator self-hosted).
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class EmbeddedS3CredentialMigration {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final List<String> CONNECTION_OPTIONS =
List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey");
// Field separator for the dedup key: a unit-separator control char that cannot appear in a
// bucket/region/endpoint/credential, so distinct field sets can never collide.
private static final char DELIMITER = '\u001f';
private final SourceStore sourceStore;
private final PolicyStore policyStore;
private final IntegrationConfigRepository connections;
private final TeamRepository teamRepository;
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void migrate() {
Map<String, IntegrationConfig> byCredentialKey = indexExistingConnections();
int migrated = 0;
for (Source source : sourceStore.all()) {
if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) {
continue;
}
IntegrationConfig connection =
connectionFor(source.options(), source.teamId(), byCredentialKey);
sourceStore.save(withOptions(source, referencing(connection, source.options(), true)));
migrated++;
}
for (Policy policy : policyStore.all()) {
OutputSpec output = policy.output();
if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) {
continue;
}
IntegrationConfig connection =
connectionFor(output.options(), policy.teamId(), byCredentialKey);
policyStore.save(
withOutput(
policy,
new OutputSpec(
output.type(),
referencing(connection, output.options(), false))));
migrated++;
}
if (migrated > 0) {
log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated);
}
}
private static boolean embedsCredentials(Map<String, Object> options) {
return options.get("accessKeyId") != null;
}
/** Reuses an existing connection with identical coordinates+credentials, else creates one. */
private IntegrationConfig connectionFor(
Map<String, Object> options, Long teamId, Map<String, IntegrationConfig> byKey) {
String key = credentialKey(options);
IntegrationConfig existing = byKey.get(key);
if (existing != null) {
return existing;
}
IntegrationConfig connection = new IntegrationConfig();
connection.setIntegrationType(IntegrationType.S3);
connection.setName(connectionName(options, byKey));
connection.setEnabled(true);
connection.setLocked(false);
connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY);
Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null);
if (team != null) {
connection.setScope(OwnerScope.TEAM);
connection.setOwnerTeam(team);
} else {
// No team (teamless self-hosted, or a source whose team was since deleted): server
// scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need
// an admin to re-share the connection - acceptable for the narrow orphaned case.
connection.setScope(OwnerScope.SERVER);
}
Map<String, Object> config = new LinkedHashMap<>();
for (String option : CONNECTION_OPTIONS) {
Object value = options.get(option);
if (value != null && !value.toString().isBlank()) {
config.put(option, value);
}
}
connection.setConfig(OBJECT_MAPPER.writeValueAsString(config));
IntegrationConfig saved = connections.save(connection);
byKey.put(key, saved);
return saved;
}
/** The rewritten options: the connection reference plus per-use settings only. */
private static Map<String, Object> referencing(
IntegrationConfig connection, Map<String, Object> legacy, boolean keepMode) {
Map<String, Object> options = new LinkedHashMap<>();
options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId());
Object prefix = legacy.get("prefix");
if (prefix != null && !prefix.toString().isBlank()) {
options.put("prefix", prefix);
}
Object mode = legacy.get("mode");
if (keepMode && mode != null && !mode.toString().isBlank()) {
options.put("mode", mode);
}
return options;
}
private Map<String, IntegrationConfig> indexExistingConnections() {
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
for (IntegrationConfig connection : connections.findAll()) {
if (connection.getIntegrationType() != IntegrationType.S3) {
continue;
}
try {
Map<String, Object> config =
OBJECT_MAPPER.readValue(connection.getConfig(), Map.class);
byKey.putIfAbsent(credentialKey(config), connection);
} catch (Exception e) {
log.debug(
"Skipping unreadable S3 connection {} while indexing: {}",
connection.getId(),
e.getMessage());
}
}
return byKey;
}
private static String credentialKey(Map<String, Object> options) {
StringBuilder key = new StringBuilder();
for (String option : CONNECTION_OPTIONS) {
Object value = options.get(option);
key.append(value == null ? "" : value.toString().trim()).append(DELIMITER);
}
return key.toString();
}
private static String connectionName(
Map<String, Object> options, Map<String, IntegrationConfig> byKey) {
String base = "S3: " + options.getOrDefault("bucket", "bucket");
long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count();
return sameName == 0 ? base : base + " (" + (sameName + 1) + ")";
}
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());
}
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());
}
}
@@ -0,0 +1,56 @@
package stirling.software.proprietary.policy.s3;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection
* cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source
* delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live
* stores.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
private final SourceStore sourceStore;
private final PolicyStore policyStore;
@Override
public List<String> usagesOf(long configId) {
List<String> usages = new ArrayList<>();
for (Source source : sourceStore.all()) {
if (references(source.options(), configId)) {
usages.add("source '" + source.name() + "'");
}
}
for (Policy policy : policyStore.all()) {
if (references(policy.output().options(), configId)) {
usages.add("pipeline '" + policy.name() + "'");
}
}
return usages;
}
private static boolean references(Map<String, Object> options, long configId) {
try {
Long reference = S3ConnectionResolver.connectionId(options);
return reference != null && reference == configId;
} catch (IllegalArgumentException unparseable) {
return false;
}
}
}
@@ -5,10 +5,12 @@ 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.
* The fully resolved connection settings the S3 input source and output sink run with - normally
* produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint,
* credentials) with per-use options (prefix, mode), or parsed directly from legacy options that
* still embed credentials. 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,
@@ -0,0 +1,151 @@
package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code
* connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region,
* endpoint, and credentials; the options own per-use settings such as prefix and mode). Options
* with no {@code connectionId} fall back to legacy embedded credentials, so rows written before
* connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them.
*
* <p>When an authenticated caller is present (save-time validation), they must be allowed to use
* the connection. Background sweeps and deliveries run with no caller and skip that check: the
* referencing source or policy was access-checked when it was saved.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionResolver {
static final String CONNECTION_ID_OPTION = "connectionId";
private static final String PREFIX_OPTION = "prefix";
private static final String MODE_OPTION = "mode";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final IntegrationConfigRepository connections;
private final OwnershipService ownership;
private final UserService userService;
public S3Config resolve(Map<String, Object> options) {
Long connectionId = connectionId(options);
if (connectionId == null) {
// Legacy embedded credentials, pending migration.
return S3Config.from(options);
}
IntegrationConfig connection =
connections
.findById(connectionId)
.filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3)
.filter(this::usableByCurrentUser)
// Existence and access collapse into one error: a caller must not be able
// to tell "no such connection" from "someone else's connection" and
// enumerate ids. The id/name are never echoed.
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown or inaccessible s3 connection"));
if (!connection.isEnabled()) {
throw new IllegalArgumentException("s3 connection is disabled");
}
Map<String, Object> merged = new LinkedHashMap<>(connectionConfig(connection));
copyPerUseOption(options, merged, PREFIX_OPTION);
copyPerUseOption(options, merged, MODE_OPTION);
return S3Config.from(merged);
}
/** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */
static Long connectionId(Map<String, Object> options) {
Object reference = options.get(CONNECTION_ID_OPTION);
if (reference == null || (reference instanceof String s && s.isBlank())) {
return null;
}
if (reference instanceof Number number) {
return number.longValue();
}
try {
return Long.valueOf(reference.toString().trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"s3 'connectionId' is not a valid connection reference: " + reference);
}
}
/**
* Whether the current caller may use this connection. With no principal - a background sweep or
* delivery on a worker thread that carries no {@code SecurityContext} - access is treated as
* already established: stored policies are validated with the caller present at save time, and
* ad-hoc runs are validated on the request thread before dispatch (see {@code
* PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing
* standing between a caller and a connection, or the check becomes a confused deputy.
*/
private boolean usableByCurrentUser(IntegrationConfig connection) {
User user = currentUser();
return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user);
}
// Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated.
private User currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return null;
}
Object principal = auth.getPrincipal();
if (principal instanceof User user) {
return user;
}
if (principal instanceof UserDetails userDetails) {
return userService.findByUsername(userDetails.getUsername()).orElse(null);
}
if (principal instanceof String username && !"anonymousUser".equals(username)) {
return userService.findByUsername(username).orElse(null);
}
return null;
}
private static Map<String, Object> connectionConfig(IntegrationConfig connection) {
String json = connection.getConfig();
if (json == null || json.isBlank()) {
return Map.of();
}
try {
return OBJECT_MAPPER.readValue(
json, new TypeReference<LinkedHashMap<String, Object>>() {});
} catch (Exception e) {
throw new IllegalArgumentException(
"s3 connection '" + connection.getName() + "' has unreadable config", e);
}
}
private static void copyPerUseOption(
Map<String, Object> options, Map<String, Object> merged, String key) {
Object value = options.get(key);
if (value != null && !value.toString().isBlank()) {
merged.put(key, value);
}
}
}
@@ -0,0 +1,51 @@
package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.cluster.s3.S3Clients;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
/**
* The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and
* credentials required, endpoint an http(s) URL that must not reach private addresses without the
* operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save
* time so a bad connection fails in the form rather than in a sweep.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3IntegrationValidator implements IntegrationConfigValidator {
private final ApplicationProperties applicationProperties;
@Override
public IntegrationType type() {
return IntegrationType.S3;
}
@Override
public void validate(Map<String, Object> config) {
S3Config parsed = S3Config.from(config);
if (parsed.endpoint() == null) {
return;
}
try {
S3Clients.validateEndpointHost(
URI.create(parsed.endpoint()),
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
"S3 connection endpoint",
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
+ " MinIO).");
} catch (IllegalStateException e) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
}
@@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable {
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
@@ -1,46 +0,0 @@
[
{
"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
}
]
@@ -1,49 +0,0 @@
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());
}
}
@@ -1,70 +0,0 @@
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());
}
}
@@ -1,95 +0,0 @@
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"));
}
}
@@ -1,81 +0,0 @@
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());
}
}
@@ -1,98 +0,0 @@
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());
}
}
@@ -1,45 +0,0 @@
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);
}
}
@@ -1,173 +0,0 @@
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"));
}
}
@@ -13,9 +13,9 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
@@ -52,7 +52,58 @@ class IntegrationConfigServiceTest {
@Mock
private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository;
@InjectMocks private IntegrationConfigService service;
@Mock private IntegrationConfigValidator validator;
@Mock private IntegrationConfigUsageCheck usageCheck;
private IntegrationConfigService service;
@BeforeEach
void setUp() {
service =
new IntegrationConfigService(
repository,
ownership,
secretMasker,
grantRepository,
List.of(validator),
List.of(usageCheck));
}
@Test
void createRejectsAConfigItsTypeValidatorRefuses() {
when(secretMasker.sanitize(any())).thenReturn(Map.of());
when(validator.type()).thenReturn(IntegrationType.API);
org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'"))
.when(validator)
.validate(any());
assertThatThrownBy(
() ->
service.create(
request(IntegrationType.API, OwnerScope.USER, null),
user(7)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
@Test
void deleteRefusedWhileAnythingStillReferencesTheConfig() {
IntegrationConfig cfg = config(9L);
when(repository.findById(9L)).thenReturn(Optional.of(cfg));
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'"));
assertThatThrownBy(() -> service.delete(9L, user(7)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.CONFLICT));
verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class));
}
@Test
void createDelegatesOwnershipAndSanitizesConfig() {
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -194,6 +195,29 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
@Test
@DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread")
void rejectsUnauthorizedAdHocOutput() {
// The confused-deputy guard: an S3 output referencing a connection the caller may not
// use is validated here (principal present) and refused before any worker dispatch.
PipelineDefinition definition =
new PipelineDefinition(
"pipe",
List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)),
new OutputSpec("s3", Map.of("connectionId", 999)));
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
.when(policyValidator)
.validateOutput(any());
assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles()))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
verify(policyRunner, never()).runAdHoc(any(), any(), any());
}
}
@Nested
@@ -82,6 +82,28 @@ class PolicyValidatorTest {
assertTrue(ex.getMessage().contains("schedule"));
}
@Test
void validateOutputDelegatesToTheSink() {
when(outputSink.supports(any())).thenReturn(true);
OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1));
validator.validateOutput(output);
verify(outputSink).validate(output);
}
@Test
void validateOutputSurfacesAnInaccessibleConnection() {
when(outputSink.supports(any())).thenReturn(true);
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
.when(outputSink)
.validate(any());
assertThrows(
IllegalArgumentException.class,
() -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1))));
}
@Test
void rejectsAnUnknownTriggerType() {
when(trigger.type()).thenReturn("schedule");
@@ -23,6 +23,7 @@ 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 stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -82,7 +83,9 @@ class S3InputSourceMinioTest {
// 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));
source =
new S3InputSource(
new S3ConnectionPool(properties), S3TestConnections.legacyResolver());
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -161,7 +164,9 @@ class S3InputSourceMinioTest {
@Test
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
S3InputSource guarded =
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties()),
S3TestConnections.legacyResolver());
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
.isInstanceOf(IllegalArgumentException.class)
@@ -29,6 +29,7 @@ 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 stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.exception.SdkClientException;
@@ -64,7 +65,8 @@ class S3InputSourceTest {
void setUp() {
source =
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver());
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -28,6 +28,7 @@ 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 stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -90,8 +91,8 @@ class S3OutputSinkMinioTest {
properties.getPolicies().setAllowPrivateS3Endpoints(true);
S3ConnectionPool pool = new S3ConnectionPool(properties);
ledger = new InProcessProcessedLedger();
sink = new S3OutputSink(pool, ledger);
source = new S3InputSource(pool);
sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger);
source = new S3InputSource(pool, S3TestConnections.legacyResolver());
}
@Test
@@ -32,6 +32,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.exception.SdkClientException;
@@ -66,6 +67,7 @@ class S3OutputSinkTest {
sink =
new S3OutputSink(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver(),
ledger);
}
@@ -0,0 +1,212 @@
package stirling.software.proprietary.policy.s3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
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.atomic.AtomicLong;
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.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.security.repository.TeamRepository;
/**
* Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated
* team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops.
*/
@ExtendWith(MockitoExtension.class)
class EmbeddedS3CredentialMigrationTest {
@Mock private IntegrationConfigRepository connections;
@Mock private TeamRepository teamRepository;
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
private EmbeddedS3CredentialMigration migration;
@BeforeEach
void setUp() {
migration =
new EmbeddedS3CredentialMigration(
sourceStore, policyStore, connections, teamRepository);
AtomicLong ids = new AtomicLong(100);
// Lenient: the nothing-to-migrate cases never create a connection.
lenient().when(connections.findAll()).thenReturn(List.of());
lenient()
.when(connections.save(any()))
.thenAnswer(
invocation -> {
IntegrationConfig saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(ids.incrementAndGet());
}
return saved;
});
}
@Test
void extractsSharedCredentialsIntoOneTeamScopedConnection() {
Team team = new Team();
team.setId(7L);
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
Source source =
sourceStore.save(
new Source(
null,
"Claims intake",
"s3",
Map.of(
"bucket", "inbox",
"prefix", "incoming/",
"mode", "snapshot",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"),
true,
"alice",
7L));
Policy policy =
policyStore.save(
new Policy(
null,
"Rotate",
"alice",
true,
null,
List.of(source.id()),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec(
"s3",
Map.of(
"bucket", "inbox",
"prefix", "processed/",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")),
7L));
migration.migrate();
// Same bucket + credentials on both rows: exactly one connection extracted.
verify(connections, times(1)).save(any());
Map<String, Object> sourceOptions = sourceStore.get(source.id()).orElseThrow().options();
assertEquals(101L, sourceOptions.get("connectionId"));
assertEquals("incoming/", sourceOptions.get("prefix"));
assertEquals("snapshot", sourceOptions.get("mode"));
assertNull(sourceOptions.get("accessKeyId"));
assertNull(sourceOptions.get("secretAccessKey"));
assertNull(sourceOptions.get("bucket"));
Map<String, Object> outputOptions =
policyStore.get(policy.id()).orElseThrow().output().options();
assertEquals(101L, outputOptions.get("connectionId"));
assertEquals("processed/", outputOptions.get("prefix"));
assertNull(outputOptions.get("secretAccessKey"));
}
@Test
void connectionOwnershipFollowsTheSourceTeam() {
Team team = new Team();
team.setId(7L);
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
sourceStore.save(s3Source("teamed", 7L));
migration.migrate();
verify(connections)
.save(
org.mockito.ArgumentMatchers.argThat(
connection ->
connection.getScope() == OwnerScope.TEAM
&& connection.getOwnerTeam() == team));
}
@Test
void teamlessRowsBecomeServerScopedConnections() {
sourceStore.save(s3Source("solo", null));
migration.migrate();
verify(connections)
.save(
org.mockito.ArgumentMatchers.argThat(
connection -> connection.getScope() == OwnerScope.SERVER));
}
@Test
void aSecondRunFindsNothingToDo() {
sourceStore.save(s3Source("once", null));
migration.migrate();
migration.migrate();
// One connection from the first run; the rewritten source no longer embeds credentials.
verify(connections, times(1)).save(any());
}
@Test
void nonS3AndAlreadyMigratedRowsAreUntouched() {
Source folder =
sourceStore.save(
new Source(
null,
"Folder",
"folder",
Map.of("directory", "/in"),
true,
"alice",
null));
Source migrated =
sourceStore.save(
new Source(
null,
"Done already",
"s3",
Map.of("connectionId", 55L, "prefix", "in/"),
true,
"alice",
null));
migration.migrate();
verify(connections, times(0)).save(any());
assertEquals(
Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options());
assertEquals(
Map.of("connectionId", 55L, "prefix", "in/"),
sourceStore.get(migrated.id()).orElseThrow().options());
}
private static Source s3Source(String name, Long teamId) {
return new Source(
null,
name,
"s3",
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"),
true,
"alice",
teamId);
}
}
@@ -0,0 +1,52 @@
package stirling.software.proprietary.policy.s3;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */
class PolicyS3ConnectionUsageCheckTest {
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
private final PolicyS3ConnectionUsageCheck check =
new PolicyS3ConnectionUsageCheck(sourceStore, policyStore);
@Test
void reportsSourcesAndOutputsReferencingTheConnection() {
sourceStore.save(
new Source(
null,
"Claims intake",
"s3",
Map.of("connectionId", 5L, "prefix", "in/"),
true,
"alice",
null));
policyStore.save(
new Policy(
null,
"Rotate",
"alice",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec("s3", Map.of("connectionId", "5")),
null));
assertThat(check.usagesOf(5))
.containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'");
assertThat(check.usagesOf(6)).isEmpty();
}
}
@@ -0,0 +1,149 @@
package stirling.software.proprietary.policy.s3;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the
* legacy embedded fallback, and the save-time access check that background sweeps skip.
*/
@ExtendWith(MockitoExtension.class)
class S3ConnectionResolverTest {
@Mock private IntegrationConfigRepository connections;
@Mock private OwnershipService ownership;
@Mock private UserService userService;
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void resolvesAConnectionAndMergesPerUseOptions() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
S3Config config =
resolver()
.resolve(
Map.of(
"connectionId", 9L,
"prefix", "incoming/",
"mode", "snapshot"));
assertEquals("inbox", config.bucket());
assertEquals("AKIAEXAMPLE", config.accessKeyId());
assertEquals("incoming/", config.prefix());
assertTrue(config.snapshot());
}
@Test
void acceptsAStringConnectionReference() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket());
}
@Test
void fallsBackToLegacyEmbeddedCredentials() {
S3Config config =
resolver()
.resolve(
Map.of(
"bucket", "legacy",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"));
assertEquals("legacy", config.bucket());
}
@Test
void rejectsUnknownDisabledOrWrongTypeConnections() {
when(connections.findById(1L)).thenReturn(Optional.empty());
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 1L)));
when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false)));
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 2L)));
IntegrationConfig mcp = s3Connection(3L, true);
mcp.setIntegrationType(IntegrationType.MCP);
when(connections.findById(3L)).thenReturn(Optional.of(mcp));
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 3L)));
}
@Test
void anAuthenticatedSaverMustBeAllowedToUseTheConnection() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
User saver = new User();
saver.setUsername("alice");
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of()));
when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false);
// Denied reads the same as unknown and never echoes the connection name, so ids can't be
// enumerated by probing.
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 9L)));
try {
resolver().resolve(Map.of("connectionId", 9L));
} catch (IllegalArgumentException e) {
org.junit.jupiter.api.Assertions.assertFalse(
e.getMessage().contains("Claims bucket"),
"access-denied error must not leak the connection name");
}
}
@Test
void backgroundSweepsWithNoUserSkipTheAccessCheck() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
// No authentication in the context: resolution succeeds without consulting ownership.
assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket());
}
private S3ConnectionResolver resolver() {
return new S3ConnectionResolver(connections, ownership, userService);
}
private static IntegrationConfig s3Connection(long id, boolean enabled) {
IntegrationConfig connection = new IntegrationConfig();
connection.setId(id);
connection.setIntegrationType(IntegrationType.S3);
connection.setName("Claims bucket");
connection.setEnabled(enabled);
connection.setConfig(
"{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\","
+ "\"secretAccessKey\":\"shh\"}");
return connection;
}
}
@@ -0,0 +1,71 @@
package stirling.software.proprietary.policy.s3;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.integration.model.IntegrationType;
/**
* Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing
* credentials, bad endpoints, and private endpoints without the operator opt-in.
*/
class S3IntegrationValidatorTest {
@Test
void acceptsACompleteConnection() {
assertThatCode(
() ->
validator(false)
.validate(
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")))
.doesNotThrowAnyException();
}
@Test
void rejectsMissingCredentialsOrBucket() {
assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox")))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
validator(false)
.validate(
Map.of(
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsAPrivateEndpointWithoutTheOperatorOptIn() {
Map<String, Object> config =
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh",
"endpoint", "http://localhost:9000");
assertThatThrownBy(() -> validator(false).validate(config))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("allowPrivateS3Endpoints");
assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException();
}
@Test
void itOnlyClaimsTheS3Type() {
org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type());
}
private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints);
return new S3IntegrationValidator(properties);
}
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.policy.s3;
import static org.mockito.Mockito.mock;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.service.UserService;
/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */
public final class S3TestConnections {
private S3TestConnections() {}
/**
* A resolver for tests whose options embed credentials directly (the legacy pass-through path),
* so its collaborators are never touched.
*/
public static S3ConnectionResolver legacyResolver() {
return new S3ConnectionResolver(
mock(IntegrationConfigRepository.class),
mock(OwnershipService.class),
mock(UserService.class));
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.14.1'
version = '2.14.2'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
-23
View File
@@ -19,15 +19,6 @@ RUN apt-get update \
&& rm /tmp/task.deb \
&& rm -rf /var/lib/apt/lists/*
# Pre-download the default Auto Form Detection model (FFDNet-S, ~37MB) so the feature works
# out-of-the-box. Verified by checksum here; seeded into the writable configs model dir at startup
# by FormDetectionModelManager (FORMDETECTION_PREINSTALLEDMODELDIR below).
ARG FORM_DETECTION_MODEL_URL=https://huggingface.co/jbarrow/FFDNet-S-cpu/resolve/main/FFDNet-S.onnx
ARG FORM_DETECTION_MODEL_SHA256=93bccf47c048f9f947f9b1b52d002edf144a8a583dae39f164d9e5725321acc0
RUN mkdir -p /preinstalled-models \
&& curl -fSL "${FORM_DETECTION_MODEL_URL}" -o /preinstalled-models/ffdnet-s.onnx \
&& echo "${FORM_DETECTION_MODEL_SHA256} /preinstalled-models/ffdnet-s.onnx" | sha256sum -c -
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
@@ -64,7 +55,6 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-PprototypesMode=${PROTOTYPES_BUILD} \
-PbundleOnnxRuntime=true \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
@@ -74,15 +64,6 @@ WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Slim the bundled ONNX Runtime (included only via -PbundleOnnxRuntime above) to the target Linux
# arch (~42MB jar -> ~8MB). Docker server image only; desktop/local builds don't bundle it at all.
# The detection model itself is unaffected - it is still downloaded on demand at runtime.
COPY scripts/slim-onnxruntime.sh /tmp/slim-onnxruntime.sh
RUN apt-get update \
&& apt-get install -y --no-install-recommends zip \
&& rm -rf /var/lib/apt/lists/* \
&& sh /tmp/slim-onnxruntime.sh /layers/dependencies
# Stage 3: Final runtime image on top of pre-built base
FROM ${BASE_IMAGE}
@@ -101,9 +82,6 @@ COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --link --chown=1000:1000 scripts/ /scripts/
# Pre-bundled default detection model; FormDetectionModelManager seeds it into /configs on startup.
COPY --link --from=app-build --chown=1000:1000 /preinstalled-models/ /opt/stirling/preinstalled-models/
# Fonts go to system dir, root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
@@ -127,7 +105,6 @@ RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
# Environment variables
ENV VERSION_TAG=$VERSION_TAG \
FORMDETECTION_PREINSTALLEDMODELDIR="/opt/stirling/preinstalled-models" \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
-22
View File
@@ -20,15 +20,6 @@ RUN apt-get update \
&& rm /tmp/task.deb \
&& rm -rf /var/lib/apt/lists/*
# Pre-download the default Auto Form Detection model (FFDNet-S, ~37MB) so the feature works
# out-of-the-box - especially important for this air-gapped fat image (no runtime internet).
# Verified by checksum; seeded into the writable configs model dir at startup.
ARG FORM_DETECTION_MODEL_URL=https://huggingface.co/jbarrow/FFDNet-S-cpu/resolve/main/FFDNet-S.onnx
ARG FORM_DETECTION_MODEL_SHA256=93bccf47c048f9f947f9b1b52d002edf144a8a583dae39f164d9e5725321acc0
RUN mkdir -p /preinstalled-models \
&& curl -fSL "${FORM_DETECTION_MODEL_URL}" -o /preinstalled-models/ffdnet-s.onnx \
&& echo "${FORM_DETECTION_MODEL_SHA256} /preinstalled-models/ffdnet-s.onnx" | sha256sum -c -
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
@@ -57,7 +48,6 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
DISABLE_ADDITIONAL_FEATURES=false \
gradle clean build \
-PbuildWithFrontend=true \
-PbundleOnnxRuntime=true \
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
@@ -69,14 +59,6 @@ WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Slim the bundled ONNX Runtime (included only via -PbundleOnnxRuntime above) to the target Linux
# arch (~42MB jar -> ~8MB). Docker server image only; desktop/local builds don't bundle it at all.
COPY scripts/slim-onnxruntime.sh /tmp/slim-onnxruntime.sh
RUN apt-get update \
&& apt-get install -y --no-install-recommends zip \
&& rm -rf /var/lib/apt/lists/* \
&& sh /tmp/slim-onnxruntime.sh /layers/dependencies
# Stage 3: Final runtime image on top of pre-built base
FROM ${BASE_IMAGE}
@@ -95,9 +77,6 @@ COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --link --chown=1000:1000 scripts/ /scripts/
# Pre-bundled default detection model; FormDetectionModelManager seeds it into /configs on startup.
COPY --link --from=app-build --chown=1000:1000 /preinstalled-models/ /opt/stirling/preinstalled-models/
# Fonts go to system dir, root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
@@ -120,7 +99,6 @@ RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
# Environment variables
ENV VERSION_TAG=$VERSION_TAG \
FORMDETECTION_PREINSTALLEDMODELDIR="/opt/stirling/preinstalled-models" \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
+17 -1
View File
@@ -43,14 +43,30 @@ function parseEnTranslation(): Record<string, unknown> {
}
}
const enTranslationResources = parseEnTranslation();
if (!i18next.isInitialized) {
// initImmediate: false → initialise synchronously from the inline resources
// (there's no async backend here), so i18next is ready before the first story
// renders. Without it the first render can beat init and stick on raw keys.
void i18next.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
resources: { en: { translation: parseEnTranslation() } },
resources: { en: { translation: enTranslationResources } },
interpolation: { escapeValue: false },
react: { useSuspense: false },
initImmediate: false,
});
} else {
// Something initialised i18next first (e.g. the app's async TOML backend):
// inject the shipped English copy synchronously so t() never renders raw keys.
i18next.addResourceBundle(
"en",
"translation",
enTranslationResources,
true,
true,
);
if (i18next.language !== "en") void i18next.changeLanguage("en");
}
// Start MSW once. Storybook runs in a browser so this uses the service worker.
@@ -563,39 +563,6 @@ close = "Close"
error = "Error"
expand = "Expand"
[admin.formDetection]
active = "Active"
description = "Install the AI model used to auto-detect form fields. The selected model is downloaded on demand (about 40-100MB) into the configs volume and is not bundled with Stirling-PDF."
enableFeature = "Enable feature"
engineDescription = "Browser keeps the PDF on the device (downloads a ~12MB runtime once, then cached); Server runs it on the backend; Auto prefers the browser and falls back to the server."
engineLabel = "Where detection runs"
install = "Install"
license = "License"
notAvailable = "This catalog entry has no download URL/checksum configured yet, so it cannot be installed."
notWritable = "The model directory is not writable; check the configs volume mount."
selectModel = "Model"
selectPlaceholder = "Select a model"
status = "Status"
switch = "Switch to this model"
title = "AI Form Detection"
uninstall = "Uninstall"
[admin.formDetection.airgap]
hide = "Hide offline install instructions"
intro = "No internet on the server? Install the model manually:"
noSha = "(checksum not set)"
show = "Air-gapped / offline install instructions"
step1 = "1. On a machine with internet, download the model file:"
step2 = "2. Verify its SHA-256 checksum matches:"
step3 = "3. Copy it onto the Stirling-PDF server into the model directory:"
step4 = "4. Set formDetection.activeModelId to this model's id in settings.yml, then restart (an installed model is auto-detected on boot). <configs> is the configs volume (e.g. /configs in Docker). The Docker image also pre-bundles this model, so it is ready with no download."
[admin.formDetection.engine]
auto = "Auto"
browser = "Browser"
server = "Server"
serverUnavailable = "The server engine is not bundled in this build, so detection runs in the browser. Use Auto or Browser."
[admin.settings]
discard = "Discard"
error = "Failed to save settings"
@@ -1916,16 +1883,6 @@ bullet2 = "Creates a clean, valid filename from the detected title"
bullet3 = "Keeps the original name if no suitable title is found"
title = "Smart Renaming"
[autoFormDetection]
loading = "Detecting form fields..."
submit = "Detect & make fillable"
[autoFormDetection.error]
failed = "An error occurred while detecting form fields."
[autoFormDetection.results]
title = "Detected Form Fields"
[automate]
copyToSaved = "Copy to Saved"
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
@@ -4360,11 +4317,6 @@ desc = "Highlight, draw, add notes and shapes in the viewer"
tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand"
title = "Annotate"
[home.autoFormDetection]
desc = "Automatically detect form fields with AI and make your PDF fillable."
tags = "form,detect,fillable,acroform,auto detect,ai,fields,make fillable,form detection"
title = "Auto Form Detection"
[home.automate]
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
@@ -6288,6 +6240,10 @@ muted = "muted"
nameLabel = "Name"
namePlaceholder = "e.g. Compliance escalation"
[portal.agentBuilder.status]
draft = "Draft"
published = "Published"
[portal.agentBuilder.tabs]
evals = "Evals"
scenarios = "Scenarios"
@@ -6511,7 +6467,7 @@ install = "Install"
usage = "Usage"
[portal.catalogue.detail.locked]
description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
description = "{{name}} is included from the {{plan}}. Upgrade to embed it."
title = "Not available on your plan"
[portal.catalogue.detail.preview]
@@ -6578,6 +6534,35 @@ title = "No components available"
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
title = "Some components need a paid plan"
[portal.connections]
createTitle = "New S3 connection"
delete = "Delete"
edit = "Edit"
editTitle = "Edit S3 connection"
subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to."
[portal.connections.actions]
new = "New connection"
[portal.connections.empty]
description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs."
title = "No connections yet"
[portal.connections.picker]
cancel = "Cancel"
createNew = "New connection..."
placeholder = "Select a connection"
save = "Save connection"
[portal.connections.s3.fields]
name = "Connection name"
namePlaceholder = "e.g. Claims bucket"
[portal.connections.table]
bucket = "Bucket"
name = "Name"
region = "Region"
[portal.docs.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -7389,10 +7374,6 @@ operations_one = "Operation ({{count}})"
operations_other = "Operations ({{count}})"
output = "Output"
removeStep = "Remove operation"
s3Configure = "Configure"
s3Done = "Done"
s3ModalTitle = "Amazon S3 output"
s3NotConfigured = "Not configured"
s3PrefixHelp = "Outputs are uploaded under this key prefix."
save = "Save changes"
scheduleEvery = "Run every"
@@ -7463,6 +7444,7 @@ manual = "Manual"
schedule = "Scheduled"
[portal.policies]
defaultName = "{{category}} Policy"
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
title = "Policies"
@@ -7726,6 +7708,48 @@ soon = "Soon"
managePlan = "Manage plan"
volumeSuffix = "PDFs processed · last 30 days"
[portal.processorFlow]
footnote = "Counts are over the last 24 hours. Flow speed is illustrative."
liveBadge = "Live"
stats = "{{connected}} connected · {{processed}} PDFs processed"
title = "PDF Processor"
[portal.processorFlow.lens]
ariaLabel = "View mode"
flow = "Flow"
sankey = "Sankey"
[portal.processorFlow.outcomes]
count = "{{n}} · 24h"
failed = "Failed"
heading = "Outcomes"
success = "Delivered"
[portal.processorFlow.policies]
activeCount = "{{n}} active"
count = "{{n}} · 24h"
heading = "Policies"
setUp = "Set up"
soon = "Soon"
[portal.processorFlow.sankey]
waist = "Policies · {{n}} active"
[portal.processorFlow.sankey.empty]
description = "Connect a source and switch on a policy to watch documents flow through the processor."
title = "No flow yet"
[portal.processorFlow.sources]
comingSoonTag = "Connect"
editor = "Stirling PDF Editor"
heading = "Sources"
perDay = "{{n}} / 24h"
[portal.processorFlow.sources.comingSoon]
apiMcp = "API · MCP"
cloud = "Drive · Box · S3"
email = "Email intake"
[portal.procurement]
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -8034,34 +8058,29 @@ primaryNav = "Primary navigation"
switchApp = "Switch app"
[portal.sources]
subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it."
subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines."
title = "Sources"
[portal.sources.actions]
agentBuilder = "Agent Builder"
connectSource = "Connect source"
[portal.sources.builder]
back = "Back to sources"
cancel = "Cancel"
create = "Create source"
createTitle = "Connect a source"
delete = "Delete"
editTitle = "Edit source"
enabled = "Enabled"
save = "Save changes"
[portal.sources.delete]
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
cancel = "Cancel"
confirm = "Delete"
title = "Delete source?"
[portal.sources.detail]
closeAriaLabel = "Close detail"
delete = "Delete source"
docs24h = "Last 24h"
docs30d = "Last 30 days"
docsTotal = "Total seen"
docsTrend = "Documents over the last 30 days"
documents = "Documents"
edit = "Edit"
notReferenced = "Not referenced by any policy, so it's safe to delete."
pause = "Pause"
resume = "Resume"
subtitle = "{{type}} · {{status}}"
usedBy = "Used by"
[portal.sources.empty]
description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from."
title = "No sources connected yet"
@@ -8077,10 +8096,15 @@ disabled = "Disabled"
unused = "Unused"
[portal.sources.table]
documents = "Documents"
source = "Source"
status = "Status"
usedBy = "Policies"
[portal.sources.tabs]
connections = "Connections"
sources = "Sources"
[portal.sources.types.editor]
description = "Documents your team has processed in the editor, across policy and AI runs."
label = "Editor"
@@ -8128,6 +8152,10 @@ label = "Access key ID"
label = "Bucket"
placeholder = "my-company-inbox"
[portal.sources.types.s3.fields.connection]
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
label = "Connection"
[portal.sources.types.s3.fields.endpoint]
helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO."
label = "Custom endpoint"
@@ -8157,21 +8185,14 @@ label = "Secret access key"
label = "Source"
[portal.sources.wizard]
back = "Back"
cancel = "Cancel"
continue = "Continue"
editTitle = "Edit source"
name = "Name"
namePlaceholder = "e.g. Claims intake"
save = "Save changes"
subtitle = "Step {{current}} of {{total}} · {{label}}"
title = "Connect a source"
type = "Type"
[portal.sources.wizard.steps]
chooseType = "Choose type"
configure = "Configure"
review = "Review & connect"
[portal.tier]
enterprise = "Enterprise plan"
free = "Editor plan"
pro = "Processor plan"
[portal.usage]
managePayment = "Manage Payment"
@@ -10187,6 +10208,19 @@ resetPw = "Reset password"
suspend = "Suspend"
unlock = "Unlock account"
[users.activity]
daysAgo = "{{count}}d ago"
hoursAgo = "{{count}}h ago"
justNow = "Just now"
minutesAgo = "{{count}}m ago"
monthsAgo_one = "{{count}} month ago"
monthsAgo_other = "{{count}} months ago"
never = "Never"
weeksAgo_one = "{{count}} week ago"
weeksAgo_other = "{{count}} weeks ago"
yearsAgo_one = "{{count}} year ago"
yearsAgo_other = "{{count}} years ago"
[users.cap]
addProcessor = "+ Processor"
approver = "Approves policy"
@@ -10257,7 +10291,9 @@ by = "Invited by {{who}}"
cancel = "Cancel"
count = "{{count}} pending"
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
expires = "Expires"
expiresInDays_one = "Expires in {{count}} day"
expiresInDays_other = "Expires in {{count}} days"
expiresToday = "Expires today"
title = "Pending invitations"
[users.loadError]
-6
View File
@@ -265,11 +265,6 @@
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
},
"autoFormDetection": {
"image": "/og_images/home.png",
"title": "Auto Form Detection - Stirling PDF",
"description": "Automatically detect form fields with AI and make your PDF fillable."
},
"multiTool": {
"image": "/og_images/multi-tool.png",
"title": "Multi-Tool - Stirling PDF",
@@ -564,7 +559,6 @@
"/booklet-imposition": "bookletImposition",
"/pdf-text-editor": "pdfTextEditor",
"/form-fill": "formFill",
"/auto-form-detection": "autoFormDetection",
"/multi-tool": "multiTool",
"/read": "read",
"/automate": "automate",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.14.1",
"version": "2.14.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -30,7 +30,6 @@ import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAd
import { addStampOperationConfig } from "@app/components/tools/addStamp/useAddStampOperation";
import { addAttachmentsOperationConfig } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
import { unlockPdfFormsOperationConfig } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
import { autoFormDetectionOperationConfig } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionOperation";
import { singleLargePageOperationConfig } from "@app/hooks/tools/singleLargePage/useSingleLargePageOperation";
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
@@ -466,39 +465,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
synonyms: ["form", "fill", "fillable", "input", "field", "acroform"],
},
autoFormDetection: {
icon: (
<LocalIcon
icon="document-scanner-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.autoFormDetection.title", "Auto Form Detection"),
component: lazy(
() => import("@app/tools/autoFormDetection/AutoFormDetection"),
),
description: t(
"home.autoFormDetection.desc",
"Automatically detect form fields with AI and make your PDF fillable.",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: 1,
endpoints: ["form-detection"],
operationConfig: asRegistryConfig(autoFormDetectionOperationConfig),
synonyms: [
"form",
"detect",
"fillable",
"acroform",
"ai",
"fields",
"auto",
],
automationSettings: null,
supportsAutomate: false,
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
name: t("home.changePermissions.title", "Change Permissions"),
@@ -1,133 +0,0 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
FormDetectionModelStatus,
FormDetectionCatalogEntry,
} from "@app/hooks/useFormDetectionModelStatus";
import apiClient from "@app/services/apiClient";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AutoFormDetectionParameters,
defaultParameters,
} from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionParameters";
const DETECT_ENDPOINT = "/api/v1/ai/form-detection/detect";
const STATUS_URL = "/api/v1/ai/form-detection-model/status";
// Static function shared by the hook and the automation executor.
export const buildAutoFormDetectionFormData = (
parameters: AutoFormDetectionParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("file", file);
// Server path detects and applies the AcroForm in one call, returning the fillable PDF.
formData.append("applyToPdf", "true");
if (typeof parameters.confidence === "number") {
formData.append("confThreshold", String(parameters.confidence));
}
return formData;
};
function outputName(file: File): string {
const base = (file.name || "document").replace(/\.pdf$/i, "");
return `${base}_form.pdf`;
}
async function serverDetect(
parameters: AutoFormDetectionParameters,
file: File,
): Promise<File> {
const res = await apiClient.post(
DETECT_ENDPOINT,
buildAutoFormDetectionFormData(parameters, file),
{ responseType: "blob" },
);
return new File([res.data as Blob], outputName(file), {
type: "application/pdf",
});
}
async function browserDetect(
parameters: AutoFormDetectionParameters,
file: File,
entry: FormDetectionCatalogEntry,
): Promise<File> {
// Lazy-load the in-browser engine (onnxruntime-web + the ~12MB wasm) only when browser-mode
// detection actually runs - it is never pulled into the initial bundle or loaded on the homepage.
const { runBrowserDetection } =
await import("@app/services/formDetection/runBrowserPipeline");
const bytes = await file.arrayBuffer();
const { appliedPdf } = await runBrowserDetection(
bytes,
entry,
parameters.confidence,
);
return new File([new Uint8Array(appliedPdf)], outputName(file), {
type: "application/pdf",
});
}
/**
* Runs detection where the admin configured it to run: 'server' (upload), 'browser' (in-browser
* WASM only - the PDF never leaves the device, no fallback), or 'auto' (browser first, falling back
* to the server on any browser-path error).
*/
async function processAutoFormDetection(
parameters: AutoFormDetectionParameters,
files: File[],
): Promise<{ files: File[] }> {
const file = files[0];
const status = (await apiClient.get(STATUS_URL))
.data as FormDetectionModelStatus;
const mode = status.executionMode ?? "auto";
const activeEntry = (status.catalog ?? []).find(
(c) => c.id === status.activeModelId,
);
if (mode === "server" || !activeEntry) {
return { files: [await serverDetect(parameters, file)] };
}
if (mode === "browser") {
// Strict: never fall back to the server, so the PDF truly stays on the device.
return { files: [await browserDetect(parameters, file, activeEntry)] };
}
// auto: prefer the browser, fall back to the server if it fails.
try {
return { files: [await browserDetect(parameters, file, activeEntry)] };
} catch (e) {
console.warn(
"[AutoFormDetection] in-browser engine failed; falling back to server",
e,
);
return { files: [await serverDetect(parameters, file)] };
}
}
export const autoFormDetectionOperationConfig = {
toolType: ToolType.custom,
customProcessor: processAutoFormDetection,
operationType: "autoFormDetection",
// Used only for cloud/credit routing of the server path; execution is the custom processor.
endpoint: DETECT_ENDPOINT,
defaultParameters,
} as const;
export const useAutoFormDetectionOperation = () => {
const { t } = useTranslation();
return useToolOperation<AutoFormDetectionParameters>({
...autoFormDetectionOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"autoFormDetection.error.failed",
"An error occurred while detecting form fields.",
),
),
});
};
@@ -1,24 +0,0 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface AutoFormDetectionParameters extends BaseParameters {
/** Optional confidence threshold override (0-1); blank uses the model default. */
confidence?: number;
}
export const defaultParameters: AutoFormDetectionParameters = {};
export type AutoFormDetectionParametersHook =
BaseParametersHook<AutoFormDetectionParameters>;
export const useAutoFormDetectionParameters =
(): AutoFormDetectionParametersHook => {
return useBaseParameters({
defaultParameters,
// Gated endpoint key - the tool tile/button stay disabled until a model is installed.
endpointName: "form-detection",
});
};
@@ -13,7 +13,6 @@ import {
import {
deserializeToolStep,
getExecutableTools,
serializeStepFromEndpoint,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
@@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => {
});
});
describe("serializeStepFromEndpoint", () => {
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
// (redact's `wordsToRedact`), with several fields left to their defaults.
const api = serializeStepFromEndpoint(
"/api/v1/security/auto-redact",
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
dynamicRegistry,
);
expect(api.operation).toBe("/api/v1/security/auto-redact");
// wordsToRedact -> listOfText (the field the backend actually reads), and the
// frontend-only `mode` is dropped.
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
expect(api.parameters).not.toHaveProperty("wordsToRedact");
expect(api.parameters).not.toHaveProperty("mode");
// Fields the wizard never set still get their defaults so the body is complete.
expect(api.parameters).toHaveProperty("wholeWordSearch");
expect(api.parameters).toHaveProperty("customPadding");
});
test("passes an unmapped endpoint's params through unchanged", () => {
expect(
serializeStepFromEndpoint(
"/api/v1/unknown/thing",
{ keep: true },
dynamicRegistry,
),
).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
@@ -197,31 +197,6 @@ export function serializeToolStep(
return { operation, parameters };
}
/**
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
* the backend contract - into the backend step contract, mapping params through the tool's
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
*/
export function serializeStepFromEndpoint(
operation: string,
params: ErasedToolParams,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const match = findToolByEndpoint({ operation, parameters: params }, registry);
const config = match?.[1].operationConfig;
if (!config) return { operation, parameters: params };
const merged = { ...(config.defaultParameters ?? {}), ...params };
return {
operation: resolveEndpoint(config, merged) ?? operation,
parameters: config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {},
};
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
interface Params {
a: number;
}
// A minimal config that type-checks against the flatten endpoint's model.
const CONFIG = {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 } satisfies Params,
toApiParams: (p: Params) => ({ renderDpi: p.a }),
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
};
describe("describeToolOperation", () => {
test("wraps the config's mappers and endpoint into a descriptor", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.endpoint).toBe("/api/v1/misc/flatten");
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
});
test("fromApi merges the mapped values over the defaults", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
});
test("throws when the config lacks a mapper", () => {
expect(() =>
describeToolOperation("/api/v1/misc/flatten", {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 },
toApiParams: (p: Params) => ({ renderDpi: p.a }),
}),
).toThrow(/mappers/);
});
});
@@ -0,0 +1,59 @@
/**
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
* frontend<->backend parameter conversion.
*/
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
readonly endpoint: E;
readonly defaultParameters: TParams;
toApi(params: TParams): ToolApiParams[E];
/** Backend model -> full frontend params (defaults merged under the mapped values). */
fromApi(api: ToolApiParams[E]): TParams;
}
/**
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
*/
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
endpoint: CE | null | ((params: TParams) => CE | null);
defaultParameters?: TParams;
toApiParams?(params: TParams): ToolApiParams[CE];
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
}
/**
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
*/
export function describeToolOperation<
E extends CE,
CE extends ToolEndpoint,
TParams,
>(
endpoint: E,
config: BidirectionalToolConfig<TParams, CE>,
): ToolOperationDescriptor<E, TParams> {
const { toApiParams, fromApiParams, defaultParameters } = config;
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
throw new Error(
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
);
}
return {
endpoint,
defaultParameters,
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
// runtime mapper produces this endpoint's model).
toApi: (params) => toApiParams(params) as ToolApiParams[E],
fromApi: (api) =>
({
...defaultParameters,
...fromApiParams(api as ToolApiParams[CE]),
}) as TParams,
};
}
@@ -249,11 +249,3 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
refetch: () => fetchAllEndpointStatuses(true),
};
}
/**
* Invalidate the cached endpoint-availability map so the next check refetches.
* Call after an admin action changes availability (e.g. installing the form-detection model).
*/
export function invalidateEndpointCache() {
resetGlobalCache();
}
@@ -1,139 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { invalidateEndpointCache } from "@app/hooks/useEndpointConfig";
export interface FormDetectionCatalogEntry {
id: string;
displayName: string;
description: string;
license: string;
sizeBytes: number;
onnxUrl: string;
sha256: string;
// Pipeline spec (parity with the backend ModelCatalogEntry) - drives the in-browser engine.
inputSize: number;
resizeMode?: string;
padColor?: number[];
channelOrder?: string;
normMean?: number[];
normStd?: number[];
outputLayout?: string;
hasObjectness?: boolean;
classNames?: string[];
classFieldTypes?: string[];
scoreThreshold?: number;
nms?: string;
iou?: number;
}
export type FormDetectionState =
| "not_installed"
| "downloading"
| "verifying"
| "ready"
| "failed";
export type FormDetectionExecutionMode = "auto" | "browser" | "server";
export interface FormDetectionModelStatus {
status: FormDetectionState;
progress: number;
activeModelId: string;
installed: string[];
error: string | null;
writable: boolean;
catalog: FormDetectionCatalogEntry[];
enabled: boolean;
executionMode: FormDetectionExecutionMode;
serverEngineAvailable: boolean;
}
const STATUS_URL = "/api/v1/ai/form-detection-model/status";
const INSTALL_URL = "/api/v1/ai/form-detection-model/install";
const CONFIG_URL = "/api/v1/ai/form-detection-model/config";
const MODEL_URL = "/api/v1/ai/form-detection-model";
/**
* Polls the Auto Form Detection model status and exposes admin install/uninstall actions.
* Polling only runs while a download/verify is in flight. When readiness flips, the shared
* endpoint-availability cache is invalidated so the tool tile re-enables/disables.
*/
export function useFormDetectionModelStatus() {
const [status, setStatus] = useState<FormDetectionModelStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStatus = useCallback(async () => {
try {
const res = await apiClient.get<FormDetectionModelStatus>(STATUS_URL);
setStatus(res.data);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load model status");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const active = status?.status;
// Poll only while an install is in flight.
useEffect(() => {
if (active === "downloading" || active === "verifying") {
const id = setInterval(fetchStatus, 1500);
return () => clearInterval(id);
}
return undefined;
}, [active, fetchStatus]);
// When readiness flips, the tool availability cache must be refreshed.
useEffect(() => {
if (active === "ready" || active === "not_installed") {
invalidateEndpointCache();
}
}, [active]);
const install = useCallback(
async (modelId: string) => {
await apiClient.post(INSTALL_URL, { modelId });
await fetchStatus();
},
[fetchStatus],
);
const uninstall = useCallback(
async (modelId?: string) => {
const url = modelId
? `${MODEL_URL}?modelId=${encodeURIComponent(modelId)}`
: MODEL_URL;
await apiClient.delete(url);
await fetchStatus();
},
[fetchStatus],
);
const setConfig = useCallback(
async (config: {
enabled?: boolean;
executionMode?: FormDetectionExecutionMode;
}) => {
await apiClient.post(CONFIG_URL, config);
await fetchStatus();
},
[fetchStatus],
);
return {
status,
loading,
error,
refetch: fetchStatus,
install,
uninstall,
setConfig,
};
}
@@ -1,51 +0,0 @@
// Apply detected fields as a real AcroForm using @cantoo/pdf-lib (the same library the rest of the
// app uses). The browser counterpart of FormUtils.addFields; coordinates are already in PDF points
// with a bottom-left origin, which is exactly what addToPage expects.
import { PDFDocument } from "@cantoo/pdf-lib";
import { DetectedField } from "@app/services/formDetection/types";
export async function applyFields(
pdfBytes: ArrayBuffer | Uint8Array,
fields: DetectedField[],
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const form = pdfDoc.getForm();
const pages = pdfDoc.getPages();
const counts: Record<string, number> = {};
for (const f of fields) {
const page = pages[f.page];
if (!page) continue;
const r = f.rectInPdfPoints;
if (r.w <= 0 || r.h <= 0) continue;
const kind =
f.type === "checkbox"
? "checkbox"
: f.type === "signature"
? "signature"
: "text";
counts[kind] = (counts[kind] ?? 0) + 1;
const name = `${kind}_${f.page + 1}_${counts[kind]}`;
try {
if (kind === "checkbox") {
const cb = form.createCheckBox(name);
cb.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
} else {
// pdf-lib has no first-class signature widget; a text field keeps it fillable.
const tf = form.createTextField(name);
tf.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
}
} catch {
// Skip a field that fails to add (e.g. a duplicate name) rather than abort the whole doc.
}
}
return pdfDoc.save();
}
@@ -1,32 +0,0 @@
// Map a detection (original bitmap pixels, top-left origin) to PDF points (bottom-left origin).
// 1:1 port of CoordinateMapper.toPdfPoints in the backend.
import { Detection, RectPt } from "@app/services/formDetection/types";
export interface RasterPageInfo {
pageWidthPt: number;
pageHeightPt: number;
scaleX: number; // pixels per point
scaleY: number;
}
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : Math.min(v, hi);
}
export function toPdfPoints(d: Detection, page: RasterPageInfo): RectPt {
const sx = page.scaleX > 0 ? page.scaleX : 1;
const sy = page.scaleY > 0 ? page.scaleY : 1;
const wPt = d.w / sx;
const hPt = d.h / sy;
let xPt = d.x / sx;
// Flip Y: bitmap origin is top-left, PDF origin is bottom-left.
let yPt = page.pageHeightPt - d.y / sy - hPt;
xPt = clamp(xPt, 0, page.pageWidthPt);
yPt = clamp(yPt, 0, page.pageHeightPt);
const w = clamp(wPt, 0, page.pageWidthPt - xPt);
const h = clamp(hPt, 0, page.pageHeightPt - yPt);
return { x: xPt, y: yPt, w, h };
}
@@ -1,90 +0,0 @@
import { describe, it, expect } from "vitest";
import { decode } from "@app/services/formDetection/decode";
import {
ModelPipelineSpec,
Preprocessed,
RawOutput,
} from "@app/services/formDetection/types";
// Golden fixture shared VERBATIM with the backend YoloTest.java
// (decodeThresholdsAndSuppressesOverlaps). Asserting the same inputs -> same outputs in both
// suites proves the in-browser decode matches the server decode 1:1, so the two execution paths
// produce identical detections.
const spec: ModelPipelineSpec = {
inputSize: 10,
resizeMode: "letterbox",
padColor: [114, 114, 114],
channelOrder: "rgb",
normMean: [0, 0, 0],
normStd: [1, 1, 1],
outputLayout: "nc_first",
hasObjectness: false,
classNames: ["text", "choice"],
classFieldTypes: ["text", "checkbox"],
scoreThreshold: 0.5,
nms: "perClass",
iou: 0.5,
};
// identity transform (scale 1, no pad), 10x10 source
const pre: Preprocessed = {
chw: new Float32Array(0),
inputSize: 10,
scaleX: 1,
scaleY: 1,
padX: 0,
padY: 0,
srcW: 10,
srcH: 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)
const data = [
5,
5,
8, // cx
5,
5,
8, // cy
4,
4,
2, // w
4,
4,
2, // h
0.9,
0.8,
0.7, // text score
0.1,
0.1,
0.1, // choice score
];
const out: RawOutput = { data, d1: 6, d2: 3 };
describe("formDetection decode (parity with backend Yolo.decode)", () => {
it("thresholds and suppresses overlaps identically to the Java golden fixture", () => {
const dets = decode(out, spec, pre, 0.5);
// a0 (box A, 0.9) kept; a1 (box A', 0.8) suppressed by NMS; a2 (box B, 0.7) kept
expect(dets).toHaveLength(2);
expect(dets[0].classId).toBe(0);
expect(dets[0].score).toBeCloseTo(0.9, 5);
expect(dets[0].x).toBeCloseTo(3, 4);
expect(dets[0].y).toBeCloseTo(3, 4);
expect(dets[0].w).toBeCloseTo(4, 4);
expect(dets[0].h).toBeCloseTo(4, 4);
expect(dets[1].score).toBeCloseTo(0.7, 5);
expect(dets[1].x).toBeCloseTo(7, 4);
expect(dets[1].y).toBeCloseTo(7, 4);
expect(dets[1].w).toBeCloseTo(2, 4);
expect(dets[1].h).toBeCloseTo(2, 4);
});
it("drops everything when the threshold exceeds all scores", () => {
expect(decode(out, spec, pre, 0.95)).toHaveLength(0);
});
});
@@ -1,123 +0,0 @@
// Pure decode/NMS/un-projection - a 1:1 port of Yolo.decode in the backend. Kept free of any
// browser API so it can be unit-tested for parity against the Java golden output.
import {
Detection,
ModelPipelineSpec,
Preprocessed,
RawOutput,
} from "@app/services/formDetection/types";
function at(
data: Float32Array | number[],
ncFirst: boolean,
anchors: number,
channels: number,
c: number,
a: number,
): number {
return ncFirst ? data[c * anchors + a] : data[a * channels + c];
}
function iou(a: Detection, b: Detection): number {
const ax2 = a.x + a.w;
const ay2 = a.y + a.h;
const bx2 = b.x + b.w;
const by2 = b.y + b.h;
const ix1 = Math.max(a.x, b.x);
const iy1 = Math.max(a.y, b.y);
const ix2 = Math.min(ax2, bx2);
const iy2 = Math.min(ay2, by2);
const iw = Math.max(0, ix2 - ix1);
const ih = Math.max(0, iy2 - iy1);
const inter = iw * ih;
const union = a.w * a.h + b.w * b.h - inter;
return union <= 0 ? 0 : inter / union;
}
function nms(
dets: Detection[],
mode: string,
iouThreshold: number,
): Detection[] {
if (dets.length < 2 || (mode ?? "").toLowerCase() === "none") {
return dets;
}
const classAgnostic = (mode ?? "").toLowerCase().includes("agnostic");
const sorted = [...dets].sort((x, y) => y.score - x.score);
const removed = new Array<boolean>(sorted.length).fill(false);
const keep: Detection[] = [];
for (let i = 0; i < sorted.length; i++) {
if (removed[i]) continue;
const di = sorted[i];
keep.push(di);
for (let j = i + 1; j < sorted.length; j++) {
if (removed[j]) continue;
const dj = sorted[j];
if (!classAgnostic && di.classId !== dj.classId) continue;
if (iou(di, dj) > iouThreshold) removed[j] = true;
}
}
return keep;
}
/** Decode raw output, threshold, NMS, and un-project to original bitmap pixels. */
export function decode(
out: RawOutput,
spec: ModelPipelineSpec,
pre: Preprocessed,
scoreThreshold: number,
): Detection[] {
const numClasses = spec.classNames?.length ?? 0;
if (numClasses === 0) return [];
const obj = spec.hasObjectness;
const ncFirst = (spec.outputLayout ?? "").toLowerCase() !== "anchors_first";
const channels = ncFirst ? out.d1 : out.d2;
const anchors = ncFirst ? out.d2 : out.d1;
const expected = 4 + (obj ? 1 : 0) + numClasses;
if (channels < expected) {
return [];
}
const classOffset = 4 + (obj ? 1 : 0);
const data = out.data;
const dets: Detection[] = [];
for (let a = 0; a < anchors; a++) {
const objScore = obj ? at(data, ncFirst, anchors, channels, 4, a) : 1;
let bestClass = -1;
let bestScore = 0;
for (let c = 0; c < numClasses; c++) {
const s =
at(data, ncFirst, anchors, channels, classOffset + c, a) * objScore;
if (s > bestScore) {
bestScore = s;
bestClass = c;
}
}
if (bestClass < 0 || bestScore < scoreThreshold) continue;
const cx = at(data, ncFirst, anchors, channels, 0, a);
const cy = at(data, ncFirst, anchors, channels, 1, a);
const w = at(data, ncFirst, anchors, channels, 2, a);
const h = at(data, ncFirst, anchors, channels, 3, a);
const x1 = cx - w / 2;
const y1 = cy - h / 2;
const ox = (x1 - pre.padX) / pre.scaleX;
const oy = (y1 - pre.padY) / pre.scaleY;
let ow = w / pre.scaleX;
let oh = h / pre.scaleY;
const cxl = Math.max(0, Math.min(ox, pre.srcW));
const 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.push({
classId: bestClass,
score: bestScore,
x: cxl,
y: cyl,
w: ow,
h: oh,
});
}
return nms(dets, spec.nms, spec.iou);
}
@@ -1,69 +0,0 @@
// Fetch the active .onnx from the backend serve endpoint, verify its SHA-256, and keep it in the
// Cache API keyed by checksum so it is downloaded only once per device (then reused across reloads).
const MODEL_FILE_URL = "/api/v1/ai/form-detection-model/file";
const CACHE_NAME = "stirling-form-detection-models";
function toHex(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let out = "";
for (let i = 0; i < bytes.length; i++) {
out += bytes[i].toString(16).padStart(2, "0");
}
return out;
}
async function verify(bytes: ArrayBuffer, expectedSha?: string): Promise<void> {
if (!expectedSha) return;
const digest = await crypto.subtle.digest("SHA-256", bytes);
const actual = toHex(digest);
if (actual.toLowerCase() !== expectedSha.toLowerCase()) {
throw new Error(
`Model checksum mismatch (expected ${expectedSha}, got ${actual})`,
);
}
}
/**
* Return the active model bytes, from the Cache API when present (and checksum-valid) or by
* downloading from the backend. The cache key is the checksum, so a model swap naturally misses.
*/
export async function loadModelBytes(
expectedSha?: string,
): Promise<ArrayBuffer> {
const cacheKey = `${MODEL_FILE_URL}#${expectedSha ?? "nosha"}`;
// Cache API is unavailable in non-secure contexts; degrade to a plain download in that case.
const cache = await caches.open(CACHE_NAME).catch(() => null);
if (cache) {
const hit = await cache.match(cacheKey).catch(() => undefined);
if (hit) {
const buf = await hit.arrayBuffer();
try {
await verify(buf, expectedSha);
return buf;
} catch {
await cache.delete(cacheKey).catch(() => false); // stale/corrupt - re-download
}
}
}
const res = await fetch(MODEL_FILE_URL, { credentials: "include" });
if (!res.ok) {
throw new Error(`Model download failed: HTTP ${res.status}`);
}
const buf = await res.arrayBuffer();
await verify(buf, expectedSha);
if (cache) {
await cache
.put(
cacheKey,
new Response(buf, {
headers: { "Content-Type": "application/octet-stream" },
}),
)
.catch(() => undefined); // best-effort; ignore quota/availability errors
}
return buf;
}
@@ -1,60 +0,0 @@
// Wraps onnxruntime-web: points it at the locally-hosted CPU WASM (copied by Vite into /ort/),
// runs single-threaded (the app sets no COOP/COEP so SharedArrayBuffer threading is unavailable),
// and caches one session per model checksum. Output is returned in the same flat layout the
// backend uses so decode.ts can interpret it identically.
import * as ort from "onnxruntime-web";
import { RawOutput } from "@app/services/formDetection/types";
let configured = false;
function configureOrt(): void {
if (configured) return;
ort.env.wasm.numThreads = 1;
// The CPU SIMD .wasm + its loader are copied next to the app under /ort/ by vite.config.ts.
ort.env.wasm.wasmPaths = new URL("ort/", document.baseURI).href;
configured = true;
}
let session: ort.InferenceSession | null = null;
let sessionKey: string | null = null;
/** Create (or reuse) a session for the given model bytes; keyed by checksum so swaps reload. */
export async function getSession(
modelBytes: ArrayBuffer,
key: string,
): Promise<ort.InferenceSession> {
configureOrt();
if (session && sessionKey === key) return session;
if (session) {
try {
await session.release();
} catch {
// ignore
}
session = null;
sessionKey = null;
}
session = await ort.InferenceSession.create(modelBytes, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
});
sessionKey = key;
return session;
}
export async function runInference(
s: ort.InferenceSession,
chw: Float32Array,
inputSize: number,
): Promise<RawOutput> {
const inputName = s.inputNames[0];
const tensor = new ort.Tensor("float32", chw, [1, 3, inputSize, inputSize]);
const result = await s.run({ [inputName]: tensor });
const out = result[s.outputNames[0]];
const dims = out.dims;
// Expect [1, d1, d2]; data is flat row-major so data[i*d2 + j] == out[0][i][j].
const d1 = dims.length >= 2 ? Number(dims[1]) : 0;
const d2 = dims.length >= 3 ? Number(dims[2]) : 0;
return { data: out.data as Float32Array, d1, d2 };
}
@@ -1,87 +0,0 @@
// Render each PDF page to an RGBA bitmap via PDF.js, at a DPI chosen so the long side is about the
// model input size. Mirrors the backend PageRasterizer: the px-per-point scale is taken from the
// rendered canvas so coordinate mapping does not depend on how the DPI rounds.
export interface RasterPage {
pageIndex: number;
rgba: Uint8ClampedArray;
widthPx: number;
heightPx: number;
pageWidthPt: number;
pageHeightPt: number;
scaleX: number;
scaleY: number;
}
declare global {
interface Window {
pdfjsLib?: {
getDocument: (src: { data: ArrayBuffer | Uint8Array }) => {
promise: Promise<PdfDocumentProxy>;
};
};
}
}
interface PdfViewport {
width: number;
height: number;
}
interface PdfPageProxy {
getViewport: (opts: { scale: number }) => PdfViewport;
render: (opts: {
canvasContext: CanvasRenderingContext2D;
viewport: PdfViewport;
}) => { promise: Promise<void> };
}
interface PdfDocumentProxy {
numPages: number;
getPage: (n: number) => Promise<PdfPageProxy>;
}
export async function renderPages(
pdfBytes: ArrayBuffer | Uint8Array,
inputSize: number,
): Promise<RasterPage[]> {
const pdfjs = window.pdfjsLib;
if (!pdfjs) throw new Error("PDF.js is not available in this build");
const data =
pdfBytes instanceof Uint8Array ? pdfBytes : new Uint8Array(pdfBytes);
const pdf = await pdfjs.getDocument({ data }).promise;
const pages: RasterPage[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const base = page.getViewport({ scale: 1 });
const pageWidthPt = base.width;
const pageHeightPt = base.height;
const maxSide = Math.max(pageWidthPt, pageHeightPt);
let dpi = maxSide <= 0 ? 150 : Math.round((72 * inputSize) / maxSide);
dpi = Math.max(36, Math.min(dpi, 300));
const scale = dpi / 72;
const vp = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(vp.width));
canvas.height = Math.max(1, Math.ceil(vp.height));
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("2D canvas context unavailable");
// Forms render on white; PDF.js does not paint a background.
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport: vp }).promise;
const rgba = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
pages.push({
pageIndex: i - 1,
rgba,
widthPx: canvas.width,
heightPx: canvas.height,
pageWidthPt,
pageHeightPt,
scaleX: pageWidthPt > 0 ? canvas.width / pageWidthPt : scale,
scaleY: pageHeightPt > 0 ? canvas.height / pageHeightPt : scale,
});
}
return pages;
}
@@ -1,107 +0,0 @@
// Letterbox/stretch-resize, channel-swap, normalise and lay out as NCHW float32 - the browser
// counterpart of Yolo.preprocess. The resize uses a 2D canvas (bilinear-ish); the normalisation
// step is split out as a pure function so it can be unit-tested against the Java golden vectors.
import {
ModelPipelineSpec,
Preprocessed,
} from "@app/services/formDetection/types";
function clampByte(v: number): number {
return Math.max(0, Math.min(255, Math.round(v)));
}
function orZeros(v?: number[]): number[] {
return v && v.length >= 3 ? v : [0, 0, 0];
}
function orOnes(v?: number[]): number[] {
return v && v.length >= 3 ? v : [1, 1, 1];
}
/** Pure: turn an NxN RGBA buffer into normalised NCHW float32 per the spec. */
export function normalizeToCHW(
rgbaNxN: Uint8ClampedArray | Uint8Array,
n: number,
spec: ModelPipelineSpec,
): Float32Array {
const bgr = (spec.channelOrder ?? "").toLowerCase() === "bgr";
const mean = orZeros(spec.normMean);
const std = orOnes(spec.normStd);
const plane = n * n;
const chw = new Float32Array(3 * plane);
for (let i = 0; i < plane; i++) {
const r = rgbaNxN[i * 4] / 255;
const g = rgbaNxN[i * 4 + 1] / 255;
const b = rgbaNxN[i * 4 + 2] / 255;
const c0 = bgr ? b : r;
const c1 = g;
const 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 chw;
}
/** Browser: resize source RGBA into the model's NxN input and normalise to NCHW float32. */
export function preprocess(
rgba: Uint8ClampedArray | Uint8Array,
srcW: number,
srcH: number,
spec: ModelPipelineSpec,
): Preprocessed {
const n = spec.inputSize;
const letterbox = (spec.resizeMode ?? "").toLowerCase() !== "stretch";
let scaleX: number;
let scaleY: number;
let padX: number;
let padY: number;
let drawW: number;
let drawH: number;
if (letterbox) {
const scale = Math.min(n / srcW, n / srcH);
drawW = Math.max(1, Math.round(srcW * scale));
drawH = Math.max(1, Math.round(srcH * scale));
padX = Math.floor((n - drawW) / 2);
padY = Math.floor((n - drawH) / 2);
scaleX = scale;
scaleY = scale;
} else {
drawW = n;
drawH = n;
padX = 0;
padY = 0;
scaleX = n / srcW;
scaleY = n / srcH;
}
const pad = spec.padColor ?? [114, 114, 114];
const canvas = document.createElement("canvas");
canvas.width = n;
canvas.height = n;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("2D canvas context unavailable");
ctx.fillStyle = `rgb(${clampByte(pad[0] ?? 114)},${clampByte(pad[1] ?? 114)},${clampByte(pad[2] ?? 114)})`;
ctx.fillRect(0, 0, n, n);
// Source RGBA -> temp canvas, then draw scaled into the padded NxN canvas.
const src = document.createElement("canvas");
src.width = srcW;
src.height = srcH;
const sctx = src.getContext("2d");
if (!sctx) throw new Error("2D canvas context unavailable");
// Copy into a fresh ArrayBuffer-backed array: ImageData's type rejects the
// Uint8ClampedArray<ArrayBufferLike> form (TS 5.7 typed-array generics).
const buf = new Uint8ClampedArray(rgba);
sctx.putImageData(new ImageData(buf, srcW, srcH), 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(src, padX, padY, drawW, drawH);
const px = ctx.getImageData(0, 0, n, n).data;
const chw = normalizeToCHW(px, n, spec);
return { chw, inputSize: n, scaleX, scaleY, padX, padY, srcW, srcH };
}
@@ -1,64 +0,0 @@
// Orchestrates the in-browser engine: fetch+cache the model, render each page, preprocess, run
// onnxruntime-web, decode, map to PDF points, then build the fillable AcroForm - entirely on the
// device, so the PDF never leaves the browser. Mirrors FormDetectionController.detect server-side.
import { FormDetectionCatalogEntry } from "@app/hooks/useFormDetectionModelStatus";
import { applyFields } from "@app/services/formDetection/applyFields";
import { toPdfPoints } from "@app/services/formDetection/coordinateMapping";
import { decode } from "@app/services/formDetection/decode";
import { loadModelBytes } from "@app/services/formDetection/modelCache";
import {
getSession,
runInference,
} from "@app/services/formDetection/onnxSession";
import { renderPages } from "@app/services/formDetection/pdfRender";
import { preprocess } from "@app/services/formDetection/preprocess";
import { DetectedField, resolveSpec } from "@app/services/formDetection/types";
export interface BrowserDetectResult {
fields: DetectedField[];
appliedPdf: Uint8Array;
}
export async function runBrowserDetection(
pdfBytes: ArrayBuffer,
activeEntry: FormDetectionCatalogEntry,
confThreshold?: number,
): Promise<BrowserDetectResult> {
const spec = resolveSpec(activeEntry);
const score =
typeof confThreshold === "number" ? confThreshold : spec.scoreThreshold;
const modelBytes = await loadModelBytes(activeEntry.sha256);
const session = await getSession(
modelBytes,
activeEntry.sha256 || activeEntry.id,
);
const fieldType = (classId: number): string => {
const types = spec.classFieldTypes;
return types && classId >= 0 && classId < types.length
? types[classId]
: "text";
};
// pdf.js may detach the input buffer, so give each consumer its own copy.
const pages = await renderPages(pdfBytes.slice(0), spec.inputSize);
const fields: DetectedField[] = [];
for (const page of pages) {
const pre = preprocess(page.rgba, page.widthPx, page.heightPx, spec);
const out = await runInference(session, pre.chw, spec.inputSize);
for (const d of decode(out, spec, pre, score)) {
fields.push({
type: fieldType(d.classId),
page: page.pageIndex,
rectInPdfPoints: toPdfPoints(d, page),
confidence: d.score,
});
}
}
const appliedPdf = await applyFields(pdfBytes.slice(0), fields);
return { fields, appliedPdf };
}
@@ -1,86 +0,0 @@
// Shared types for the in-browser Auto Form Detection pipeline. The numeric pipeline mirrors the
// backend (Yolo.java / CoordinateMapper.java) 1:1 so browser and server produce the same fields.
import { FormDetectionCatalogEntry } from "@app/hooks/useFormDetectionModelStatus";
/** Pipeline spec resolved from the active catalog entry (with backend defaults applied). */
export interface ModelPipelineSpec {
inputSize: number;
resizeMode: string; // "stretch" | "letterbox"
padColor: number[];
channelOrder: string; // "rgb" | "bgr"
normMean: number[];
normStd: number[];
outputLayout: string; // "nc_first" | "anchors_first"
hasObjectness: boolean;
classNames: string[];
classFieldTypes: string[];
scoreThreshold: number;
nms: string; // "none" | "perClass" | "classAgnostic"
iou: number;
}
/** A detection in original bitmap-pixel space, top-left origin. */
export interface Detection {
classId: number;
score: number;
x: number;
y: number;
w: number;
h: number;
}
/** Normalised model input plus the transform needed to invert it. */
export interface Preprocessed {
chw: Float32Array;
inputSize: number;
scaleX: number;
scaleY: number;
padX: number;
padY: number;
srcW: number;
srcH: number;
}
/** Raw model output flattened to data[i*d2 + j] with dims d1 x d2. */
export interface RawOutput {
data: Float32Array | number[];
d1: number;
d2: number;
}
export interface RectPt {
x: number;
y: number;
w: number;
h: number;
}
/** Shared output schema (mirrors the server /detect response). */
export interface DetectedField {
type: string;
page: number;
rectInPdfPoints: RectPt;
confidence: number;
}
/** Resolve a catalog entry's pipeline spec, applying the same defaults the backend uses. */
export function resolveSpec(
entry: FormDetectionCatalogEntry,
): ModelPipelineSpec {
return {
inputSize: entry.inputSize > 0 ? entry.inputSize : 1216,
resizeMode: entry.resizeMode ?? "letterbox",
padColor: entry.padColor ?? [114, 114, 114],
channelOrder: entry.channelOrder ?? "rgb",
normMean: entry.normMean ?? [0, 0, 0],
normStd: entry.normStd ?? [1, 1, 1],
outputLayout: entry.outputLayout ?? "nc_first",
hasObjectness: entry.hasObjectness ?? false,
classNames: entry.classNames ?? [],
classFieldTypes: entry.classFieldTypes ?? [],
scoreThreshold: entry.scoreThreshold ?? 0.3,
nms: entry.nms ?? "perClass",
iou: entry.iou ?? 0.45,
};
}
@@ -4,7 +4,12 @@
* without needing to make API calls
*/
// window.pdfjsLib is declared (narrowly) in formDetection/pdfRender.ts.
// PDF.js types (simplified)
declare global {
interface Window {
pdfjsLib?: any;
}
}
export interface SignatureDetectionResult {
hasSignatures: boolean;
@@ -24,9 +29,8 @@ const detectSignaturesInFile = async (
file: File,
): Promise<SignatureDetectionResult> => {
try {
// pdfRender.ts narrowly types window.pdfjsLib; this service uses more of the API.
const pdfjsLib = window.pdfjsLib as any;
if (!pdfjsLib) {
// Ensure PDF.js is available
if (!window.pdfjsLib) {
return {
hasSignatures: false,
error: "PDF.js not available",
@@ -37,7 +41,8 @@ const detectSignaturesInFile = async (
const arrayBuffer = await file.arrayBuffer();
// Load the PDF document
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer })
.promise;
let totalSignatures = 0;
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.1",
appVersion: "2.14.2",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
@@ -1,124 +0,0 @@
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
} from "@app/tests/helpers/api-stubs";
/**
* Stubbed coverage for the Auto Form Detection tool and its admin install panel.
* - the tool tile renders even when the `form-detection` endpoint is disabled
* (model not installed), so users can discover it,
* - the tile opens the tool once the endpoint reports enabled,
* - an admin sees the "AI Form Detection" settings section.
*/
const MODEL_STATUS_NOT_INSTALLED = {
status: "not_installed",
progress: 0,
activeModelId: "",
installed: [],
error: null,
writable: true,
catalog: [
{
id: "ffdnet-s",
displayName: "CommonForms FFDNet-S",
description: "Small form-field detector",
license: "CC-BY-4.0",
sizeBytes: 0,
onnxUrl: "",
sha256: "",
inputSize: 1024,
},
],
};
async function stubModelStatus(page: Page) {
await page.route("**/api/v1/ai/form-detection-model/status", (route) =>
route.fulfill({ json: MODEL_STATUS_NOT_INSTALLED }),
);
}
test.describe("Auto Form Detection tool", () => {
test("tile is present even when the model endpoint is disabled", async ({
page,
}) => {
await seedCookieConsent(page);
await bypassOnboarding(page);
await stubModelStatus(page);
await mockAppApis(page, {
endpointsAvailability: { "form-detection": { enabled: false } },
});
await page.goto("/");
const tile = page.locator('[data-tour="tool-button-autoFormDetection"]');
await expect(tile.first()).toBeVisible({ timeout: 10_000 });
});
test("clicking the tile opens the tool when the endpoint is enabled", async ({
page,
}) => {
await seedCookieConsent(page);
await bypassOnboarding(page);
await stubModelStatus(page);
await mockAppApis(page, {
endpointsAvailability: { "form-detection": { enabled: true } },
});
await page.goto("/");
await page
.locator('[data-tour="tool-button-autoFormDetection"]')
.first()
.click();
await expect(page).toHaveURL(/auto-form-detection/i);
});
test("admin sees the AI Form Detection box inside the Features section", async ({
page,
}) => {
await seedCookieConsent(page);
await bypassOnboarding(page);
// Seed JWT so the auth-gated dashboard chrome renders for the admin user.
await page.addInitScript(() => {
localStorage.setItem(
"stirling_jwt",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature",
);
});
await stubModelStatus(page);
await mockAppApis(page, {
enableLogin: true,
isAdmin: true,
user: {
id: 1,
username: "admin",
email: "admin@example.com",
roles: ["ROLE_ADMIN"],
},
});
await page.route("**/api/v1/proprietary/ui-data/account", (route) =>
route.fulfill({
json: { username: "admin", email: "admin@example.com", isAdmin: true },
}),
);
await page.goto("/");
const configBtn = page.locator('[data-testid="config-button"]').first();
if (!(await configBtn.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Config button not rendered for admin on this build");
return;
}
await configBtn.click();
const dialog = page.locator(".mantine-Modal-content").first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
// AI Form Detection now lives as a box inside the "Features" section,
// not as its own nav entry - navigate there first.
await dialog.getByText("Features", { exact: true }).first().click();
await expect(dialog.getByText(/AI Form Detection/i).first()).toBeVisible({
timeout: 5_000,
});
});
});
+28 -3
View File
@@ -270,8 +270,29 @@
--grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
}
/* Always-dark code palette. Not theme-switched. */
:root {
/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark
terminal palette in dark mode. (CodeBlock renders plain text, so only the
base colours are load-bearing; the syntax slots are kept for future Shiki. */
:root,
[data-theme="light"] {
--code-bg: #f6f8fa;
--code-bg-alt: #eef1f4;
--code-bg-header: #eaeef2;
--code-text: #1f2328;
--code-dim: #656d76;
--code-muted: #8c959f;
--code-keyword: #cf222e;
--code-string: #0a3069;
--code-number: #0550ae;
--code-fn: #8250df;
--code-type: #953800;
--code-property: #0550ae;
--code-comment: #6e7781;
--code-border: #d0d7de;
/* Window-chrome traffic-light dots in the code-block header. */
--code-dot: #d0d7de;
}
[data-theme="dark"] {
--code-bg: #0f172a;
--code-bg-alt: #1e293b;
--code-bg-header: #1a2332;
@@ -286,12 +307,16 @@
--code-property: #93c5fd;
--code-comment: #475569;
--code-border: #1e293b;
/* Window-chrome traffic-light dots in the code-block header. */
--code-dot: #475569;
}
/* Radii / typography / motion / spacing / z-index — theme-stable */
:root {
/* Home hero strip navy. Theme-stable by design — the hero keeps this deep
navy in both light and dark (it's a branded surface, like the assistant
header), so it's defined once here rather than in the light/dark blocks. */
--color-hero-navy: #16213e;
--radius-xs: 0.1875rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
@@ -1,45 +0,0 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { useAutoFormDetectionParameters } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionParameters";
import { useAutoFormDetectionOperation } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionOperation";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
const AutoFormDetection = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
"autoFormDetection",
useAutoFormDetectionParameters,
useAutoFormDetectionOperation,
props,
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasFiles || base.hasResults,
},
steps: [],
executeButton: {
text: t("autoFormDetection.submit", "Detect & make fillable"),
isVisible: !base.hasResults,
loadingText: t("autoFormDetection.loading", "Detecting form fields..."),
onClick: base.handleExecute,
endpointEnabled: base.endpointEnabled,
paramsValid: base.params.validateParameters(),
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("autoFormDetection.results.title", "Detected Form Fields"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
// Static method to get the operation hook for automation.
AutoFormDetection.tool = () => useAutoFormDetectionOperation;
export default AutoFormDetection as ToolComponent;
-1
View File
@@ -64,7 +64,6 @@ export const CORE_REGULAR_TOOL_IDS = [
"bookletImposition",
"pdfTextEditor",
"formFill",
"autoFormDetection",
] as const;
export const CORE_SUPER_TOOL_IDS = ["multiTool", "read", "automate"] as const;
+9 -6
View File
@@ -61,11 +61,14 @@
background: var(--button-bg, transparent) !important;
}
/* Disabled buttons in dark read as a muted surface (not a dimmed accent that
still looks clickable, and not an invisible transparent pill). Covers every
variant so a disabled primary and a disabled secondary look alike. */
[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]),
[data-theme="dark"]
.sui-btn--primary.mantine-Button-root:disabled:not([data-loading]),
[data-theme="dark"]
.sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) {
background: var(--button-bg);
color: var(--button-color);
opacity: 0.55;
.sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) {
background: var(--color-bg-muted);
color: var(--color-text-5);
border-color: transparent;
opacity: 1;
}
+2 -1
View File
@@ -51,7 +51,8 @@
transition: background var(--motion-fast);
}
.sui-code__copy:hover {
background: rgba(255, 255, 255, 0.05);
/* Subtle tint that reads on both the light and dark code surfaces. */
background: color-mix(in srgb, var(--code-text) 8%, transparent);
}
.sui-code__pre {
margin: 0;
+42 -31
View File
@@ -19,6 +19,12 @@ export interface TableProps<T> {
rowKey: (row: T) => string;
/** Makes rows interactive (hover + click + keyboard). */
onRowClick?: (row: T) => void;
/**
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
* all rows interactive.
*/
isRowInteractive?: (row: T) => boolean;
/** Rendered in place of the body when there are no rows. */
empty?: ReactNode;
className?: string;
@@ -35,6 +41,7 @@ export function Table<T>({
rows,
rowKey,
onRowClick,
isRowInteractive,
empty,
className,
}: TableProps<T>) {
@@ -66,38 +73,42 @@ export function Table<T>({
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={rowKey(row)}
className={
interactive
? "sui-table__row sui-table__row--interactive"
: "sui-table__row"
}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={interactive ? 0 : undefined}
role={interactive ? "button" : undefined}
onKeyDown={
interactive
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick?.(row);
rows.map((row) => {
const rowInteractive =
interactive && (isRowInteractive?.(row) ?? true);
return (
<tr
key={rowKey(row)}
className={
rowInteractive
? "sui-table__row sui-table__row--interactive"
: "sui-table__row"
}
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
tabIndex={rowInteractive ? 0 : undefined}
role={rowInteractive ? "button" : undefined}
onKeyDown={
rowInteractive
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick?.(row);
}
}
}
: undefined
}
>
{columns.map((c) => (
<td
key={c.key}
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
>
{c.render(row)}
</td>
))}
</tr>
))
: undefined
}
>
{columns.map((c) => (
<td
key={c.key}
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
>
{c.render(row)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
@@ -480,6 +480,3 @@ export function useEndpointConfig(): EndpointConfig {
return { backendUrl };
}
// Desktop endpoint config holds no module-level cache; nothing to invalidate.
export function invalidateEndpointCache() {}
@@ -5,6 +5,7 @@ import { Documents } from "@portal/views/Documents";
import { Pipelines } from "@portal/views/Pipelines";
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
import { Sources } from "@portal/views/Sources";
import { SourceBuilder } from "@portal/views/SourceBuilder";
import { AgentBuilder } from "@portal/views/AgentBuilder";
import { Policies } from "@portal/views/Policies";
import { Components } from "@portal/views/Components";
@@ -36,6 +37,14 @@ export function ViewRouter() {
element={<PipelineBuilder />}
/>
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
<Route
path={`${rel(VIEW_PATHS.sources)}/new`}
element={<SourceBuilder />}
/>
<Route
path={`${rel(VIEW_PATHS.sources)}/:id`}
element={<SourceBuilder />}
/>
<Route
path={rel(VIEW_PATHS["agent-builder"])}
element={<AgentBuilder />}
+5
View File
@@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
draft: "neutral",
};
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
published: "portal.agentBuilder.status.published",
draft: "portal.agentBuilder.status.draft",
};
/**
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
* palette in restricted mode so the deny list is picked from a known set
@@ -0,0 +1,74 @@
/**
* Integrations service layer: stored connections (S3 today; MCP/API later) that
* policy sources and pipeline outputs reference by id instead of embedding
* credentials. Secrets are write-only - reads return them masked, and sending
* the mask back on update keeps the stored value.
*/
import { apiClient } from "@portal/api/http";
export type IntegrationType = "S3" | "MCP" | "API";
export type OwnerScope = "USER" | "TEAM" | "SERVER";
/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */
export interface IntegrationConfig {
id: number;
integrationType: IntegrationType;
name: string;
scope: OwnerScope;
ownerUserId: number | null;
ownerTeamId: number | null;
enabled: boolean;
locked: boolean;
defaultAccess: string;
config: Record<string, unknown>;
canManage: boolean;
createdAt: string;
updatedAt: string;
}
/** Create/update body; omitted fields keep their stored values on update. */
export interface IntegrationConfigRequest {
integrationType?: IntegrationType;
name?: string;
scope?: OwnerScope;
ownerTeamId?: number | null;
enabled?: boolean;
config?: Record<string, unknown>;
}
export async function fetchIntegrations(): Promise<IntegrationConfig[]> {
return apiClient.local.json<IntegrationConfig[]>("/api/v1/integrations");
}
/** The S3 connections the caller may use, for source/output pickers. */
export async function fetchS3Connections(): Promise<IntegrationConfig[]> {
return (await fetchIntegrations()).filter(
(integration) => integration.integrationType === "S3",
);
}
export async function createIntegration(
body: IntegrationConfigRequest,
): Promise<IntegrationConfig> {
return apiClient.local.json<IntegrationConfig>("/api/v1/integrations", {
method: "POST",
body,
});
}
export async function updateIntegration(
id: number,
body: IntegrationConfigRequest,
): Promise<IntegrationConfig> {
return apiClient.local.json<IntegrationConfig>(
`/api/v1/integrations/${encodeURIComponent(id)}`,
{ method: "PUT", body },
);
}
export async function deleteIntegration(id: number): Promise<void> {
await apiClient.local.json<void>(
`/api/v1/integrations/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
}
+39 -52
View File
@@ -9,9 +9,12 @@
* approach the editor uses for its own catalogue view.
*/
import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type {
PolicyDecodedState,
PolicyRunView,
@@ -65,7 +68,7 @@ export interface PolicyConfigDef {
rules: string[];
scopeLabel: string;
fields: PolicyField[];
defaultOperations: WirePipelineStep[];
defaultOperations: PolicyToolStep[];
}
export interface PolicyState {
@@ -127,20 +130,11 @@ export interface CatalogueEntry {
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tool → endpoint registry */
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
export const TOOL_ENDPOINTS: Record<string, string> = {
redact: "/api/v1/security/auto-redact",
sanitize: "/api/v1/security/sanitize-pdf",
watermark: "/api/v1/security/add-watermark",
ocr: "/api/v1/misc/ocr-pdf",
flatten: "/api/v1/misc/flatten",
compress: "/api/v1/misc/compress-pdf",
};
/** Values are i18n keys — render with t(). */
export const ENDPOINT_LABELS: Record<string, string> = {
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
@@ -153,7 +147,8 @@ export function humanizeEndpoint(
path: string,
t: (key: string) => string,
): string {
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
const label = ENDPOINT_LABELS[path as ToolEndpoint];
if (label) return t(label);
const last = path.split("/").filter(Boolean).pop() ?? path;
return last
.replace(/-/g, " ")
@@ -229,10 +224,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
@@ -259,32 +251,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{
operation: TOOL_ENDPOINTS.redact,
parameters: {
mode: "automatic",
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
},
},
{
operation: TOOL_ENDPOINTS.sanitize,
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
},
},
{
operation: TOOL_ENDPOINTS.watermark,
// convertPDFToImage bakes the watermark in so it can't be stripped
parameters: {
convertPDFToImage: true,
},
},
// Flatten to image so redactions can't be lifted off.
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
}),
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
policyStep("sanitize", { removeEmbeddedFiles: false }),
// Bake in via image so it can't be stripped.
policyStep("watermark", { convertPDFToImage: true }),
],
fields: [],
},
@@ -296,10 +272,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.compliance.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.compliance.fields.frameworks",
@@ -342,7 +315,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.routing.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.routing.fields.destination",
@@ -373,7 +346,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.retention.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.retention.fields.keepFor",
@@ -556,17 +529,30 @@ const DEFAULT_RETRY_DELAY = 5;
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
type CatalogueWireBody = WirePolicy & { categoryId: string };
/**
* The persisted policy name derived from its category, e.g. "Security Policy".
* `category.label` is an i18n key, so translate it before building the name;
* otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources
* "Used by" pill).
*/
function policyDisplayName(entry: CatalogueEntry, t: TFunction): string {
return t("portal.policies.defaultName", {
category: t(entry.category.label),
});
}
/** Build a wire policy from a setup wizard result. */
export function buildWireFromSetup(
entry: CatalogueEntry,
result: PolicySetupResult,
t: TFunction,
enabled = true,
): CatalogueWireBody {
return {
categoryId: entry.category.id,
...toWirePolicy({
id: entry.policy?.state.backendId ?? "",
name: `${entry.category.label} Policy`,
name: policyDisplayName(entry, t),
enabled,
categoryId: entry.category.id,
sources: result.sources,
@@ -589,13 +575,14 @@ export function buildWireFromState(
entry: CatalogueEntry,
policy: DecoratedPolicy,
enabled: boolean,
t: TFunction,
): CatalogueWireBody {
const s = policy.state;
return {
categoryId: entry.category.id,
...toWirePolicy({
id: s.backendId ?? "",
name: `${entry.category.label} Policy`,
name: policyDisplayName(entry, t),
enabled,
categoryId: entry.category.id,
sources: s.sources,
@@ -0,0 +1,176 @@
/**
* Processor-flow assembler for the home visualiser.
*
* Fans in the three real portal surfaces — sources (`/api/v1/sources`), policies
* (`/api/v1/policies`) and their runs (`/api/v1/policies/runs`) — and derives the
* left→middle→right shape the {@link ProcessorFlow} component renders:
*
* sources → policies → outcomes
*
* Everything here is real backend data. Per-run source attribution does not
* exist (a `PolicyRunView` carries `policyId` but no source id), so the flow
* animation is illustrative; the node counts are not — each source's `docs24h`,
* each policy's trailing-24h run count, and the success/failure split all come
* straight from the API.
*/
import { apiClient } from "@portal/api/http";
import { fetchSources } from "@portal/api/sources";
import { POLICY_CATEGORIES } from "@portal/api/policies";
import { fromWirePolicy } from "@app/policies/codec";
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
/** A source that actually feeds the processor today (editor, folder, S3, …). */
export interface FlowSource {
id: string;
/** Display name (already resolved; editor rows get a friendly label). */
name: string;
type: string;
/** Documents this source fed into runs over the trailing 24h. */
docs24h: number;
}
/**
* A connector type shown in the sources column but not yet a real source type —
* a "coming soon" affordance only. `labelKey` is an i18n key.
*/
export interface FlowComingSoonSource {
key: string;
labelKey: string;
}
/**
* Row display state, mirroring the Policies page:
* - `active` — configured + enabled; shows its live 24h run count
* - `off` — available but not set up; offers a "Set up" CTA
* - `locked` — a coming-soon category that doesn't exist yet
*/
export type FlowPolicyState = "active" | "off" | "locked";
/**
* One row in the middle policies column — the full policy catalogue, in the
* same order the Policies page shows, including the coming-soon categories.
*/
export interface FlowPolicy {
/** Category id (also the lane key for the flow animation). */
key: string;
/** i18n key for the category label. */
labelKey: string;
/** Material Symbols icon name (from the catalogue). */
icon: string;
state: FlowPolicyState;
configured: boolean;
runs24h: number;
}
export type FlowOutcomeKey = "success" | "failed";
/** A terminal audit outcome node on the right, counted over the trailing 24h. */
export interface FlowOutcome {
key: FlowOutcomeKey;
labelKey: string;
count24h: number;
}
export interface ProcessorFlow {
sources: FlowSource[];
comingSoonSources: FlowComingSoonSource[];
policies: FlowPolicy[];
outcomes: FlowOutcome[];
}
const DAY_MS = 86_400_000;
/** Connector types the sources column advertises but can't create yet. */
const COMING_SOON_SOURCES: FlowComingSoonSource[] = [
{ key: "apiMcp", labelKey: "portal.processorFlow.sources.comingSoon.apiMcp" },
{
key: "cloud",
labelKey: "portal.processorFlow.sources.comingSoon.cloud",
},
{
key: "email",
labelKey: "portal.processorFlow.sources.comingSoon.email",
},
];
/**
* The full policy catalogue, in the Policies-page order, including the
* coming-soon categories (rendered as locked). `active` rows carry their
* trailing-24h run count.
*/
function buildPolicies(
wirePolicies: WirePolicy[],
runs: PolicyRunView[],
): FlowPolicy[] {
const cutoff = Date.now() - DAY_MS;
const decoded = wirePolicies.map(fromWirePolicy);
return POLICY_CATEGORIES.map((cat) => {
const dp = decoded.find((p) => p.categoryId === cat.id);
const configured = Boolean(dp?.enabled);
const state: FlowPolicyState = configured
? "active"
: cat.comingSoon
? "locked"
: "off";
const runs24h = dp
? runs.filter((r) => r.policyId === dp.id && r.createdAt >= cutoff).length
: 0;
return {
key: cat.id,
labelKey: cat.label,
icon: cat.icon,
state,
configured,
runs24h,
};
});
}
/** Terminal audit outcomes over the trailing 24h — success vs failure. */
function buildOutcomes(runs: PolicyRunView[]): FlowOutcome[] {
const cutoff = Date.now() - DAY_MS;
const recent = runs.filter((r) => r.createdAt >= cutoff);
const success = recent.filter((r) => r.status === "COMPLETED").length;
const failed = recent.filter(
(r) => r.status === "FAILED" || r.status === "CANCELLED",
).length;
return [
{
key: "success",
labelKey: "portal.processorFlow.outcomes.success",
count24h: success,
},
{
key: "failed",
labelKey: "portal.processorFlow.outcomes.failed",
count24h: failed,
},
];
}
/** Assemble the full flow model from the three live portal surfaces. */
export async function fetchProcessorFlow(): Promise<ProcessorFlow> {
const [sourcesResp, wirePolicies, runs] = await Promise.all([
fetchSources(),
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
apiClient.local
.json<PolicyRunView[]>("/api/v1/policies/runs")
.catch(() => [] as PolicyRunView[]),
]);
const sources: FlowSource[] = sourcesResp.sources.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
docs24h: s.docs24h,
}));
return {
sources,
comingSoonSources: COMING_SOON_SOURCES,
policies: buildPolicies(wirePolicies, runs),
outcomes: buildOutcomes(runs),
};
}
+14 -9
View File
@@ -1,3 +1,7 @@
// The bare i18next singleton (the same instance @app/i18n initializes at
// startup), imported directly so this data module doesn't pull i18n's
// init side effects into unit tests that mock react-i18next.
import i18n from "i18next";
import { apiClient } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
@@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId {
/** A member's last-seen time as plain language; "Never" when no session is tracked. */
function relativeTime(value: number | string | undefined): string {
if (value === undefined || value === null) return "Never";
if (value === undefined || value === null)
return i18n.t("users.activity.never");
const ts = typeof value === "string" ? Date.parse(value) : value;
if (!Number.isFinite(ts) || ts <= 0) return "Never";
if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never");
const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
if (mins < 1) return i18n.t("users.activity.justNow");
if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours });
const days = Math.round(hours / 24);
if (days < 7) return `${days}d ago`;
if (days < 7) return i18n.t("users.activity.daysAgo", { count: days });
const weeks = Math.round(days / 7);
if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks });
const months = Math.round(days / 30);
if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`;
if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months });
const years = Math.round(days / 365);
return years <= 1 ? "1 year ago" : `${years} years ago`;
return i18n.t("users.activity.yearsAgo", { count: years });
}
/** 0 / huge sentinel license values mean "no seat limit". */
@@ -15,7 +15,7 @@
align-items: center;
gap: 1.25rem;
padding: 1rem 1.25rem;
background: #16213e;
background: var(--color-hero-navy);
}
.portal-editor-hero__logo {
@@ -123,7 +123,7 @@
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
background: #ffffff;
border-color: #ffffff;
color: #16213e;
color: var(--color-hero-navy);
}
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover {
background: rgba(255, 255, 255, 0.88);

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