Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1874b39f | ||
|
|
776749277c | ||
|
|
41b1b89fcb | ||
|
|
4d4e994562 | ||
|
|
2b118556f3 | ||
|
|
fbaff56d1c | ||
|
|
fc210bc761 | ||
|
|
e3802c56b2 | ||
|
|
a1b15e0570 | ||
|
|
76549288a9 | ||
|
|
b5d0c4a5ed | ||
|
|
8bfcf6eb7e | ||
|
|
b019f9b570 | ||
|
|
c59571f17c | ||
|
|
7cdf72c5f9 | ||
|
|
38ccea074c | ||
|
|
a7307ff393 | ||
|
|
72729e99c1 | ||
|
|
5fba2720f0 | ||
|
|
01a1ef8c44 | ||
|
|
8535c7e9ac | ||
|
|
cca3f42623 | ||
|
|
1b7ffcdbac | ||
|
|
67a0ca6110 | ||
|
|
8e4b2e2fc6 | ||
|
|
3c93457021 |
@@ -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,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,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
-19
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
-64
@@ -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));
|
||||
}
|
||||
}
|
||||
-174
@@ -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) {}
|
||||
}
|
||||
-119
@@ -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;
|
||||
}
|
||||
}
|
||||
-99
@@ -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);
|
||||
}
|
||||
}
|
||||
-153
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-252
@@ -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};
|
||||
}
|
||||
}
|
||||
-16
@@ -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) {}
|
||||
}
|
||||
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
-75
@@ -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;
|
||||
}
|
||||
-46
@@ -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;
|
||||
}
|
||||
-35
@@ -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);
|
||||
}
|
||||
}
|
||||
-65
@@ -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;
|
||||
}
|
||||
}
|
||||
-509
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-4
@@ -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()
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+20
@@ -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
|
||||
|
||||
+13
-1
@@ -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) {
|
||||
|
||||
+14
-13
@@ -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".
|
||||
|
||||
+7
-5
@@ -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<>();
|
||||
|
||||
+212
@@ -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());
|
||||
}
|
||||
}
|
||||
+56
@@ -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,
|
||||
|
||||
+151
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
}
|
||||
]
|
||||
-49
@@ -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());
|
||||
}
|
||||
}
|
||||
-70
@@ -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());
|
||||
}
|
||||
}
|
||||
-95
@@ -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"));
|
||||
}
|
||||
}
|
||||
-81
@@ -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());
|
||||
}
|
||||
}
|
||||
-98
@@ -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());
|
||||
}
|
||||
}
|
||||
-45
@@ -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);
|
||||
}
|
||||
}
|
||||
-173
@@ -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"));
|
||||
}
|
||||
}
|
||||
+53
-2
@@ -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() {
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
+22
@@ -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");
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
+3
-1
@@ -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();
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+212
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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();
|
||||
}
|
||||
}
|
||||
+149
@@ -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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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
@@ -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"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
-133
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
-24
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user