Merge branch 'main' into pipeline_controller_20260525
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
name: Docker Compose Cucumber tests (saas / PAYG)
|
||||
|
||||
# Self-contained CI job for the PAYG shadow-mode cucumber scenarios.
|
||||
# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR
|
||||
# that doesn't touch the saas flavour.
|
||||
#
|
||||
# Companion to `docker-compose-tests.yml` (which runs against the
|
||||
# proprietary-flavour stack and skips features/payg via behave.ini's
|
||||
# exclude_re). Kept as a separate workflow so the saas matrix can fail and
|
||||
# succeed independently without touching the main cucumber harness.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "app/saas/**"
|
||||
- "testing/cucumber/features/payg/**"
|
||||
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||
- "testing/cucumber/requirements.txt"
|
||||
- "testing/compose/docker-compose-saas.yml"
|
||||
- "testing/compose/payg/**"
|
||||
- "testing/test-payg.sh"
|
||||
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/saas/**"
|
||||
- "testing/cucumber/features/payg/**"
|
||||
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||
- "testing/cucumber/requirements.txt"
|
||||
- "testing/compose/docker-compose-saas.yml"
|
||||
- "testing/compose/payg/**"
|
||||
- "testing/test-payg.sh"
|
||||
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
docker-compose-tests-saas:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-saas-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
# No "Install Docker Compose" step: Ubuntu runners ship with `docker compose`
|
||||
# v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout
|
||||
# (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary
|
||||
# isn't needed. Avoids a `curl | sudo install` without checksum verification
|
||||
# (Aikido flagged this when copy-pasted from docker-compose-tests.yml).
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Run PAYG Cucumber Tests
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: |
|
||||
chmod +x ./testing/test-payg.sh
|
||||
./testing/test-payg.sh
|
||||
|
||||
- name: Dump saas container logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true
|
||||
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true
|
||||
|
||||
- name: Upload PAYG Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: payg-cucumber-report
|
||||
path: testing/cucumber/report-payg.html
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: PAYG Cucumber Test Report
|
||||
if: always()
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: PAYG Cucumber Tests
|
||||
path: testing/cucumber/junit-payg/*.xml
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
@@ -62,21 +62,21 @@ tasks:
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build --bundles app
|
||||
- npx tauri build --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
build:dev:windows:
|
||||
desc: "Build Tauri desktop NSIS installer (Windows)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build --bundles nsis
|
||||
- npx tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
build:dev:linux:
|
||||
desc: "Build Tauri desktop AppImage (Linux)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build --bundles appimage
|
||||
- npx tauri build --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
test:
|
||||
desc: "Run Tauri/Cargo tests"
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Provides metadata about tool endpoints for internal dispatch. */
|
||||
public interface ToolMetadataService {
|
||||
|
||||
/** Returns true if the given operation path accepts multiple input files. */
|
||||
boolean isMultiInput(String operationPath);
|
||||
|
||||
/**
|
||||
* Returns the file extensions (lowercase, no leading dot, e.g. {@code "pdf"}) that the
|
||||
* operation accepts as input ({@code output=false}) or produces as output ({@code
|
||||
* output=true}), derived from the endpoint's declared type. Returns {@code null} when the
|
||||
* endpoint declares no specific type, which callers should treat as "any type accepted".
|
||||
*/
|
||||
List<String> getExtensionTypes(boolean output, String operationPath);
|
||||
|
||||
/**
|
||||
* Returns true when the endpoint's ZIP response is a transport for multiple typed results and
|
||||
* should be unpacked: multi-output endpoints (Type:SIMO / Type:MIMO) and wrapper declarations
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.Transformer;
|
||||
import javax.xml.transform.TransformerException;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NamedNodeMap;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
// Strips external refs from OOXML/ODF uploads so LibreOffice can't be made to fetch them.
|
||||
@Component
|
||||
@Slf4j
|
||||
public class OfficeDocumentSanitizer {
|
||||
|
||||
private static final Set<String> OOXML_EXTENSIONS =
|
||||
Set.of(
|
||||
"docx", "docm", "dotx", "dotm", "xlsx", "xlsm", "xltx", "xltm", "pptx", "pptm",
|
||||
"potx", "potm", "ppsx", "ppsm");
|
||||
|
||||
private static final Set<String> ODF_EXTENSIONS =
|
||||
Set.of(
|
||||
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg", "odf", "odc", "odi",
|
||||
"odm");
|
||||
|
||||
private static final Set<String> ODF_XML_PARTS =
|
||||
Set.of("content.xml", "styles.xml", "meta.xml", "settings.xml");
|
||||
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public OfficeDocumentSanitizer(
|
||||
SsrfProtectionService ssrfProtectionService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.ssrfProtectionService = ssrfProtectionService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isSanitizableExtension(String extension) {
|
||||
if (extension == null) {
|
||||
return false;
|
||||
}
|
||||
String lower = extension.toLowerCase(Locale.ROOT);
|
||||
return OOXML_EXTENSIONS.contains(lower) || ODF_EXTENSIONS.contains(lower);
|
||||
}
|
||||
|
||||
public byte[] sanitize(byte[] documentBytes, String extension) throws IOException {
|
||||
if (documentBytes == null || documentBytes.length == 0) {
|
||||
throw new IOException("Office document input is empty or null");
|
||||
}
|
||||
if (applicationProperties.getSystem().isDisableSanitize()) {
|
||||
log.debug("Office document sanitization disabled by configuration");
|
||||
return documentBytes;
|
||||
}
|
||||
if (!isSanitizableExtension(extension)) {
|
||||
return documentBytes;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(documentBytes.length);
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new ByteArrayInputStream(documentBytes));
|
||||
ZipOutputStream zipOut = new ZipOutputStream(out)) {
|
||||
|
||||
ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
byte[] bytes = entry.isDirectory() ? new byte[0] : zipIn.readAllBytes();
|
||||
|
||||
if (!entry.isDirectory()) {
|
||||
bytes = sanitizeEntry(name, bytes);
|
||||
}
|
||||
|
||||
ZipEntry outEntry = new ZipEntry(name);
|
||||
if (entry.getComment() != null) {
|
||||
outEntry.setComment(entry.getComment());
|
||||
}
|
||||
if (entry.getExtra() != null) {
|
||||
outEntry.setExtra(entry.getExtra());
|
||||
}
|
||||
zipOut.putNextEntry(outEntry);
|
||||
if (!entry.isDirectory()) {
|
||||
zipOut.write(bytes);
|
||||
}
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private byte[] sanitizeEntry(String entryName, byte[] entryBytes) {
|
||||
String lower = entryName.toLowerCase(Locale.ROOT);
|
||||
try {
|
||||
if (lower.endsWith(".rels")) {
|
||||
return sanitizeOoxmlRels(entryBytes);
|
||||
}
|
||||
if (isOdfXmlPart(lower)) {
|
||||
return sanitizeOdfXml(entryBytes);
|
||||
}
|
||||
} catch (ParserConfigurationException
|
||||
| SAXException
|
||||
| IOException
|
||||
| TransformerException e) {
|
||||
log.warn(
|
||||
"Failed to parse XML part '{}' for sanitization, leaving as-is: {}",
|
||||
entryName,
|
||||
e.getMessage());
|
||||
}
|
||||
return entryBytes;
|
||||
}
|
||||
|
||||
private boolean isOdfXmlPart(String lowerName) {
|
||||
int slash = lowerName.lastIndexOf('/');
|
||||
String base = slash >= 0 ? lowerName.substring(slash + 1) : lowerName;
|
||||
return ODF_XML_PARTS.contains(base);
|
||||
}
|
||||
|
||||
private byte[] sanitizeOoxmlRels(byte[] xmlBytes)
|
||||
throws IOException, ParserConfigurationException, SAXException, TransformerException {
|
||||
Document doc = parseSecurely(xmlBytes);
|
||||
Element root = doc.getDocumentElement();
|
||||
if (root == null) {
|
||||
return xmlBytes;
|
||||
}
|
||||
NodeList relationships = root.getElementsByTagNameNS("*", "Relationship");
|
||||
List<Node> toRemove = new ArrayList<>();
|
||||
for (int i = 0; i < relationships.getLength(); i++) {
|
||||
Node node = relationships.item(i);
|
||||
NamedNodeMap attrs = node.getAttributes();
|
||||
if (attrs == null) {
|
||||
continue;
|
||||
}
|
||||
Node targetMode = attrs.getNamedItem("TargetMode");
|
||||
if (targetMode == null || !"external".equalsIgnoreCase(targetMode.getNodeValue())) {
|
||||
continue;
|
||||
}
|
||||
Node target = attrs.getNamedItem("Target");
|
||||
String targetValue = target == null ? "" : target.getNodeValue();
|
||||
if (isAdminAllowed(targetValue)) {
|
||||
continue;
|
||||
}
|
||||
log.warn(
|
||||
"Stripping OOXML external relationship target: {}",
|
||||
truncateForLog(targetValue));
|
||||
toRemove.add(node);
|
||||
}
|
||||
if (toRemove.isEmpty()) {
|
||||
return xmlBytes;
|
||||
}
|
||||
for (Node n : toRemove) {
|
||||
n.getParentNode().removeChild(n);
|
||||
}
|
||||
return serializeDocument(doc);
|
||||
}
|
||||
|
||||
private byte[] sanitizeOdfXml(byte[] xmlBytes)
|
||||
throws IOException, ParserConfigurationException, SAXException, TransformerException {
|
||||
Document doc = parseSecurely(xmlBytes);
|
||||
Element root = doc.getDocumentElement();
|
||||
if (root == null) {
|
||||
return xmlBytes;
|
||||
}
|
||||
boolean modified = stripExternalHrefs(root);
|
||||
if (!modified) {
|
||||
return xmlBytes;
|
||||
}
|
||||
return serializeDocument(doc);
|
||||
}
|
||||
|
||||
private boolean stripExternalHrefs(Node node) {
|
||||
boolean modified = false;
|
||||
if (node.getNodeType() == Node.ELEMENT_NODE) {
|
||||
NamedNodeMap attrs = node.getAttributes();
|
||||
List<String> hrefAttrsToRemove = new ArrayList<>();
|
||||
for (int i = 0; i < attrs.getLength(); i++) {
|
||||
Node attr = attrs.item(i);
|
||||
String name = attr.getNodeName();
|
||||
if (name == null) {
|
||||
continue;
|
||||
}
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (!(lower.equals("xlink:href")
|
||||
|| lower.endsWith(":href")
|
||||
|| lower.equals("href"))) {
|
||||
continue;
|
||||
}
|
||||
String value = attr.getNodeValue();
|
||||
if (!isExternalUrl(value)) {
|
||||
continue;
|
||||
}
|
||||
if (isAdminAllowed(value)) {
|
||||
continue;
|
||||
}
|
||||
log.warn(
|
||||
"Stripping ODF external href attribute ({}): {}",
|
||||
name,
|
||||
truncateForLog(value));
|
||||
hrefAttrsToRemove.add(name);
|
||||
}
|
||||
Element element = (Element) node;
|
||||
for (String attrName : hrefAttrsToRemove) {
|
||||
element.removeAttribute(attrName);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
NodeList children = node.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
if (stripExternalHrefs(children.item(i))) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
private boolean isExternalUrl(String url) {
|
||||
if (url == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = url.trim().toLowerCase(Locale.ROOT);
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("../")) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.startsWith("http://")
|
||||
|| trimmed.startsWith("https://")
|
||||
|| trimmed.startsWith("ftp://")
|
||||
|| trimmed.startsWith("ftps://")
|
||||
|| trimmed.startsWith("file:")
|
||||
|| trimmed.startsWith("smb:")
|
||||
|| trimmed.startsWith("\\\\")
|
||||
|| trimmed.startsWith("//");
|
||||
}
|
||||
|
||||
// Preserved only with an explicit allowedDomains entry; MEDIUM default would admit public URLs.
|
||||
private boolean isAdminAllowed(String url) {
|
||||
if (ssrfProtectionService == null || url == null || url.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
ApplicationProperties.Html.UrlSecurity config =
|
||||
applicationProperties.getSystem().getHtml().getUrlSecurity();
|
||||
if (config == null
|
||||
|| config.getAllowedDomains() == null
|
||||
|| config.getAllowedDomains().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return ssrfProtectionService.isUrlAllowed(url);
|
||||
}
|
||||
|
||||
private Document parseSecurely(byte[] xmlBytes)
|
||||
throws ParserConfigurationException, SAXException, IOException {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
factory.setNamespaceAware(true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
return builder.parse(new ByteArrayInputStream(xmlBytes));
|
||||
}
|
||||
|
||||
private byte[] serializeDocument(Document doc) throws TransformerException {
|
||||
TransformerFactory tf = TransformerFactory.newInstance();
|
||||
tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
Transformer transformer = tf.newTransformer();
|
||||
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
|
||||
transformer.setOutputProperty(OutputKeys.INDENT, "no");
|
||||
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
transformer.transform(new DOMSource(doc), new StreamResult(baos));
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private String truncateForLog(String value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
return value.length() > 80 ? value.substring(0, 80) + "..." : value;
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
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.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
class OfficeDocumentSanitizerTest {
|
||||
|
||||
private static final String EXTERNAL_URL = "https://webhook.site/ssrf-callback";
|
||||
private static final String INTERNAL_TARGET = "media/image1.png";
|
||||
|
||||
private static final String DOCX_RELS =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ INTERNAL_TARGET
|
||||
+ "\"/>"
|
||||
+ "</Relationships>";
|
||||
|
||||
private static final String DOCX_DOCUMENT =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
|
||||
+ "<w:body><w:p/></w:body></w:document>";
|
||||
|
||||
private static final String ODF_CONTENT_EXTERNAL =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<office:document-content"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
|
||||
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
|
||||
+ "<office:body><office:text>"
|
||||
+ "<draw:frame><draw:image xlink:href=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "</office:text></office:body></office:document-content>";
|
||||
|
||||
private SsrfProtectionService ssrfProtectionService;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private OfficeDocumentSanitizer sanitizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
ssrfProtectionService = mock(SsrfProtectionService.class);
|
||||
sanitizer = new OfficeDocumentSanitizer(ssrfProtectionService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSanitizableExtension_recognizesOoxmlAndOdf() {
|
||||
assertTrue(sanitizer.isSanitizableExtension("docx"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("DOCX"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("xlsx"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("pptx"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("odt"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("ods"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("odp"));
|
||||
assertFalse(sanitizer.isSanitizableExtension("pdf"));
|
||||
assertFalse(sanitizer.isSanitizableExtension("html"));
|
||||
assertFalse(sanitizer.isSanitizableExtension(""));
|
||||
assertFalse(sanitizer.isSanitizableExtension(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_stripsOoxmlExternalRelationship() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
entries.put("word/document.xml", DOCX_DOCUMENT.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL), "External URL should be stripped from .rels");
|
||||
assertFalse(
|
||||
rels.toLowerCase().contains("targetmode=\"external\""),
|
||||
"TargetMode=External relationship should be removed");
|
||||
assertTrue(rels.contains(INTERNAL_TARGET), "Internal image target should be preserved");
|
||||
assertArrayEquals(
|
||||
DOCX_DOCUMENT.getBytes(StandardCharsets.UTF_8),
|
||||
result.get("word/document.xml"),
|
||||
"Non-rels entries must be untouched");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_pptxExternalImageRelStripped() throws IOException {
|
||||
String pptxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("ppt/slides/_rels/slide1.xml.rels", pptxRels.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] pptx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(pptx, "pptx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("ppt/slides/_rels/slide1.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_xlsxExternalImageRelStripped() throws IOException {
|
||||
String xlsxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"xl/drawings/_rels/drawing1.xml.rels", xlsxRels.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] xlsx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(xlsx, "xlsx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(
|
||||
result.get("xl/drawings/_rels/drawing1.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_odtStripsExternalXlinkHrefButKeepsInternal() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
|
||||
String manifestXml =
|
||||
"<?xml version=\"1.0\"?><manifest:manifest"
|
||||
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
|
||||
entries.put("META-INF/manifest.xml", manifestXml.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odt = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(odt, "odt");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String content = new String(result.get("content.xml"), StandardCharsets.UTF_8);
|
||||
assertFalse(content.contains(EXTERNAL_URL), "External xlink:href should be stripped");
|
||||
assertTrue(content.contains("Pictures/image1.png"), "Internal href should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_odsStripsExternalXlinkHref() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] ods = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(ods, "ods");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String content = new String(result.get("content.xml"), StandardCharsets.UTF_8);
|
||||
assertFalse(content.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_odpStripsExternalXlinkHrefInStylesXml() throws IOException {
|
||||
String stylesXml =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<office:document-styles"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
|
||||
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
|
||||
+ "<draw:image xlink:href=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\"/></office:document-styles>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("styles.xml", stylesXml.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odp = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(odp, "odp");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String content = new String(result.get("styles.xml"), StandardCharsets.UTF_8);
|
||||
assertFalse(content.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_disabledByConfigReturnsOriginal() throws IOException {
|
||||
applicationProperties.getSystem().setDisableSanitize(true);
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] result = sanitizer.sanitize(docx, "docx");
|
||||
assertArrayEquals(docx, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_unrecognizedExtensionReturnsOriginal() throws IOException {
|
||||
byte[] original = "irrelevant".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] result = sanitizer.sanitize(original, "pdf");
|
||||
assertArrayEquals(original, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_emptyInputThrows() {
|
||||
assertThrows(IOException.class, () -> sanitizer.sanitize(new byte[0], "docx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_nullInputThrows() {
|
||||
assertThrows(IOException.class, () -> sanitizer.sanitize(null, "docx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_preservesEntryWithExternalRefWhenAdminAllowsDomain() throws IOException {
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getHtml()
|
||||
.getUrlSecurity()
|
||||
.getAllowedDomains()
|
||||
.add("webhook.site");
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(eq(EXTERNAL_URL))).thenReturn(true);
|
||||
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertTrue(rels.contains(EXTERNAL_URL), "Allow-listed external URL should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_doesNotConsultSsrfServiceWhenAllowedDomainsEmpty() throws IOException {
|
||||
// Even if mock would say allowed, we should not invoke it when there is no allow-list,
|
||||
// because MEDIUM default would let public URLs through and re-introduce the vulnerability.
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(eq(EXTERNAL_URL))).thenReturn(true);
|
||||
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_handlesNonXmlEntriesSafely() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
byte[] imageBytes = new byte[] {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a};
|
||||
entries.put("word/media/image1.png", imageBytes);
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
assertArrayEquals(imageBytes, result.get("word/media/image1.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_internalLinksKeptWhenNoExternalPresent() throws IOException {
|
||||
String internalOnlyRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\"media/image1.png\"/>"
|
||||
+ "</Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"word/_rels/document.xml.rels", internalOnlyRels.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertTrue(rels.contains("media/image1.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_corruptZipProducesSafeOutput() throws IOException {
|
||||
byte[] garbage = "this is not a zip file".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] result = sanitizer.sanitize(garbage, "docx");
|
||||
Map<String, byte[]> entries = unzip(result);
|
||||
assertTrue(entries.isEmpty(), "Garbage input must not yield exploitable entries");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_relativeOdfPathsArePreserved() throws IOException {
|
||||
String content =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<office:document-content"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
|
||||
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
|
||||
+ "<draw:image xlink:href=\"../Pictures/image1.png\"/>"
|
||||
+ "<draw:image xlink:href=\"#anchor\"/>"
|
||||
+ "</office:document-content>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("content.xml", content.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odt = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(odt, "odt");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String out = new String(result.get("content.xml"), StandardCharsets.UTF_8);
|
||||
assertTrue(out.contains("../Pictures/image1.png"));
|
||||
assertTrue(out.contains("#anchor"));
|
||||
}
|
||||
|
||||
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
|
||||
ZipEntry entry = new ZipEntry(e.getKey());
|
||||
zos.putNextEntry(entry);
|
||||
zos.write(e.getValue());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static Map<String, byte[]> unzip(byte[] data) throws IOException {
|
||||
Map<String, byte[]> entries = new HashMap<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(data))) {
|
||||
ZipEntry e;
|
||||
while ((e = zis.getNextEntry()) != null) {
|
||||
entries.put(e.getName(), zis.readAllBytes());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import io.swagger.v3.oas.models.media.StringSchema;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import io.swagger.v3.oas.models.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -60,6 +61,15 @@ public class OpenApiConfig {
|
||||
|
||||
OpenAPI openAPI = new OpenAPI().info(info).openapi("3.0.3");
|
||||
|
||||
// Register a single global "AI" tag so every AI endpoint groups under it in the docs.
|
||||
// The AI controllers are currently @Hidden, so they don't emit this tag themselves yet;
|
||||
// defining it here keeps the grouping ready for when those endpoints are unhidden.
|
||||
openAPI.addTagsItem(
|
||||
new Tag()
|
||||
.name("AI")
|
||||
.description(
|
||||
"AI-powered document creation, editing, and assistant endpoints."));
|
||||
|
||||
// Add server configuration from environment variable
|
||||
String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL");
|
||||
Server server;
|
||||
|
||||
+5
-1
@@ -10,6 +10,7 @@ import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.multipdf.Overlay;
|
||||
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -157,7 +158,10 @@ public class PdfOverlayController {
|
||||
PDDocument singlePageDocument = new PDDocument()) {
|
||||
singlePageDocument.addPage(overlayPdf.getPage(pageCountInCurrentOverlay));
|
||||
File tempFile = Files.createTempFile("overlay-page-", ".pdf").toFile();
|
||||
singlePageDocument.save(tempFile);
|
||||
// NO_COMPRESSION: this single-page doc holds a page copied from overlayPdf.
|
||||
// PDFBox 3.0.7's compressed writer (PDFBOX-6203) drops shared resources imported
|
||||
// across documents, corrupting overlay fonts. Revert once on 3.0.8.
|
||||
singlePageDocument.save(tempFile, CompressParameters.NO_COMPRESSION);
|
||||
|
||||
overlayGuide.put(basePageIndex, tempFile.getAbsolutePath());
|
||||
tempFiles.add(tempFile); // Keep track of the temporary file for cleanup
|
||||
|
||||
+23
-32
@@ -6,11 +6,9 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -262,38 +260,31 @@ public class RearrangePagesPDFController {
|
||||
}
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int i = 0; i < newPageOrder.size(); i++) {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
|
||||
// Snapshot the desired pages before mutating the source document's page tree.
|
||||
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
|
||||
for (Integer idx : newPageOrder) {
|
||||
newPages.add(document.getPage(idx));
|
||||
}
|
||||
|
||||
// Create a new document based on the original one
|
||||
try (PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
PDDocumentCatalog sourceCatalog = document.getDocumentCatalog();
|
||||
if (sourceCatalog != null) {
|
||||
PDAcroForm sourceForm = sourceCatalog.getAcroForm(null);
|
||||
if (sourceForm != null) {
|
||||
rearrangedDocument
|
||||
.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.setItem(COSName.ACRO_FORM, sourceForm.getCOSObject());
|
||||
}
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"),
|
||||
tempFileManager);
|
||||
// Rearrange in-place on the source document rather than copying pages into a
|
||||
// freshly-created PDDocument. Copying pages across documents triggers a PDFBox
|
||||
// 3.0.7 compressed-save regression (PDFBOX-6203, fixed for 3.0.8) where shared
|
||||
// resource objects (fonts, etc.) imported from the source can be silently
|
||||
// dropped from the output, producing pages with "font not found" errors.
|
||||
PDPageTree pages = document.getPages();
|
||||
for (int i = totalPages - 1; i >= 0; i--) {
|
||||
pages.remove(i);
|
||||
}
|
||||
for (PDPage page : newPages) {
|
||||
pages.add(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"),
|
||||
tempFileManager);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("document rearrangement", e);
|
||||
|
||||
+7
-3
@@ -35,6 +35,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.OfficeDocumentSanitizer;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -50,6 +51,7 @@ public class ConvertOfficeController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final CustomHtmlSanitizer customHtmlSanitizer;
|
||||
private final OfficeDocumentSanitizer officeDocumentSanitizer;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -83,14 +85,16 @@ public class ConvertOfficeController {
|
||||
Path inputPath = workDir.resolve(baseName + "." + extensionLower);
|
||||
Path outputPath = workDir.resolve(baseName + ".pdf");
|
||||
|
||||
// Check if the file is HTML and apply sanitization if needed
|
||||
// Sanitize input before LibreOffice sees it so embedded URLs can't trigger SSRF.
|
||||
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
|
||||
// Read and sanitize HTML content
|
||||
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
|
||||
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
|
||||
} else if (officeDocumentSanitizer.isSanitizableExtension(extensionLower)) {
|
||||
byte[] sanitized =
|
||||
officeDocumentSanitizer.sanitize(inputFile.getBytes(), extensionLower);
|
||||
Files.write(inputPath, sanitized);
|
||||
} else {
|
||||
// copy file content
|
||||
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@ import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
@@ -357,7 +358,10 @@ public class AutoSplitPdfController {
|
||||
for (int i = 0; i < splitDocuments.size(); i++) {
|
||||
String fileName = filename + "_" + (i + 1) + ".pdf";
|
||||
zipOut.putNextEntry(new ZipEntry(fileName));
|
||||
splitDocuments.get(i).save(zipOut);
|
||||
// NO_COMPRESSION: split docs are built by addPage()-ing pages copied from the
|
||||
// source document. PDFBox 3.0.7's compressed writer (PDFBOX-6203) drops shared
|
||||
// resources imported across documents, corrupting fonts. Revert once on 3.0.8.
|
||||
splitDocuments.get(i).save(zipOut, CompressParameters.NO_COMPRESSION);
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -17,6 +17,7 @@ import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
@@ -427,7 +428,10 @@ public class OCRController {
|
||||
// Save original page without OCR as fallback
|
||||
try (PDDocument pageDoc = new PDDocument()) {
|
||||
pageDoc.addPage(page);
|
||||
pageDoc.save(pageOutputPath);
|
||||
// NO_COMPRESSION: page is copied from another document;
|
||||
// PDFBox 3.0.7 compressed writer (PDFBOX-6203) drops shared
|
||||
// resources, corrupting fonts. Revert once on 3.0.8.
|
||||
pageDoc.save(pageOutputPath, CompressParameters.NO_COMPRESSION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +441,10 @@ public class OCRController {
|
||||
// Save original page without OCR
|
||||
try (PDDocument pageDoc = new PDDocument()) {
|
||||
pageDoc.addPage(page);
|
||||
pageDoc.save(pageOutputPath);
|
||||
// NO_COMPRESSION: page is copied from another document; PDFBox 3.0.7
|
||||
// compressed writer (PDFBOX-6203) drops shared resources, corrupting
|
||||
// fonts on retained text pages. Revert once on 3.0.8.
|
||||
pageDoc.save(pageOutputPath, CompressParameters.NO_COMPRESSION);
|
||||
merger.addSource(pageOutputPath);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -25,6 +25,7 @@ import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.SvgSanitizer;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -36,6 +37,7 @@ public class OverlayImageController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final SvgSanitizer svgSanitizer;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
@@ -61,6 +63,9 @@ public class OverlayImageController {
|
||||
byte[] imageBytes = imageFile.getBytes();
|
||||
|
||||
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
|
||||
if (isSvg) {
|
||||
imageBytes = svgSanitizer.sanitize(imageBytes);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
|
||||
int pages = document.getNumberOfPages();
|
||||
|
||||
+28
@@ -41,6 +41,8 @@ public class ReactRoutingController {
|
||||
private boolean indexHtmlExists = false;
|
||||
private boolean useExternalIndexHtml = false;
|
||||
private boolean loggedMissingIndex = false;
|
||||
private String cachedSaasLandingHtml;
|
||||
private boolean saasLandingExists = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
@@ -49,6 +51,20 @@ public class ReactRoutingController {
|
||||
// Always initialize callback HTML (used for OAuth desktop flow)
|
||||
this.cachedCallbackHtml = buildCallbackHtml();
|
||||
|
||||
// SaaS landing page: only present on the classpath when the :saas module is bundled
|
||||
// (app/saas/src/main/resources/static/saas-landing.html). When present it replaces the
|
||||
// root page so the SaaS API host shows its own landing instead of the OSS API-only page.
|
||||
ClassPathResource saasLanding = new ClassPathResource("static/saas-landing.html");
|
||||
if (saasLanding.exists()) {
|
||||
try (InputStream in = saasLanding.getInputStream()) {
|
||||
this.cachedSaasLandingHtml = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
this.saasLandingExists = true;
|
||||
log.info("SaaS landing page detected; serving it at '/' and '/index.html'");
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to read saas-landing.html; falling back to index.html", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
@@ -132,6 +148,18 @@ public class ReactRoutingController {
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveRootPage(HttpServletRequest request) {
|
||||
// Swap ONLY the root page for SaaS. SPA entry points that delegate to serveIndexHtml
|
||||
// (/auth/callback, /share/{token}, forwarded routes) keep serving the normal shell.
|
||||
if (saasLandingExists && cachedSaasLandingHtml != null) {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache().mustRevalidate())
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(cachedSaasLandingHtml);
|
||||
}
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
|
||||
try {
|
||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -993,6 +994,49 @@ public class GlobalExceptionHandler {
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Unmapped path → clean 404 instead of falling through to the generic 500 catch-all. */
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<ProblemDetail> handleNoResourceFound(
|
||||
NoResourceFoundException ex, HttpServletRequest request) {
|
||||
// /api/* miss = likely missing controller (operator-relevant); other paths = favicons,
|
||||
// robots.txt, scanner noise. Demote the latter so prod logs aren't flooded.
|
||||
String uri = request.getRequestURI();
|
||||
if (uri != null && uri.startsWith("/api/")) {
|
||||
log.warn("No resource at {}: {}", uri, ex.getMessage());
|
||||
} else {
|
||||
log.debug("No resource at {}: {}", uri, ex.getMessage());
|
||||
}
|
||||
|
||||
String title = getLocalizedMessage("error.notFound.title", ErrorTitles.NOT_FOUND_DEFAULT);
|
||||
String detail =
|
||||
getLocalizedMessage(
|
||||
"error.notFound.detail",
|
||||
String.format(
|
||||
"No endpoint found for %s %s",
|
||||
request.getMethod(), request.getRequestURI()),
|
||||
request.getMethod(),
|
||||
request.getRequestURI());
|
||||
|
||||
ProblemDetail problemDetail =
|
||||
createBaseProblemDetail(HttpStatus.NOT_FOUND, detail, request);
|
||||
problemDetail.setType(URI.create(ErrorTypes.NOT_FOUND));
|
||||
problemDetail.setTitle(title);
|
||||
problemDetail.setProperty("title", title);
|
||||
problemDetail.setProperty("method", request.getMethod());
|
||||
addStandardHints(
|
||||
problemDetail,
|
||||
"error.notFound.hints",
|
||||
List.of(
|
||||
"Verify the URL path and HTTP method are correct.",
|
||||
"Check the API base path and version if applicable.",
|
||||
"Ensure there are no typos in the endpoint path."));
|
||||
problemDetail.setProperty("actionRequired", "Use a valid endpoint URL and method.");
|
||||
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IllegalArgumentException.
|
||||
*
|
||||
|
||||
@@ -63,6 +63,7 @@ public class ApiDocService implements stirling.software.common.service.ToolMetad
|
||||
return "http://localhost:" + port + contextPath + "/v1/api-docs";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getExtensionTypes(boolean output, String operationName) {
|
||||
if (outputToFileTypes.isEmpty()) {
|
||||
outputToFileTypes.put("PDF", List.of("pdf"));
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.apache.batik.bridge.GVTBuilder;
|
||||
import org.apache.batik.bridge.UserAgent;
|
||||
import org.apache.batik.bridge.UserAgentAdapter;
|
||||
import org.apache.batik.gvt.GraphicsNode;
|
||||
import org.apache.batik.util.ParsedURL;
|
||||
import org.apache.batik.util.XMLResourceDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
@@ -39,7 +40,16 @@ public class SvgOverlayUtil {
|
||||
svgDoc = factory.createSVGDocument("file:///overlay.svg", inputStream);
|
||||
}
|
||||
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
UserAgent userAgent =
|
||||
new UserAgentAdapter() {
|
||||
@Override
|
||||
public void checkLoadExternalResource(
|
||||
ParsedURL resourceURL, ParsedURL docURL) {
|
||||
throw new SecurityException(
|
||||
"External resource loading is disabled for SVG overlays: "
|
||||
+ resourceURL);
|
||||
}
|
||||
};
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
@@ -93,6 +93,11 @@ posthog.host=https://eu.i.posthog.com
|
||||
|
||||
spring.main.allow-bean-definition-overriding=true
|
||||
|
||||
# spring-data-redis is on the classpath only for the optional Valkey backplane (which wires its own
|
||||
# factory); exclude Spring Boot's stock Redis auto-config so a default install doesn't create a dead
|
||||
# localhost:6379 factory that flips /actuator/health to DOWN.
|
||||
spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration
|
||||
|
||||
# Set up a consistent temporary directory location
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
|
||||
|
||||
+129
-144
@@ -4,10 +4,14 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -56,6 +60,38 @@ class RearrangePagesPDFControllerTest {
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[] {1, 2, 3});
|
||||
}
|
||||
|
||||
/** Build a real, in-memory PDDocument with the requested number of blank pages. */
|
||||
private PDDocument buildRealPdf(int pageCount) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the underlying {@link org.apache.pdfbox.cos.COSDictionary} for each page in document
|
||||
* order. PDPageTree returns a fresh PDPage wrapper per get(), so comparing wrappers with
|
||||
* assertSame is unreliable - the COSDictionary identity is the stable handle.
|
||||
*/
|
||||
private List<Object> snapshotCosPages(PDDocument doc) {
|
||||
List<Object> snapshot = new ArrayList<>();
|
||||
for (PDPage p : doc.getPages()) {
|
||||
snapshot.add(p.getCOSObject());
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private List<Object> reloadAndSnapshot(ResponseEntity<Resource> response) throws IOException {
|
||||
try (var in = response.getBody().getInputStream();
|
||||
var baos = new ByteArrayOutputStream()) {
|
||||
in.transferTo(baos);
|
||||
try (PDDocument out = Loader.loadPDF(baos.toByteArray())) {
|
||||
return snapshotCosPages(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeletePages_Success() throws IOException {
|
||||
MockMultipartFile file = createMockPdf();
|
||||
@@ -83,27 +119,23 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("REVERSE_ORDER");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page0 = mock(PDPage.class);
|
||||
PDPage page1 = mock(PDPage.class);
|
||||
PDPage page2 = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(3)) {
|
||||
List<Object> originals = snapshotCosPages(realDoc);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(3);
|
||||
when(mockDoc.getPage(0)).thenReturn(page0);
|
||||
when(mockDoc.getPage(1)).thenReturn(page1);
|
||||
when(mockDoc.getPage(2)).thenReturn(page2);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
verify(mockNewDoc).addPage(page2);
|
||||
verify(mockNewDoc).addPage(page1);
|
||||
verify(mockNewDoc).addPage(page0);
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
List<Object> finalOrder = reloadAndSnapshot(response);
|
||||
assertEquals(3, finalOrder.size());
|
||||
// We can no longer compare references after a save/reload, so compare via
|
||||
// the in-memory snapshot taken *after* the controller mutated the source.
|
||||
List<Object> mutatedSource = snapshotCosPages(realDoc);
|
||||
assertSame(originals.get(2), mutatedSource.get(0));
|
||||
assertSame(originals.get(1), mutatedSource.get(1));
|
||||
assertSame(originals.get(0), mutatedSource.get(2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,25 +146,18 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("REMOVE_FIRST");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page0 = mock(PDPage.class);
|
||||
PDPage page1 = mock(PDPage.class);
|
||||
PDPage page2 = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(3)) {
|
||||
List<Object> originals = snapshotCosPages(realDoc);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(3);
|
||||
when(mockDoc.getPage(1)).thenReturn(page1);
|
||||
when(mockDoc.getPage(2)).thenReturn(page2);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
verify(mockNewDoc).addPage(page1);
|
||||
verify(mockNewDoc).addPage(page2);
|
||||
verify(mockNewDoc, never()).addPage(page0);
|
||||
assertNotNull(response);
|
||||
List<Object> mutated = snapshotCosPages(realDoc);
|
||||
assertEquals(2, mutated.size());
|
||||
assertSame(originals.get(1), mutated.get(0));
|
||||
assertSame(originals.get(2), mutated.get(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -143,23 +168,18 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("REMOVE_LAST");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page0 = mock(PDPage.class);
|
||||
PDPage page1 = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(3)) {
|
||||
List<Object> originals = snapshotCosPages(realDoc);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(3);
|
||||
when(mockDoc.getPage(0)).thenReturn(page0);
|
||||
when(mockDoc.getPage(1)).thenReturn(page1);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
verify(mockNewDoc).addPage(page0);
|
||||
verify(mockNewDoc).addPage(page1);
|
||||
assertNotNull(response);
|
||||
List<Object> mutated = snapshotCosPages(realDoc);
|
||||
assertEquals(2, mutated.size());
|
||||
assertSame(originals.get(0), mutated.get(0));
|
||||
assertSame(originals.get(1), mutated.get(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -170,21 +190,19 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("REMOVE_FIRST_AND_LAST");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page1 = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(4)) {
|
||||
List<Object> originals = snapshotCosPages(realDoc);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(4);
|
||||
when(mockDoc.getPage(1)).thenReturn(page1);
|
||||
when(mockDoc.getPage(2)).thenReturn(page1);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
List<Object> mutated = snapshotCosPages(realDoc);
|
||||
assertEquals(2, mutated.size());
|
||||
assertSame(originals.get(1), mutated.get(0));
|
||||
assertSame(originals.get(2), mutated.get(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -195,23 +213,15 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("DUPLEX_SORT");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page0 = mock(PDPage.class);
|
||||
PDPage page1 = mock(PDPage.class);
|
||||
PDPage page2 = mock(PDPage.class);
|
||||
PDPage page3 = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(4)) {
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(4);
|
||||
when(mockDoc.getPage(anyInt())).thenReturn(page0);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(4, realDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -222,20 +232,15 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("BOOKLET_SORT");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(4)) {
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(4);
|
||||
when(mockDoc.getPage(anyInt())).thenReturn(page);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(4, realDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -246,20 +251,15 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("ODD_EVEN_SPLIT");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(4)) {
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(4);
|
||||
when(mockDoc.getPage(anyInt())).thenReturn(page);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(4, realDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -270,24 +270,20 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("3,1,2");
|
||||
request.setCustomMode("custom");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page0 = mock(PDPage.class);
|
||||
PDPage page1 = mock(PDPage.class);
|
||||
PDPage page2 = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(3)) {
|
||||
List<Object> originals = snapshotCosPages(realDoc);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(3);
|
||||
when(mockDoc.getPage(0)).thenReturn(page0);
|
||||
when(mockDoc.getPage(1)).thenReturn(page1);
|
||||
when(mockDoc.getPage(2)).thenReturn(page2);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
List<Object> mutated = snapshotCosPages(realDoc);
|
||||
assertEquals(3, mutated.size());
|
||||
assertSame(originals.get(2), mutated.get(0));
|
||||
assertSame(originals.get(0), mutated.get(1));
|
||||
assertSame(originals.get(1), mutated.get(2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -298,21 +294,15 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("3");
|
||||
request.setCustomMode("DUPLICATE");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(2)) {
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(2);
|
||||
when(mockDoc.getPage(anyInt())).thenReturn(page);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
// 2 pages * 3 duplicates = 6 addPage calls
|
||||
verify(mockNewDoc, times(6)).addPage(page);
|
||||
assertNotNull(response);
|
||||
// 2 pages * 3 duplicates = 6 final pages
|
||||
assertEquals(6, realDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -323,19 +313,14 @@ class RearrangePagesPDFControllerTest {
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("SIDE_STITCH_BOOKLET_SORT");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
PDDocument mockNewDoc = mock(PDDocument.class);
|
||||
PDPage page = mock(PDPage.class);
|
||||
try (PDDocument realDoc = buildRealPdf(4)) {
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
|
||||
when(mockDoc.getNumberOfPages()).thenReturn(4);
|
||||
when(mockDoc.getPage(anyInt())).thenReturn(page);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
|
||||
.thenReturn(mockNewDoc);
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(4, realDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -32,6 +32,7 @@ import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.OverlayImageRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.SvgSanitizer;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -52,6 +53,7 @@ class OverlayImageControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private SvgSanitizer svgSanitizer;
|
||||
|
||||
@InjectMocks private OverlayImageController controller;
|
||||
|
||||
@@ -205,6 +207,52 @@ class OverlayImageControllerTest {
|
||||
mockDoc.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlayImage_svgInput_sanitizedBeforeOverlay() throws Exception {
|
||||
byte[] maliciousSvg =
|
||||
("<svg xmlns=\"http://www.w3.org/2000/svg\""
|
||||
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\""
|
||||
+ " width=\"10\" height=\"10\">"
|
||||
+ "<image x=\"0\" y=\"0\" width=\"10\" height=\"10\""
|
||||
+ " xlink:href=\"file:///etc/passwd\"/>"
|
||||
+ "</svg>")
|
||||
.getBytes();
|
||||
byte[] sanitized =
|
||||
("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">"
|
||||
+ "<image x=\"0\" y=\"0\" width=\"10\" height=\"10\"/>"
|
||||
+ "</svg>")
|
||||
.getBytes();
|
||||
when(svgSanitizer.sanitize(maliciousSvg)).thenReturn(sanitized);
|
||||
|
||||
MockMultipartFile svgFile =
|
||||
new MockMultipartFile("imageFile", "overlay.svg", "image/svg+xml", maliciousSvg);
|
||||
OverlayImageRequest request = new OverlayImageRequest();
|
||||
request.setFileInput(pdfFile);
|
||||
request.setImageFile(svgFile);
|
||||
request.setX(0);
|
||||
request.setY(0);
|
||||
request.setEveryPage(false);
|
||||
|
||||
PDDocument mockDoc = new PDDocument();
|
||||
mockDoc.addPage(new PDPage(PDRectangle.A4));
|
||||
when(pdfDocumentFactory.load(any(byte[].class))).thenReturn(mockDoc);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> mockedWebResponse =
|
||||
mockStatic(WebResponseUtils.class)) {
|
||||
mockedWebResponse
|
||||
.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(streamingOk("result".getBytes()));
|
||||
|
||||
controller.overlayImage(request);
|
||||
}
|
||||
mockDoc.close();
|
||||
|
||||
verify(svgSanitizer).sanitize(maliciousSvg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlayImage_withCoordinates_usesXY() throws Exception {
|
||||
OverlayImageRequest request = new OverlayImageRequest();
|
||||
|
||||
+15
@@ -18,6 +18,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -27,6 +28,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -208,6 +210,19 @@ class GlobalExceptionHandlerTest {
|
||||
assertEquals(HttpStatus.NOT_FOUND, resp.getStatusCode());
|
||||
}
|
||||
|
||||
// ---- NoResourceFoundException ----
|
||||
// Regression guard: was falling through to the 500 catch-all.
|
||||
|
||||
@Test
|
||||
void handleNoResourceFound_returns_404_not_500() {
|
||||
when(request.getMethod()).thenReturn("GET");
|
||||
NoResourceFoundException ex =
|
||||
new NoResourceFoundException(HttpMethod.GET, "/api/v1/storage/folders", "");
|
||||
ResponseEntity<ProblemDetail> resp = handler.handleNoResourceFound(ex, request);
|
||||
assertEquals(HttpStatus.NOT_FOUND, resp.getStatusCode());
|
||||
assertEquals("GET", resp.getBody().getProperties().get("method"));
|
||||
}
|
||||
|
||||
// ---- IllegalArgumentException ----
|
||||
|
||||
@Test
|
||||
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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 org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunView;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.ManualTrigger;
|
||||
import stirling.software.proprietary.security.config.PremiumEndpoint;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Manages policies and runs pipelines. The premium backend entry point: CRUD for stored {@code
|
||||
* Policy} objects, running a stored policy by id, and running an ad-hoc pipeline (for AI/Automate
|
||||
* one-offs).
|
||||
*
|
||||
* <p>Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for
|
||||
* status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using
|
||||
* the file ids in the run view.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/policies")
|
||||
@Hidden
|
||||
@PremiumEndpoint
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
|
||||
public class PolicyController {
|
||||
|
||||
private final ManualTrigger manualTrigger;
|
||||
private final PolicyRunRegistry runRegistry;
|
||||
private final PolicyStore policyStore;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
/** SSE emitter timeout, generous enough for long multi-step runs on large files. */
|
||||
@Value("${stirling.policies.streamTimeoutMs:1800000}")
|
||||
private long streamTimeoutMs;
|
||||
|
||||
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Run a tool pipeline",
|
||||
description =
|
||||
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
|
||||
+ " files (each under a multipart field named as its asset key, e.g."
|
||||
+ " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
|
||||
+ " steps in order asynchronously and returns a run id. Poll the run"
|
||||
+ " status endpoint and download outputs via /api/v1/general/files/{id}.")
|
||||
public ResponseEntity<JobResponse<Void>> run(
|
||||
@RequestParam("json") String json, MultipartHttpServletRequest request)
|
||||
throws IOException {
|
||||
PipelineDefinition definition = parseDefinition(json);
|
||||
PolicyInputs inputs = collectInputs(request);
|
||||
String runId = manualTrigger.fire(definition, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Run a tool pipeline with live progress",
|
||||
description =
|
||||
"Same as /run, but returns Server-Sent Events: a 'step' event as each step"
|
||||
+ " starts and completes, then a terminal 'completed', 'failed',"
|
||||
+ " 'cancelled', or 'waiting' event carrying the final run view.")
|
||||
public SseEmitter runStream(
|
||||
@RequestParam("json") String json, MultipartHttpServletRequest request)
|
||||
throws IOException {
|
||||
PipelineDefinition definition = parseDefinition(json);
|
||||
PolicyInputs inputs = collectInputs(request);
|
||||
|
||||
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
|
||||
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
|
||||
|
||||
PolicyRunHandle handle = manualTrigger.fire(definition, inputs, streamListener(emitter));
|
||||
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
|
||||
// engine's worker thread after the run is done, so this never races the step events.
|
||||
handle.completion()
|
||||
.whenComplete(
|
||||
(run, throwable) -> {
|
||||
if (throwable != null) {
|
||||
sendEvent(
|
||||
emitter,
|
||||
"failed",
|
||||
Map.of("message", throwable.getMessage()));
|
||||
} else {
|
||||
sendEvent(emitter, terminalEventName(run), PolicyRunView.of(run));
|
||||
}
|
||||
emitter.complete();
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@GetMapping("/run/{runId}")
|
||||
@Operation(
|
||||
summary = "Get pipeline run status",
|
||||
description = "Returns the current status, step cursor, and output files of a run.")
|
||||
public ResponseEntity<PolicyRunView> status(@PathVariable String runId) {
|
||||
PolicyRun run = runRegistry.get(runId);
|
||||
if (run == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(PolicyRunView.of(run));
|
||||
}
|
||||
|
||||
// --- Policy management ---
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Create or update a policy",
|
||||
description =
|
||||
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
|
||||
+ " assigned; returns the stored policy with its id.")
|
||||
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
|
||||
return ResponseEntity.ok(policyStore.save(parsePolicy(json)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List policies")
|
||||
public List<Policy> listPolicies() {
|
||||
return policyStore.all();
|
||||
}
|
||||
|
||||
@GetMapping("/{policyId}")
|
||||
@Operation(summary = "Get a policy by id")
|
||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||
return policyStore
|
||||
.get(policyId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{policyId}")
|
||||
@Operation(summary = "Delete a policy by id")
|
||||
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
|
||||
return policyStore.delete(policyId)
|
||||
? ResponseEntity.noContent().build()
|
||||
: ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Run a stored policy",
|
||||
description =
|
||||
"Runs the stored policy's pipeline on the supplied files (primary documents"
|
||||
+ " under 'fileInput', supporting files under their asset-key fields)."
|
||||
+ " Runs regardless of the policy's enabled flag, which only gates"
|
||||
+ " automatic triggering. Returns a run id.")
|
||||
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
|
||||
@PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "No policy: " + policyId));
|
||||
PolicyInputs inputs = collectInputs(request);
|
||||
String runId = manualTrigger.run(policy, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
}
|
||||
|
||||
private Policy parsePolicy(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, Policy.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
|
||||
}
|
||||
}
|
||||
|
||||
private PipelineDefinition parseDefinition(String json) {
|
||||
PipelineDefinition definition;
|
||||
try {
|
||||
definition = objectMapper.readValue(json, PipelineDefinition.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
|
||||
}
|
||||
if (definition.steps().isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the multipart file parts into the primary document stream ("fileInput") and the named
|
||||
* supporting-file store: every other file field becomes an asset keyed by its field name, which
|
||||
* a step references from {@code fileParameters}.
|
||||
*/
|
||||
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
|
||||
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
|
||||
List<Resource> primary = toResources(fileMap.get("fileInput"));
|
||||
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<MultipartFile>> entry : fileMap.entrySet()) {
|
||||
if ("fileInput".equals(entry.getKey())) {
|
||||
continue;
|
||||
}
|
||||
List<Resource> assets = toResources(entry.getValue());
|
||||
if (!assets.isEmpty()) {
|
||||
supportingFiles.put(entry.getKey(), assets);
|
||||
}
|
||||
}
|
||||
return new PolicyInputs(primary, supportingFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* A progress listener that forwards each step transition to the SSE stream as a "step" event.
|
||||
*/
|
||||
private PolicyProgressListener streamListener(SseEmitter emitter) {
|
||||
return new PolicyProgressListener() {
|
||||
@Override
|
||||
public void onStepStart(int stepIndex, int stepCount, String operation) {
|
||||
sendEvent(emitter, "step", stepEvent("started", stepIndex, stepCount, operation));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStepComplete(int stepIndex, int stepCount, String operation) {
|
||||
sendEvent(emitter, "step", stepEvent("completed", stepIndex, stepCount, operation));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Map<String, Object> stepEvent(
|
||||
String phase, int stepIndex, int stepCount, String operation) {
|
||||
return Map.of(
|
||||
"phase", phase,
|
||||
"stepIndex", stepIndex,
|
||||
"stepCount", stepCount,
|
||||
"operation", operation);
|
||||
}
|
||||
|
||||
private static String terminalEventName(PolicyRun run) {
|
||||
PolicyRunStatus status = run.getStatus();
|
||||
return switch (status) {
|
||||
case COMPLETED -> "completed";
|
||||
case FAILED -> "failed";
|
||||
case CANCELLED -> "cancelled";
|
||||
case WAITING_FOR_INPUT -> "waiting";
|
||||
default -> "ended";
|
||||
};
|
||||
}
|
||||
|
||||
private void sendEvent(SseEmitter emitter, String name, Object data) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
// Client disconnected or the emitter already closed. The run continues and its results
|
||||
// remain downloadable via the job endpoints; nothing useful left to stream.
|
||||
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Resource> toResources(List<MultipartFile> files) throws IOException {
|
||||
List<Resource> resources = new ArrayList<>();
|
||||
if (files == null) {
|
||||
return resources;
|
||||
}
|
||||
for (MultipartFile file : files) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
|
||||
file.transferTo(tempFile.getPath());
|
||||
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
resources.add(
|
||||
new FileSystemResource(tempFile.getFile()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return originalName;
|
||||
}
|
||||
});
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.ResourceMonitor;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.WaitState;
|
||||
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
|
||||
/**
|
||||
* Runs pipelines asynchronously as tracked jobs.
|
||||
*
|
||||
* <p>Each run is the unit of async work: {@link #submit} returns a run id immediately and the
|
||||
* pipeline executes on a virtual thread, so a step blocking on a slow tool does not tie up a
|
||||
* platform thread. The run drives {@link PolicyExecutor} for the actual step loop, registers its
|
||||
* outputs and progress with {@link TaskManager} (so the existing job status/download endpoints work
|
||||
* unchanged), and keeps rich state in {@link PolicyRunRegistry}.
|
||||
*
|
||||
* <p>The engine deliberately manages its own virtual-thread execution rather than routing through
|
||||
* {@code JobExecutorService}: that path force-completes a job once its work returns, which is
|
||||
* incompatible with a run that suspends in {@code WAITING_FOR_INPUT}. It still applies the shared
|
||||
* {@link ResourceMonitor}/{@link JobQueue} admission control, so heavy runs queue under load
|
||||
* instead of oversubscribing.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PolicyEngine {
|
||||
|
||||
/**
|
||||
* Resource weight of a pipeline run for admission control. A run chains many tools and holds
|
||||
* intermediate files, so it is weighted as heavy work: the shared {@link ResourceMonitor}
|
||||
* should let it start while the system is healthy but hold it back under memory/CPU pressure.
|
||||
* See {@link ResourceMonitor#shouldQueueJob(int)} for how a weight maps to that decision.
|
||||
*/
|
||||
private static final int RUN_RESOURCE_WEIGHT = 50;
|
||||
|
||||
private final PolicyExecutor stepExecutor;
|
||||
private final TaskManager taskManager;
|
||||
private final PolicyRunRegistry registry;
|
||||
private final FileStorage fileStorage;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
private final List<PolicyOutputSink> outputSinks;
|
||||
private final ResourceMonitor resourceMonitor;
|
||||
private final JobQueue jobQueue;
|
||||
|
||||
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
|
||||
|
||||
/**
|
||||
* Submit a pipeline to run asynchronously. The returned handle's run id scopes a job in {@link
|
||||
* TaskManager}, so progress (notes), status, and result files are observable via the existing
|
||||
* job endpoints as well as via {@link #getRun(String)}; its completion future resolves when the
|
||||
* run reaches a terminal or paused state.
|
||||
*/
|
||||
public PolicyRunHandle submit(
|
||||
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
// Scope the run id to the current user (on this request thread) so the file-download
|
||||
// ownership check passes; NoOpJobOwnershipService returns the id unchanged when security
|
||||
// is off.
|
||||
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
|
||||
taskManager.createTask(runId);
|
||||
PolicyRun run = new PolicyRun(runId, definition);
|
||||
registry.register(run);
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
PolicyProgressListener tracking = trackingListener(runId, run, listener);
|
||||
Runnable task = () -> runToCompletion(run, inputs, tracking, completion);
|
||||
|
||||
// Each run is one admission unit; steps run synchronously within it, so this gates heavy
|
||||
// work under load without the pool-within-pool risk of queueing each tool call. Under
|
||||
// resource pressure the run waits in the shared JobQueue; otherwise it starts immediately.
|
||||
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
|
||||
log.debug("Queueing policy run {} under resource pressure", runId);
|
||||
jobQueue.queueJob(
|
||||
runId,
|
||||
RUN_RESOURCE_WEIGHT,
|
||||
() -> {
|
||||
task.run();
|
||||
return null;
|
||||
},
|
||||
0L)
|
||||
.exceptionally(ex -> failRejectedRun(run, completion, ex));
|
||||
} else {
|
||||
asyncExecutor.execute(task);
|
||||
}
|
||||
return new PolicyRunHandle(runId, completion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a stored policy on demand. Builds the policy's pipeline and submits it. {@code enabled}
|
||||
* gates automatic triggering, not explicit runs, so this runs regardless of that flag.
|
||||
*/
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return submit(policy.toDefinition(), inputs, listener);
|
||||
}
|
||||
|
||||
public PolicyRun getRun(String runId) {
|
||||
return registry.get(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request cancellation of a run. Stage 1 marks the run cancelled in the registry if it has not
|
||||
* already finished; interrupting an in-flight tool call lands in a later stage.
|
||||
*/
|
||||
public boolean cancel(String runId) {
|
||||
PolicyRun run = registry.get(runId);
|
||||
if (run == null) {
|
||||
return false;
|
||||
}
|
||||
boolean cancelled = run.cancel();
|
||||
if (cancelled) {
|
||||
taskManager.addNote(runId, "Run cancelled by request");
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented; the run shape and
|
||||
* {@link WaitState} snapshot are in place so this can be added without reworking the engine.
|
||||
*/
|
||||
public String resume(String runId, List<Resource> additionalInputs) {
|
||||
throw new UnsupportedOperationException("Pause/resume is not yet implemented");
|
||||
}
|
||||
|
||||
private void runToCompletion(
|
||||
PolicyRun run,
|
||||
PolicyInputs inputs,
|
||||
PolicyProgressListener listener,
|
||||
CompletableFuture<PolicyRun> completion) {
|
||||
String runId = run.getRunId();
|
||||
try {
|
||||
run.markRunning();
|
||||
PolicyExecutionResult result =
|
||||
stepExecutor.execute(run.getDefinition(), inputs, listener);
|
||||
OutputSpec output = run.getDefinition().output();
|
||||
List<ResultFile> outputs = sinkFor(output).deliver(runId, result.files(), output);
|
||||
taskManager.setMultipleFileResults(runId, outputs);
|
||||
taskManager.setComplete(runId);
|
||||
run.complete(outputs);
|
||||
} catch (PolicyInputRequiredException e) {
|
||||
// Designed-for path: suspend the run rather than fail it. Persist intermediates as
|
||||
// fileIds so the run can resume after this worker thread is gone.
|
||||
WaitState wait = suspend(e);
|
||||
run.waitForInput(wait);
|
||||
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
String message = toolTimeoutMessage(e);
|
||||
log.error(
|
||||
"Policy run {} timed out on {}: {}",
|
||||
runId,
|
||||
e.getEndpointPath(),
|
||||
e.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} catch (Exception e) {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} finally {
|
||||
// Always resolve the handle with the run's final state so stream/await callers unblock.
|
||||
completion.complete(run);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<?> failRejectedRun(
|
||||
PolicyRun run, CompletableFuture<PolicyRun> completion, Throwable ex) {
|
||||
// Only reached if the run never started (e.g. the queue was full). A run that started
|
||||
// always resolves its own completion in runToCompletion.
|
||||
if (!completion.isDone()) {
|
||||
String message = "Policy run could not be queued: " + ex.getMessage();
|
||||
log.error("Policy run {} was not admitted: {}", run.getRunId(), ex.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(run.getRunId(), message);
|
||||
completion.complete(run);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private WaitState suspend(PolicyInputRequiredException e) {
|
||||
List<String> fileIds = new ArrayList<>();
|
||||
for (Resource resource : e.getPendingFiles()) {
|
||||
String name = resource.getFilename() != null ? resource.getFilename() : "pending";
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
fileIds.add(fileStorage.storeInputStream(is, name).fileId());
|
||||
} catch (IOException io) {
|
||||
log.warn("Failed to persist pending file for paused run: {}", io.getMessage());
|
||||
}
|
||||
}
|
||||
return new WaitState(e.getMessage(), e.getResumeStepIndex(), fileIds);
|
||||
}
|
||||
|
||||
private PolicyProgressListener trackingListener(
|
||||
String runId, PolicyRun run, PolicyProgressListener delegate) {
|
||||
return new PolicyProgressListener() {
|
||||
@Override
|
||||
public void onStepStart(int stepIndex, int stepCount, String operation) {
|
||||
run.enterStep(stepIndex);
|
||||
taskManager.addNote(
|
||||
runId,
|
||||
"Step " + stepIndex + "/" + stepCount + ": " + operation + " started");
|
||||
delegate.onStepStart(stepIndex, stepCount, operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStepComplete(int stepIndex, int stepCount, String operation) {
|
||||
taskManager.addNote(
|
||||
runId,
|
||||
"Step " + stepIndex + "/" + stepCount + ": " + operation + " completed");
|
||||
delegate.onStepComplete(stepIndex, stepCount, operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHeartbeat() {
|
||||
delegate.onHeartbeat();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private PolicyOutputSink sinkFor(OutputSpec spec) {
|
||||
return outputSinks.stream()
|
||||
.filter(sink -> sink.supports(spec))
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No output sink supports spec: "
|
||||
+ (spec == null ? "<null>" : spec.type())));
|
||||
}
|
||||
|
||||
private static String toolTimeoutMessage(InternalApiTimeoutException e) {
|
||||
return String.format(
|
||||
"The %s tool did not respond within %d seconds and was aborted.",
|
||||
e.getEndpointPath(), e.getReadTimeout().toSeconds());
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Result of running a pipeline through {@link PolicyExecutor}.
|
||||
*
|
||||
* <p>{@code files} are the final output resources (temp files, not yet stored to {@code
|
||||
* FileStorage}). {@code report} is the structured metadata payload captured from the last step that
|
||||
* produced one (a JSON body, or an {@code X-Stirling-Tool-Report} header), with {@code reportTool}
|
||||
* naming the step it came from; both are null when no step produced a report.
|
||||
*/
|
||||
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Runs an ordered chain of tool steps, chaining each step's output files into the next step's
|
||||
* input.
|
||||
*
|
||||
* <p>This is the single execution loop for the proprietary surface (AI plans now;
|
||||
* manually-triggered runs and watched folders later). Each step is dispatched synchronously via
|
||||
* {@link InternalApiClient} loopback HTTP: the tool runs in its own handler and returns its file
|
||||
* inline. The caller decides how to run the executor itself (the AI turn loop calls it directly;
|
||||
* the engine runs it on a virtual thread for async runs). Files cross step boundaries as {@link
|
||||
* Resource} temp files; they are only persisted to durable storage at the run boundaries by the
|
||||
* caller.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PolicyExecutor {
|
||||
|
||||
private static final String FILTER_OPERATION_PREFIX = "/api/v1/filter/filter-";
|
||||
|
||||
private final InternalApiClient internalApiClient;
|
||||
private final ToolMetadataService toolMetadataService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
|
||||
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
|
||||
* payload the tool chose to surface alongside (or instead of) a file.
|
||||
*/
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
/**
|
||||
* Execute every step in {@code definition} in order, feeding each step's output into the next.
|
||||
* Supporting files supplied in {@code inputs} are bound to steps' named file fields and never
|
||||
* enter the document stream.
|
||||
*
|
||||
* @param definition the pipeline to run (must have at least one step)
|
||||
* @param inputs the primary documents plus the named supporting-file store
|
||||
* @param listener receives per-step progress
|
||||
* @return the final output files plus the last structured report produced, if any
|
||||
* @throws InternalApiTimeoutException if a tool does not respond within its read timeout
|
||||
* @throws IOException if a tool returns a non-OK response, references a missing supporting
|
||||
* file, or a file cannot be read
|
||||
*/
|
||||
public PolicyExecutionResult execute(
|
||||
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener)
|
||||
throws IOException {
|
||||
List<PipelineStep> steps = definition.steps();
|
||||
if (steps.isEmpty()) {
|
||||
throw new IllegalArgumentException("Pipeline definition has no steps");
|
||||
}
|
||||
|
||||
List<Resource> currentFiles = inputs.primary();
|
||||
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
||||
// Propagate the *last* non-null report; the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
PipelineStep step = steps.get(i);
|
||||
String operation = step.operation();
|
||||
if (operation == null || operation.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Pipeline step " + (i + 1) + " has no operation");
|
||||
}
|
||||
listener.onStepStart(i + 1, steps.size(), operation);
|
||||
ToolResult stepResult = executeStep(step, currentFiles, supportingFiles);
|
||||
currentFiles = stepResult.files();
|
||||
if (stepResult.report() != null) {
|
||||
lastReport = stepResult.report();
|
||||
lastReportTool = operation;
|
||||
}
|
||||
listener.onStepComplete(i + 1, steps.size(), operation);
|
||||
}
|
||||
|
||||
return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
|
||||
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
|
||||
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
|
||||
*
|
||||
* <p>A structured {@code report} may be returned alongside (or instead of) files; see {@link
|
||||
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
|
||||
* non-null report wins.
|
||||
*/
|
||||
private ToolResult executeStep(
|
||||
PipelineStep step,
|
||||
List<Resource> inputFiles,
|
||||
Map<String, List<Resource>> supportingFiles)
|
||||
throws IOException {
|
||||
requireAcceptedTypes(step.operation(), inputFiles);
|
||||
List<Resource> files = new ArrayList<>();
|
||||
JsonNode report = null;
|
||||
if (toolMetadataService.isMultiInput(step.operation())) {
|
||||
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else {
|
||||
for (Resource file : inputFiles) {
|
||||
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
if (report == null) {
|
||||
report = r.report();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ToolResult(files, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an endpoint and return its result files and optional report.
|
||||
*
|
||||
* <ul>
|
||||
* <li>JSON body (Content-Type: application/json): the entire body is the report, no files are
|
||||
* returned.
|
||||
* <li>File body (PDF etc.): the file is returned; if an {@link
|
||||
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
|
||||
* parsed as the report.
|
||||
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
|
||||
* a flat list of result files.
|
||||
* </ul>
|
||||
*/
|
||||
private ToolResult callEndpoint(
|
||||
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
|
||||
throws IOException {
|
||||
String endpointPath = step.operation();
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
for (Resource file : files) {
|
||||
body.add("fileInput", file);
|
||||
}
|
||||
// Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These
|
||||
// come from the run's named asset store, not the document stream.
|
||||
for (Map.Entry<String, String> binding : step.fileParameters().entrySet()) {
|
||||
String fieldName = binding.getKey();
|
||||
String assetKey = binding.getValue();
|
||||
List<Resource> assets = supportingFiles.get(assetKey);
|
||||
if (assets == null || assets.isEmpty()) {
|
||||
throw new IOException(
|
||||
"Step "
|
||||
+ endpointPath
|
||||
+ " references supporting file '"
|
||||
+ assetKey
|
||||
+ "' for field '"
|
||||
+ fieldName
|
||||
+ "' but no such file was provided");
|
||||
}
|
||||
for (Resource asset : assets) {
|
||||
body.add(fieldName, asset);
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
|
||||
if (entry.getValue() instanceof List<?> list) {
|
||||
if (containsStructuredElements(list)) {
|
||||
// Endpoints binding lists of structured objects (e.g. /security/redact's
|
||||
// redactions, /general/edit-text's edits) parse a single JSON string field via
|
||||
// a property editor. Pre-serialize the whole list so binding succeeds.
|
||||
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
|
||||
} else {
|
||||
for (Object item : list) {
|
||||
body.add(entry.getKey(), item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body.add(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
ResponseEntity<Resource> response = internalApiClient.post(endpointPath, body);
|
||||
if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) {
|
||||
throw new IOException(
|
||||
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
|
||||
}
|
||||
Resource resource = response.getBody();
|
||||
|
||||
// Filter operations return an empty body to signal the file was filtered out: drop it
|
||||
// rather than forwarding a zero-byte document.
|
||||
if (isFilterOperation(endpointPath) && isEmpty(resource)) {
|
||||
return new ToolResult(List.of(), null);
|
||||
}
|
||||
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
MediaType contentType = headers.getContentType();
|
||||
|
||||
// JSON-only response: the whole body is the structured report, no result file.
|
||||
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
JsonNode report = objectMapper.readTree(is);
|
||||
return new ToolResult(List.of(), report);
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode report = parseReportHeader(headers, endpointPath);
|
||||
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
|
||||
return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report);
|
||||
}
|
||||
return new ToolResult(List.of(resource), report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
|
||||
* or return null.
|
||||
*/
|
||||
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
|
||||
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(raw);
|
||||
} catch (JacksonException e) {
|
||||
log.warn(
|
||||
"Ignoring malformed {} header from {}: {}",
|
||||
AiToolResponseHeaders.TOOL_REPORT,
|
||||
endpointPath,
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsStructuredElements(List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> || item instanceof List<?>) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail the run if any document in the primary stream is not a file type the step accepts. An
|
||||
* endpoint that declares no specific input type accepts anything.
|
||||
*/
|
||||
private void requireAcceptedTypes(String operation, List<Resource> files) throws IOException {
|
||||
List<String> accepted = toolMetadataService.getExtensionTypes(false, operation);
|
||||
if (accepted == null || accepted.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Resource file : files) {
|
||||
if (!matchesType(file, accepted)) {
|
||||
throw new IOException(
|
||||
"Step "
|
||||
+ operation
|
||||
+ " accepts "
|
||||
+ accepted
|
||||
+ " but received '"
|
||||
+ file.getFilename()
|
||||
+ "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean matchesType(Resource file, List<String> acceptedExtensions) {
|
||||
String filename = file.getFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot < 0 || dot == filename.length() - 1) {
|
||||
return false;
|
||||
}
|
||||
return acceptedExtensions.contains(filename.substring(dot + 1).toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private static boolean isFilterOperation(String operation) {
|
||||
return operation.startsWith(FILTER_OPERATION_PREFIX);
|
||||
}
|
||||
|
||||
private static boolean isEmpty(Resource resource) {
|
||||
try {
|
||||
return resource.contentLength() == 0;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Thrown by a step to signal that the run cannot proceed without further user input, pausing the
|
||||
* run in {@code WAITING_FOR_INPUT} rather than failing it.
|
||||
*
|
||||
* <p>Carries everything needed to resume: a human-readable reason, the 0-based index of the step to
|
||||
* resume from, and the intermediate files produced so far. The engine persists those files and
|
||||
* suspends the run.
|
||||
*
|
||||
* <p>Defined now to fix the run shape; no step throws it yet, and the resume handshake is
|
||||
* implemented in a later stage.
|
||||
*/
|
||||
@Getter
|
||||
public class PolicyInputRequiredException extends RuntimeException {
|
||||
|
||||
private final transient List<Resource> pendingFiles;
|
||||
private final int resumeStepIndex;
|
||||
|
||||
public PolicyInputRequiredException(
|
||||
String reason, int resumeStepIndex, List<Resource> pendingFiles) {
|
||||
super(reason);
|
||||
this.resumeStepIndex = resumeStepIndex;
|
||||
this.pendingFiles = pendingFiles == null ? List.of() : pendingFiles;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
|
||||
/**
|
||||
* Returned by {@link PolicyEngine#submit}: the run id (for status polling and result download) plus
|
||||
* a future that resolves when the run reaches a terminal or paused state.
|
||||
*
|
||||
* <p>The completion future lets callers react to the end of a run (e.g. an SSE endpoint sending a
|
||||
* final event and closing the stream) without polling. It carries the {@link PolicyRun} whose
|
||||
* status describes the outcome (completed, failed, cancelled, or waiting for input); it does not
|
||||
* complete exceptionally for ordinary run failures.
|
||||
*/
|
||||
public record PolicyRunHandle(String runId, CompletableFuture<PolicyRun> completion) {}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
|
||||
/**
|
||||
* In-memory store of live {@link PolicyRun} state, keyed by runId. Holds the authoritative run
|
||||
* state machine; durable status/files for download are projected separately into {@code
|
||||
* TaskManager}.
|
||||
*
|
||||
* <p>Finished runs are evicted on a fixed interval once they age past {@code
|
||||
* stirling.policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a
|
||||
* run's rich in-memory state does not outlive the process. Only terminal runs are evicted; active
|
||||
* and paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
|
||||
* touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle
|
||||
* cleanup, so eviction only frees this map's entry.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PolicyRunRegistry {
|
||||
|
||||
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
|
||||
|
||||
private final Duration runExpiry;
|
||||
private final ScheduledExecutorService cleanupExecutor =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("policy-run-cleanup-", 0).factory());
|
||||
|
||||
public PolicyRunRegistry(
|
||||
@Value("${stirling.policies.runExpiryMinutes:30}") int runExpiryMinutes) {
|
||||
this.runExpiry = Duration.ofMinutes(runExpiryMinutes);
|
||||
cleanupExecutor.scheduleAtFixedRate(this::evictExpiredRuns, 10, 10, TimeUnit.MINUTES);
|
||||
log.debug(
|
||||
"Policy run registry initialized with run expiry of {} minutes", runExpiryMinutes);
|
||||
}
|
||||
|
||||
public void register(PolicyRun run) {
|
||||
runs.put(run.getRunId(), run);
|
||||
}
|
||||
|
||||
public PolicyRun get(String runId) {
|
||||
return runs.get(runId);
|
||||
}
|
||||
|
||||
public Collection<PolicyRun> all() {
|
||||
return runs.values();
|
||||
}
|
||||
|
||||
/** Scheduled hook: evict terminal runs that finished before the expiry window. */
|
||||
private void evictExpiredRuns() {
|
||||
try {
|
||||
evictExpired(Instant.now().minus(runExpiry));
|
||||
} catch (Exception e) {
|
||||
log.error("Error during policy run cleanup: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every terminal run last updated before {@code cutoff}; active and paused runs are kept
|
||||
* regardless of age. Returns the number evicted. Package-visible so the scheduled sweep and
|
||||
* tests exercise the same path with an explicit cutoff.
|
||||
*/
|
||||
int evictExpired(Instant cutoff) {
|
||||
int removed = 0;
|
||||
for (Map.Entry<String, PolicyRun> entry : runs.entrySet()) {
|
||||
PolicyRun run = entry.getValue();
|
||||
if (run.getStatus().isTerminal() && run.getUpdatedAt().isBefore(cutoff)) {
|
||||
runs.remove(entry.getKey());
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
log.info("Evicted {} expired policy runs", removed);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
cleanupExecutor.shutdownNow();
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Describes where a pipeline run's output files should be delivered. {@code type} selects a {@code
|
||||
* PolicyOutputSink} (e.g. "inline"); {@code options} carries sink-specific configuration.
|
||||
*
|
||||
* <p>New destinations (folder, S3) are added as new sink beans keyed on a new {@code type} without
|
||||
* changing this shape or the engine.
|
||||
*/
|
||||
public record OutputSpec(String type, Map<String, Object> options) {
|
||||
public OutputSpec {
|
||||
options = options == null ? Map.of() : options;
|
||||
}
|
||||
|
||||
/** The default destination: store outputs and return them to the caller for download. */
|
||||
public static OutputSpec inline() {
|
||||
return new OutputSpec("inline", Map.of());
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An ordered chain of tool steps plus where the output should go.
|
||||
*
|
||||
* <p>This is the single shape executed by the policy engine, shared by AI plans, manually-triggered
|
||||
* runs, and (later) watched folders. {@code output} may be null for callers that handle result
|
||||
* files themselves (e.g. the AI workflow, which builds its own response payload).
|
||||
*/
|
||||
public record PipelineDefinition(String name, List<PipelineStep> steps, OutputSpec output) {
|
||||
public PipelineDefinition {
|
||||
steps = steps == null ? List.of() : steps;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A single tool invocation in a pipeline: the API endpoint path to call and the inputs to pass.
|
||||
*
|
||||
* <p>{@code operation} is a Stirling tool endpoint path (e.g. {@code /api/v1/misc/compress-pdf}),
|
||||
* matching the dispatch convention used by {@code InternalApiClient}. {@code parameters} are the
|
||||
* tool-specific scalar form fields.
|
||||
*
|
||||
* <p>{@code fileParameters} binds a tool's named file fields (beyond the primary {@code fileInput}
|
||||
* stream) to supporting files supplied with the run: it maps the form field name (e.g. {@code
|
||||
* stampImage}, {@code overlayFiles}) to an asset key in the run's supporting-file store. This keeps
|
||||
* supporting inputs (a stamp image, a certificate, an overlay) out of the document stream that
|
||||
* flows step to step.
|
||||
*/
|
||||
public record PipelineStep(
|
||||
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
|
||||
|
||||
public PipelineStep {
|
||||
parameters = parameters == null ? Map.of() : parameters;
|
||||
fileParameters = fileParameters == null ? Map.of() : fileParameters;
|
||||
}
|
||||
|
||||
/** A step with no supporting-file bindings. */
|
||||
public PipelineStep(String operation, Map<String, Object> parameters) {
|
||||
this(operation, parameters, Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A stored, owned automation: how it is triggered, the ordered tool steps to run, and where its
|
||||
* output goes, plus identity and metadata.
|
||||
*
|
||||
* <p>This is the central object of the feature. Everything that runs a chain of tools is a use of a
|
||||
* Policy: a watched folder is a Policy with a folder {@link TriggerConfig} and a folder {@link
|
||||
* OutputSpec}; a scheduled job is a Policy with a schedule trigger; manual/Automate/AI runs execute
|
||||
* a Policy (or an ad-hoc {@link PipelineDefinition}) on demand. The engine itself only ever
|
||||
* executes the {@link PipelineDefinition} this exposes via {@link #toDefinition()} - it is
|
||||
* trigger-agnostic.
|
||||
*
|
||||
* <p>{@code enabled} gates automatic triggering (a disabled policy is not picked up by its
|
||||
* trigger); it does not block an explicit manual run.
|
||||
*/
|
||||
public record Policy(
|
||||
String id,
|
||||
String name,
|
||||
String owner,
|
||||
boolean enabled,
|
||||
TriggerConfig trigger,
|
||||
List<PipelineStep> steps,
|
||||
OutputSpec output) {
|
||||
|
||||
public Policy {
|
||||
trigger = trigger == null ? TriggerConfig.manual() : trigger;
|
||||
steps = steps == null ? List.of() : steps;
|
||||
output = output == null ? OutputSpec.inline() : output;
|
||||
}
|
||||
|
||||
/** The engine-level, trigger-agnostic view of this policy's pipeline. */
|
||||
public PipelineDefinition toDefinition() {
|
||||
return new PipelineDefinition(name, steps, output);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
/**
|
||||
* The files a run operates on, split into two roles:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code primary} - the documents that flow through the pipeline, each step's output becoming
|
||||
* the next step's input.
|
||||
* <li>{@code supportingFiles} - a named store of auxiliary files (a stamp image, certificate,
|
||||
* overlay, attachments) that steps bind to their named file fields via {@link
|
||||
* PipelineStep#fileParameters()}. These never enter the document stream.
|
||||
* </ul>
|
||||
*
|
||||
* Asset values are lists so a single key can carry multi-file fields (e.g. attachments).
|
||||
*/
|
||||
public record PolicyInputs(List<Resource> primary, Map<String, List<Resource>> supportingFiles) {
|
||||
|
||||
public PolicyInputs {
|
||||
primary = primary == null ? List.of() : primary;
|
||||
supportingFiles = supportingFiles == null ? Map.of() : supportingFiles;
|
||||
}
|
||||
|
||||
/** Inputs with primary documents only and no supporting files. */
|
||||
public static PolicyInputs of(List<Resource> primary) {
|
||||
return new PolicyInputs(primary, Map.of());
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/**
|
||||
* Live, mutable state of a single pipeline run, held in memory by {@code PolicyRunRegistry}.
|
||||
*
|
||||
* <p>This carries the rich execution state (status, step cursor, wait state) that the job system's
|
||||
* {@code JobResult} does not model. The run is also projected into {@code TaskManager} for
|
||||
* cluster-visible status, progress notes, and file download; this object is the authoritative
|
||||
* source of the state machine.
|
||||
*/
|
||||
@Getter
|
||||
public class PolicyRun {
|
||||
|
||||
private final String runId;
|
||||
private final PipelineDefinition definition;
|
||||
private final Instant createdAt = Instant.now();
|
||||
|
||||
private volatile PolicyRunStatus status = PolicyRunStatus.PENDING;
|
||||
|
||||
/** 1-based index of the step currently running (0 before the run starts). */
|
||||
private volatile int currentStep = 0;
|
||||
|
||||
private volatile WaitState waitState;
|
||||
private volatile String error;
|
||||
private volatile List<ResultFile> outputs = List.of();
|
||||
private volatile Instant updatedAt = Instant.now();
|
||||
|
||||
public PolicyRun(String runId, PipelineDefinition definition) {
|
||||
this.runId = runId;
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
public int stepCount() {
|
||||
return definition.steps().size();
|
||||
}
|
||||
|
||||
public synchronized void markRunning() {
|
||||
this.status = PolicyRunStatus.RUNNING;
|
||||
touch();
|
||||
}
|
||||
|
||||
public synchronized void enterStep(int oneBasedStepIndex) {
|
||||
this.currentStep = oneBasedStepIndex;
|
||||
touch();
|
||||
}
|
||||
|
||||
public synchronized void complete(List<ResultFile> resultFiles) {
|
||||
this.outputs = resultFiles == null ? List.of() : List.copyOf(resultFiles);
|
||||
this.status = PolicyRunStatus.COMPLETED;
|
||||
touch();
|
||||
}
|
||||
|
||||
public synchronized void fail(String message) {
|
||||
this.error = message;
|
||||
this.status = PolicyRunStatus.FAILED;
|
||||
touch();
|
||||
}
|
||||
|
||||
public synchronized void waitForInput(WaitState wait) {
|
||||
this.waitState = wait;
|
||||
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
|
||||
touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark cancelled if the run has not already reached a terminal state. Returns whether it did.
|
||||
*/
|
||||
public synchronized boolean cancel() {
|
||||
if (status.isTerminal()) {
|
||||
return false;
|
||||
}
|
||||
this.status = PolicyRunStatus.CANCELLED;
|
||||
touch();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void touch() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
/**
|
||||
* Lifecycle states of a {@link PolicyRun}.
|
||||
*
|
||||
* <p>{@code WAITING_FOR_INPUT} is modelled now so the engine and run shape support pausing a run
|
||||
* (e.g. a step that blocks for a human decision) without holding a thread; the resume handshake is
|
||||
* implemented in a later stage.
|
||||
*/
|
||||
public enum PolicyRunStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
WAITING_FOR_INPUT,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
CANCELLED;
|
||||
|
||||
public boolean isTerminal() {
|
||||
return this == COMPLETED || this == FAILED || this == CANCELLED;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/**
|
||||
* Read-only view of a {@link PolicyRun} returned by the status endpoint. Output files are surfaced
|
||||
* as {@link ResultFile} so the caller can download each via {@code GET /api/v1/general/files/{id}}.
|
||||
*/
|
||||
public record PolicyRunView(
|
||||
String runId,
|
||||
PolicyRunStatus status,
|
||||
int currentStep,
|
||||
int stepCount,
|
||||
String error,
|
||||
List<ResultFile> outputs) {
|
||||
|
||||
public static PolicyRunView of(PolicyRun run) {
|
||||
return new PolicyRunView(
|
||||
run.getRunId(),
|
||||
run.getStatus(),
|
||||
run.getCurrentStep(),
|
||||
run.stepCount(),
|
||||
run.getError(),
|
||||
run.getOutputs());
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* How a {@link Policy} is automatically triggered. {@code type} selects a trigger kind ("manual",
|
||||
* "folder", "schedule", "s3"); {@code options} carries type-specific configuration (a folder path,
|
||||
* a cron expression, a bucket, ...).
|
||||
*
|
||||
* <p>Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values
|
||||
* handled by a new trigger bean, with no change to the model. {@code "manual"} means there is no
|
||||
* automatic trigger - the policy is only ever run on demand.
|
||||
*/
|
||||
public record TriggerConfig(String type, Map<String, Object> options) {
|
||||
|
||||
public TriggerConfig {
|
||||
type = type == null || type.isBlank() ? "manual" : type;
|
||||
options = options == null ? Map.of() : options;
|
||||
}
|
||||
|
||||
/** No automatic trigger; the policy is run on demand only. */
|
||||
public static TriggerConfig manual() {
|
||||
return new TriggerConfig("manual", Map.of());
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Captured when a run pauses in {@link PolicyRunStatus#WAITING_FOR_INPUT}. Together with the run's
|
||||
* {@link PipelineDefinition} this is the resumable snapshot: {@code resumeStepIndex} is the 0-based
|
||||
* step to continue from, and {@code pendingFileIds} are the intermediate files (stored in {@code
|
||||
* FileStorage}, so they survive the worker thread ending or a node restart) that become the input
|
||||
* to the resumed run.
|
||||
*
|
||||
* <p>Stored as fileIds rather than in-memory resources by design: a paused run must be resumable
|
||||
* long after its worker thread has gone.
|
||||
*/
|
||||
public record WaitState(String reason, int resumeStepIndex, List<String> pendingFileIds) {
|
||||
public WaitState {
|
||||
pendingFileIds = pendingFileIds == null ? List.of() : pendingFileIds;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
|
||||
/**
|
||||
* Default output sink: stores each output file in {@code FileStorage} so it is downloadable via
|
||||
* {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs
|
||||
* whose results are returned to the caller.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InlineOutputSink implements PolicyOutputSink {
|
||||
|
||||
private static final String TYPE = "inline";
|
||||
|
||||
private final FileStorage fileStorage;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(OutputSpec spec) {
|
||||
return spec == null || spec.type() == null || TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
|
||||
throws IOException {
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
Resource resource = outputs.get(i);
|
||||
String name =
|
||||
resource.getFilename() != null ? resource.getFilename() : "result-" + (i + 1);
|
||||
String contentType =
|
||||
MediaTypeFactory.getMediaType(name)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.toString();
|
||||
FileStorage.StoredFile stored;
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
stored = fileStorage.storeInputStream(is, name);
|
||||
}
|
||||
results.add(
|
||||
ResultFile.builder()
|
||||
.fileId(stored.fileId())
|
||||
.fileName(name)
|
||||
.contentType(contentType)
|
||||
.fileSize(stored.size())
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
|
||||
/**
|
||||
* Delivers a finished run's output files to a destination, returning durable {@link ResultFile}
|
||||
* descriptors (fileId + metadata) for the run record.
|
||||
*
|
||||
* <p>Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations
|
||||
* (folder, S3) are added as new beans without changing the engine.
|
||||
*/
|
||||
public interface PolicyOutputSink {
|
||||
|
||||
/** Stable identifier for this sink, matching {@code OutputSpec.type()} (e.g. "inline"). */
|
||||
String type();
|
||||
|
||||
/** Whether this sink can handle the given output spec. */
|
||||
boolean supports(OutputSpec spec);
|
||||
|
||||
/**
|
||||
* Persist/deliver the output files and return their descriptors.
|
||||
*
|
||||
* @param runId the run these outputs belong to
|
||||
* @param outputs the final pipeline output resources
|
||||
* @param spec the requested destination
|
||||
*/
|
||||
List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
|
||||
throws IOException;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.policy.progress;
|
||||
|
||||
/**
|
||||
* Receives live progress as a pipeline run executes. Implementations forward to an SSE stream,
|
||||
* write job notes for polling, or both. Step indices are 1-based.
|
||||
*
|
||||
* <p>All methods default to no-ops so callers implement only what they surface.
|
||||
*/
|
||||
public interface PolicyProgressListener {
|
||||
|
||||
/** A listener that ignores all progress. */
|
||||
PolicyProgressListener NOOP = new PolicyProgressListener() {};
|
||||
|
||||
/** Called immediately before step {@code stepIndex} of {@code stepCount} begins. */
|
||||
default void onStepStart(int stepIndex, int stepCount, String operation) {}
|
||||
|
||||
/** Called immediately after step {@code stepIndex} of {@code stepCount} completes. */
|
||||
default void onStepComplete(int stepIndex, int stepCount, String operation) {}
|
||||
|
||||
/** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */
|
||||
default void onHeartbeat() {}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
* In-memory {@link PolicyStore}. Not the runtime bean - {@link JpaPolicyStore} is the durable
|
||||
* store. Kept as a lightweight, dependency-free implementation for tests and for any future no-
|
||||
* database mode.
|
||||
*/
|
||||
public class InProcessPolicyStore implements PolicyStore {
|
||||
|
||||
private final Map<String, Policy> policies = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Policy save(Policy policy) {
|
||||
String id =
|
||||
policy.id() == null || policy.id().isBlank()
|
||||
? UUID.randomUUID().toString()
|
||||
: policy.id();
|
||||
Policy stored =
|
||||
new Policy(
|
||||
id,
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.steps(),
|
||||
policy.output());
|
||||
policies.put(id, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Policy> get(String id) {
|
||||
return Optional.ofNullable(policies.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Policy> all() {
|
||||
return List.copyOf(policies.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Policy> findByTriggerType(String triggerType) {
|
||||
return policies.values().stream()
|
||||
.filter(Policy::enabled)
|
||||
.filter(policy -> triggerType.equals(policy.trigger().type()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String id) {
|
||||
return policies.remove(id) != null;
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link PolicyStore} backed by JPA. The runtime store whenever the proprietary module runs
|
||||
* (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the
|
||||
* scalar columns are kept in sync for querying.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class JpaPolicyStore implements PolicyStore {
|
||||
|
||||
private final PolicyRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Policy save(Policy policy) {
|
||||
String id =
|
||||
policy.id() == null || policy.id().isBlank()
|
||||
? UUID.randomUUID().toString()
|
||||
: policy.id();
|
||||
Policy stored =
|
||||
new Policy(
|
||||
id,
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.steps(),
|
||||
policy.output());
|
||||
|
||||
PolicyEntity entity = new PolicyEntity();
|
||||
entity.setId(id);
|
||||
entity.setName(stored.name());
|
||||
entity.setOwner(stored.owner());
|
||||
entity.setEnabled(stored.enabled());
|
||||
entity.setTriggerType(stored.trigger().type());
|
||||
entity.setPolicyJson(objectMapper.writeValueAsString(stored));
|
||||
repository.save(entity);
|
||||
return stored;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Policy> get(String id) {
|
||||
return repository.findById(id).map(this::toPolicy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Policy> all() {
|
||||
return repository.findAll().stream().map(this::toPolicy).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Policy> findByTriggerType(String triggerType) {
|
||||
return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream()
|
||||
.map(this::toPolicy)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String id) {
|
||||
if (!repository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
repository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Policy toPolicy(PolicyEntity entity) {
|
||||
return objectMapper.readValue(entity.getPolicyJson(), Policy.class);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}.
|
||||
*
|
||||
* <p>The whole policy is stored as JSON in {@code policyJson} (authoritative on read, and the same
|
||||
* serialization the API uses); the scalar columns are denormalized copies for querying - notably
|
||||
* {@code triggerType} + {@code enabled} so background triggers can fetch their policies. Ownership
|
||||
* is a plain {@code owner} string rather than a foreign key, to stay decoupled from the security
|
||||
* entities; richer team scoping can be layered on later.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "policies")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PolicyEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private String id;
|
||||
|
||||
@Column(name = "name")
|
||||
private String name;
|
||||
|
||||
@Column(name = "owner")
|
||||
private String owner;
|
||||
|
||||
@Column(name = "enabled")
|
||||
private boolean enabled;
|
||||
|
||||
@Column(name = "trigger_type")
|
||||
private String triggerType;
|
||||
|
||||
@Column(name = "policy_json", columnDefinition = "text")
|
||||
private String policyJson;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
|
||||
|
||||
/** Enabled policies of a given trigger type, for background triggers to activate. */
|
||||
List<PolicyEntity> findByTriggerTypeAndEnabledTrue(String triggerType);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
* Stores {@link Policy} definitions. The in-memory implementation backs simple deployments now; a
|
||||
* durable (JPA) implementation can replace it behind this interface without touching callers.
|
||||
*/
|
||||
public interface PolicyStore {
|
||||
|
||||
/** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */
|
||||
Policy save(Policy policy);
|
||||
|
||||
Optional<Policy> get(String id);
|
||||
|
||||
List<Policy> all();
|
||||
|
||||
/**
|
||||
* Enabled policies whose automatic trigger is of the given type (used by background triggers).
|
||||
*/
|
||||
List<Policy> findByTriggerType(String triggerType);
|
||||
|
||||
/** Remove a policy; returns whether it existed. */
|
||||
boolean delete(String id);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.policy.trigger;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.policy.engine.PolicyEngine;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
|
||||
/**
|
||||
* Runs policies on demand, in response to a request (the {@code PolicyController} endpoints, an AI,
|
||||
* or another automation). It is the request-driven trigger: no background lifecycle, it just
|
||||
* forwards to the engine. Any policy can be run manually regardless of its configured trigger type.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ManualTrigger implements PolicyTrigger {
|
||||
|
||||
private final PolicyEngine policyEngine;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return "manual";
|
||||
}
|
||||
|
||||
/** Run a stored policy immediately and return its run handle. */
|
||||
public PolicyRunHandle run(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return policyEngine.runPolicy(policy, inputs, listener);
|
||||
}
|
||||
|
||||
/** Run an ad-hoc pipeline (no stored policy), e.g. for AI or Automate one-offs. */
|
||||
public PolicyRunHandle fire(
|
||||
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return policyEngine.submit(definition, inputs, listener);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.policy.trigger;
|
||||
|
||||
/**
|
||||
* Activates policies of one trigger type. A trigger owns a {@link #type()} (matching {@code
|
||||
* TriggerConfig.type()}); when its condition fires it runs the relevant {@code Policy} through the
|
||||
* {@code PolicyEngine}.
|
||||
*
|
||||
* <p>Background triggers (folder watcher, schedule) are driven by configuration: on {@link
|
||||
* #start()} they begin watching/scheduling for the policies returned by {@code
|
||||
* PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. Request-driven triggers
|
||||
* (manual) have no background lifecycle and run a policy directly in response to a call. New
|
||||
* trigger kinds are new beans of this type; the engine and the {@code Policy} model do not change.
|
||||
*/
|
||||
public interface PolicyTrigger {
|
||||
|
||||
/** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
|
||||
String type();
|
||||
|
||||
/** Begin activating policies of this type (e.g. start a folder watcher). No-op for manual. */
|
||||
default void start() {}
|
||||
|
||||
/** Stop activating and release any resources. */
|
||||
default void stop() {}
|
||||
}
|
||||
+4
-2
@@ -31,13 +31,15 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.security.repository",
|
||||
"stirling.software.proprietary.repository",
|
||||
"stirling.software.proprietary.storage.repository",
|
||||
"stirling.software.proprietary.workflow.repository"
|
||||
"stirling.software.proprietary.workflow.repository",
|
||||
"stirling.software.proprietary.policy.store"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
"stirling.software.proprietary.model",
|
||||
"stirling.software.proprietary.storage.model",
|
||||
"stirling.software.proprietary.workflow.model"
|
||||
"stirling.software.proprietary.workflow.model",
|
||||
"stirling.software.proprietary.policy.store"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
+53
-172
@@ -14,14 +14,9 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
@@ -32,14 +27,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
import stirling.software.proprietary.model.api.ai.AiConversationMessage;
|
||||
import stirling.software.proprietary.model.api.ai.AiDocumentIngestRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiEngineProgressDetail;
|
||||
@@ -53,6 +45,13 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||
import stirling.software.proprietary.policy.engine.PolicyExecutionResult;
|
||||
import stirling.software.proprietary.policy.engine.PolicyExecutor;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
|
||||
@@ -72,12 +71,11 @@ public class AiWorkflowService {
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final InternalApiClient internalApiClient;
|
||||
private final FileStorage fileStorage;
|
||||
private final ToolMetadataService toolMetadataService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final FileIdStrategy fileIdStrategy;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
private final PolicyExecutor policyExecutor;
|
||||
private final UserServiceInterface userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -86,24 +84,22 @@ public class AiWorkflowService {
|
||||
AiEngineClient aiEngineClient,
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
ObjectMapper objectMapper,
|
||||
InternalApiClient internalApiClient,
|
||||
FileStorage fileStorage,
|
||||
ToolMetadataService toolMetadataService,
|
||||
TempFileManager tempFileManager,
|
||||
FileIdStrategy fileIdStrategy,
|
||||
AiEngineEndpointResolver endpointResolver,
|
||||
PolicyExecutor policyExecutor,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.objectMapper = objectMapper;
|
||||
this.internalApiClient = internalApiClient;
|
||||
this.fileStorage = fileStorage;
|
||||
this.toolMetadataService = toolMetadataService;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.fileIdStrategy = fileIdStrategy;
|
||||
this.endpointResolver = endpointResolver;
|
||||
this.policyExecutor = policyExecutor;
|
||||
this.userService = userService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
@@ -150,16 +146,6 @@ public class AiWorkflowService {
|
||||
record Terminal(AiWorkflowResponse response) implements WorkflowState {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
|
||||
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
|
||||
* payload the tool chose to surface alongside (or instead of) a file.
|
||||
*
|
||||
* <p>Tools populate the report either by returning a JSON body (whole body → report) or by
|
||||
* adding the {@link AiToolResponseHeaders#TOOL_REPORT} header alongside a file body.
|
||||
*/
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
public AiWorkflowResponse orchestrate(AiWorkflowRequest request) throws IOException {
|
||||
return orchestrate(request, NOOP_LISTENER);
|
||||
}
|
||||
@@ -377,7 +363,6 @@ public class AiWorkflowService {
|
||||
pages.size());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private WorkflowState onToolCall(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesById,
|
||||
@@ -394,8 +379,14 @@ public class AiWorkflowService {
|
||||
|
||||
try {
|
||||
List<Resource> inputFiles = toResources(filesById);
|
||||
listener.onProgress(AiWorkflowProgressEvent.executingTool(endpointPath, 1, 1));
|
||||
ToolResult result = executeStep(endpointPath, parameters, inputFiles);
|
||||
PipelineDefinition definition =
|
||||
new PipelineDefinition(
|
||||
null,
|
||||
List.of(new PipelineStep(endpointPath, parameters)),
|
||||
OutputSpec.inline());
|
||||
PolicyExecutionResult result =
|
||||
policyExecutor.execute(
|
||||
definition, PolicyInputs.of(inputFiles), stepProgress(listener));
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
response.getRationale(),
|
||||
@@ -469,38 +460,32 @@ public class AiWorkflowService {
|
||||
cannotContinue("AI engine returned a plan with no steps."));
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> currentFiles = toResources(filesById);
|
||||
// Propagate the *last* non-null report — the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
Map<String, Object> step = steps.get(i);
|
||||
String endpointPath = (String) step.get("tool");
|
||||
Map<String, Object> parameters =
|
||||
step.containsKey("parameters")
|
||||
? (Map<String, Object>) step.get("parameters")
|
||||
: Map.of();
|
||||
|
||||
if (endpointPath == null || endpointPath.isBlank()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("Plan step " + (i + 1) + " has no tool endpoint."));
|
||||
}
|
||||
|
||||
listener.onProgress(
|
||||
AiWorkflowProgressEvent.executingTool(endpointPath, i + 1, steps.size()));
|
||||
ToolResult stepResult = executeStep(endpointPath, parameters, currentFiles);
|
||||
currentFiles = stepResult.files();
|
||||
if (stepResult.report() != null) {
|
||||
lastReport = stepResult.report();
|
||||
lastReportTool = endpointPath;
|
||||
}
|
||||
List<PipelineStep> pipelineSteps = new ArrayList<>();
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
Map<String, Object> step = steps.get(i);
|
||||
String endpointPath = (String) step.get("tool");
|
||||
if (endpointPath == null || endpointPath.isBlank()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("Plan step " + (i + 1) + " has no tool endpoint."));
|
||||
}
|
||||
Map<String, Object> parameters =
|
||||
step.containsKey("parameters")
|
||||
? (Map<String, Object>) step.get("parameters")
|
||||
: Map.of();
|
||||
pipelineSteps.add(new PipelineStep(endpointPath, parameters));
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> inputFiles = toResources(filesById);
|
||||
PipelineDefinition definition =
|
||||
new PipelineDefinition(summary, pipelineSteps, OutputSpec.inline());
|
||||
PolicyExecutionResult result =
|
||||
policyExecutor.execute(
|
||||
definition, PolicyInputs.of(inputFiles), stepProgress(listener));
|
||||
|
||||
// Multi-turn: if the plan was emitted with resume_with set, the delegate wants
|
||||
// Java to re-invoke the orchestrator with any captured report as an artifact.
|
||||
if (resumeWith != null && !resumeWith.isBlank() && lastReport != null) {
|
||||
if (resumeWith != null && !resumeWith.isBlank() && result.report() != null) {
|
||||
WorkflowTurnRequest resumeRequest = new WorkflowTurnRequest();
|
||||
resumeRequest.setUserMessage(previousRequest.getUserMessage());
|
||||
resumeRequest.setFiles(previousRequest.getFiles());
|
||||
@@ -510,14 +495,14 @@ public class AiWorkflowService {
|
||||
.getArtifacts()
|
||||
.add(
|
||||
new PdfContentExtractor.ToolReportArtifact(
|
||||
lastReportTool, lastReport));
|
||||
result.reportTool(), result.report()));
|
||||
resumeRequest.setResumeWith(resumeWith);
|
||||
return new WorkflowState.Pending(resumeRequest);
|
||||
}
|
||||
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
summary, currentFiles, inputFileNames(filesById), lastReport));
|
||||
summary, result.files(), inputFileNames(filesById), result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -548,123 +533,19 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
|
||||
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
|
||||
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
|
||||
*
|
||||
* <p>A structured {@code report} may be returned alongside (or instead of) files — see {@link
|
||||
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
|
||||
* non-null report wins.
|
||||
* Adapt the AI workflow's {@link ProgressListener} to the engine's {@link
|
||||
* PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event
|
||||
* carrying the tool path and 1-based step position, preserving the event shape the frontend
|
||||
* already renders.
|
||||
*/
|
||||
private ToolResult executeStep(
|
||||
String endpointPath, Map<String, Object> parameters, List<Resource> inputFiles)
|
||||
throws IOException {
|
||||
List<Resource> files = new ArrayList<>();
|
||||
JsonNode report = null;
|
||||
if (toolMetadataService.isMultiInput(endpointPath)) {
|
||||
ToolResult r = callEndpoint(endpointPath, parameters, inputFiles);
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else {
|
||||
for (Resource file : inputFiles) {
|
||||
ToolResult r = callEndpoint(endpointPath, parameters, List.of(file));
|
||||
files.addAll(r.files());
|
||||
if (report == null) {
|
||||
report = r.report();
|
||||
}
|
||||
private static PolicyProgressListener stepProgress(ProgressListener listener) {
|
||||
return new PolicyProgressListener() {
|
||||
@Override
|
||||
public void onStepStart(int stepIndex, int stepCount, String operation) {
|
||||
listener.onProgress(
|
||||
AiWorkflowProgressEvent.executingTool(operation, stepIndex, stepCount));
|
||||
}
|
||||
}
|
||||
return new ToolResult(files, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an endpoint and return its result files and optional report.
|
||||
*
|
||||
* <ul>
|
||||
* <li>JSON body (Content-Type: application/json) → the entire body is the report, no files
|
||||
* are returned.
|
||||
* <li>File body (PDF etc.) → the file is returned; if an {@link
|
||||
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
|
||||
* parsed as the report.
|
||||
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
|
||||
* a flat list of result files.
|
||||
* </ul>
|
||||
*/
|
||||
private ToolResult callEndpoint(
|
||||
String endpointPath, Map<String, Object> parameters, List<Resource> files)
|
||||
throws IOException {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
for (Resource file : files) {
|
||||
body.add("fileInput", file);
|
||||
}
|
||||
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
|
||||
if (entry.getValue() instanceof List<?> list) {
|
||||
if (containsStructuredElements(list)) {
|
||||
// Endpoints binding lists of structured objects (e.g. /security/redact's
|
||||
// redactions, /general/edit-text's edits) parse a single JSON string field via
|
||||
// a property editor. Pre-serialize the whole list so binding succeeds.
|
||||
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
|
||||
} else {
|
||||
for (Object item : list) {
|
||||
body.add(entry.getKey(), item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body.add(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
ResponseEntity<Resource> response = internalApiClient.post(endpointPath, body);
|
||||
if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) {
|
||||
throw new IOException(
|
||||
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
|
||||
}
|
||||
Resource resource = response.getBody();
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
MediaType contentType = headers.getContentType();
|
||||
|
||||
// JSON-only response — the whole body is the structured report, no result file.
|
||||
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
try (java.io.InputStream is = resource.getInputStream()) {
|
||||
JsonNode report = objectMapper.readTree(is);
|
||||
return new ToolResult(List.of(), report);
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode report = parseReportHeader(headers, endpointPath);
|
||||
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
|
||||
return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report);
|
||||
}
|
||||
return new ToolResult(List.of(resource), report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
|
||||
* or return null.
|
||||
*/
|
||||
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
|
||||
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(raw);
|
||||
} catch (JacksonException e) {
|
||||
log.warn(
|
||||
"Ignoring malformed {} header from {}: {}",
|
||||
AiToolResponseHeaders.TOOL_REPORT,
|
||||
endpointPath,
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsStructuredElements(List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> || item instanceof List<?>) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException {
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.FileStorage.StoredFile;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.ResourceMonitor;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.model.TriggerConfig;
|
||||
import stirling.software.proprietary.policy.output.InlineOutputSink;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Tests for {@link PolicyEngine}: async submission runs the pipeline on a virtual thread, registers
|
||||
* outputs and progress with {@link TaskManager}, and surfaces terminal state via {@link
|
||||
* PolicyRunRegistry}. The step executor and inline sink are real (with mocked collaborators) so the
|
||||
* full run path is exercised.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PolicyEngineTest {
|
||||
|
||||
private static final String ROTATE = "/api/v1/general/rotate-pdf";
|
||||
private static final String COMPRESS = "/api/v1/misc/compress-pdf";
|
||||
|
||||
@Mock private InternalApiClient internalApiClient;
|
||||
@Mock private ToolMetadataService toolMetadataService;
|
||||
@Mock private TaskManager taskManager;
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
@Mock private JobQueue jobQueue;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private PolicyRunRegistry registry;
|
||||
private PolicyEngine engine;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("policy-engine-test-");
|
||||
TempFileManager tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
PolicyExecutor executor =
|
||||
new PolicyExecutor(
|
||||
internalApiClient,
|
||||
toolMetadataService,
|
||||
tempFileManager,
|
||||
JsonMapper.builder().build());
|
||||
registry = new PolicyRunRegistry(30);
|
||||
InlineOutputSink sink = new InlineOutputSink(fileStorage);
|
||||
engine =
|
||||
new PolicyEngine(
|
||||
executor,
|
||||
taskManager,
|
||||
registry,
|
||||
fileStorage,
|
||||
jobOwnershipService,
|
||||
List.of(sink),
|
||||
resourceMonitor,
|
||||
jobQueue);
|
||||
|
||||
// Identity scoping: the run id is the generated UUID unchanged. Lenient because the
|
||||
// resume/cancel tests do not submit a run.
|
||||
lenient()
|
||||
.when(jobOwnershipService.createScopedJobKey(anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
// Default to running immediately; the queueing test overrides this.
|
||||
lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitRunsPipelineToCompletionAndRegistersOutputs() throws Exception {
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
|
||||
stubEndpoint(COMPRESS, pdf("compressed", "compressed.pdf"));
|
||||
int[] counter = {0};
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
InputStream is = inv.getArgument(0);
|
||||
long size = is.readAllBytes().length;
|
||||
return new StoredFile("file-" + ++counter[0], size);
|
||||
});
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.submit(
|
||||
definition(
|
||||
new PipelineStep(ROTATE, Map.of()),
|
||||
new PipelineStep(COMPRESS, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
// The completion future resolves with the final run state, no polling needed.
|
||||
String runId = handle.runId();
|
||||
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
|
||||
assertEquals(PolicyRunStatus.COMPLETED, run.getStatus());
|
||||
assertEquals(1, run.getOutputs().size());
|
||||
assertEquals("compressed.pdf", run.getOutputs().get(0).getFileName());
|
||||
|
||||
// The run self-registers its results and completion with the job system.
|
||||
verify(taskManager).createTask(runId);
|
||||
verify(taskManager).setMultipleFileResults(eq(runId), any());
|
||||
verify(taskManager).setComplete(runId);
|
||||
// Progress notes were written for each step.
|
||||
verify(taskManager, atLeastOnce()).addNote(eq(runId), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitFailsRunWhenAToolErrors() throws Exception {
|
||||
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
|
||||
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.submit(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
String runId = handle.runId();
|
||||
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
|
||||
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
|
||||
verify(taskManager).setError(eq(runId), anyString());
|
||||
verify(taskManager, never()).setComplete(runId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runPolicyExecutesThePolicysPipeline() throws Exception {
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
|
||||
int[] counter = {0};
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
InputStream is = inv.getArgument(0);
|
||||
return new StoredFile("file-" + ++counter[0], is.readAllBytes().length);
|
||||
});
|
||||
|
||||
Policy policy =
|
||||
new Policy(
|
||||
"p1",
|
||||
"rotate",
|
||||
"owner",
|
||||
true,
|
||||
TriggerConfig.manual(),
|
||||
List.of(new PipelineStep(ROTATE, Map.of())),
|
||||
OutputSpec.inline());
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.runPolicy(
|
||||
policy,
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
|
||||
assertEquals(PolicyRunStatus.COMPLETED, run.getStatus());
|
||||
verify(internalApiClient).post(eq(ROTATE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runIsQueuedUnderResourcePressure() {
|
||||
when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true);
|
||||
// Returning an already-completed future keeps the run parked: the queued work (which would
|
||||
// start the run) is never executed by this mock, so it stays PENDING.
|
||||
doReturn(CompletableFuture.completedFuture(null))
|
||||
.when(jobQueue)
|
||||
.queueJob(anyString(), anyInt(), any(), anyLong());
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.submit(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
verify(jobQueue).queueJob(eq(handle.runId()), anyInt(), any(), anyLong());
|
||||
assertEquals(PolicyRunStatus.PENDING, registry.get(handle.runId()).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resumeIsNotYetImplemented() {
|
||||
assertThrows(UnsupportedOperationException.class, () -> engine.resume("any", List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelUnknownRunReturnsFalse() {
|
||||
assertFalse(engine.cancel("does-not-exist"));
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static PipelineDefinition definition(PipelineStep... steps) {
|
||||
return new PipelineDefinition("test", List.of(steps), OutputSpec.inline());
|
||||
}
|
||||
|
||||
private void stubEndpoint(String endpoint, Resource body) {
|
||||
when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body));
|
||||
}
|
||||
|
||||
private static ByteArrayResource pdf(String content, String filename) {
|
||||
return new ByteArrayResource(content.getBytes()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PolicyExecutor}, the shared pipeline step loop. Covers file chaining across
|
||||
* steps, multi-input vs per-file dispatch, ZIP unpacking, structured-list parameter encoding,
|
||||
* progress callbacks, and timeout propagation. External collaborators are mocked; {@link
|
||||
* TempFileManager} is real so ZIP extraction exercises real code.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PolicyExecutorTest {
|
||||
|
||||
private static final String ROTATE = "/api/v1/general/rotate-pdf";
|
||||
private static final String COMPRESS = "/api/v1/misc/compress-pdf";
|
||||
private static final String SPLIT = "/api/v1/general/split-pages";
|
||||
private static final String MERGE = "/api/v1/general/merge-pdfs";
|
||||
|
||||
@Mock private InternalApiClient internalApiClient;
|
||||
@Mock private ToolMetadataService toolMetadataService;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private PolicyExecutor executor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("policy-test-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
executor =
|
||||
new PolicyExecutor(
|
||||
internalApiClient, toolMetadataService, tempFileManager, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void executesStepsSequentiallyChainingOutputToInput() throws IOException {
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
|
||||
stubEndpoint(COMPRESS, pdf("compressed", "compressed.pdf"));
|
||||
|
||||
List<Integer> steps = new ArrayList<>();
|
||||
PolicyProgressListener listener =
|
||||
new PolicyProgressListener() {
|
||||
@Override
|
||||
public void onStepStart(int stepIndex, int stepCount, String operation) {
|
||||
steps.add(stepIndex);
|
||||
}
|
||||
};
|
||||
|
||||
PolicyExecutionResult result =
|
||||
executor.execute(
|
||||
definition(
|
||||
new PipelineStep(ROTATE, Map.of()),
|
||||
new PipelineStep(COMPRESS, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
listener);
|
||||
|
||||
assertEquals(1, result.files().size());
|
||||
assertEquals("compressed.pdf", result.files().get(0).getFilename());
|
||||
verify(internalApiClient, times(1)).post(eq(ROTATE), any());
|
||||
verify(internalApiClient, times(1)).post(eq(COMPRESS), any());
|
||||
// Progress fired once per step, in order.
|
||||
assertEquals(List.of(1, 2), steps);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiInputEndpointIsCalledOnceWithAllFiles() throws IOException {
|
||||
when(toolMetadataService.isMultiInput(MERGE)).thenReturn(true);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(MERGE)).thenReturn(false);
|
||||
stubEndpoint(MERGE, pdf("merged", "merged.pdf"));
|
||||
|
||||
PolicyExecutionResult result =
|
||||
executor.execute(
|
||||
definition(new PipelineStep(MERGE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("a", "a.pdf"), pdf("b", "b.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
assertEquals(1, result.files().size());
|
||||
verify(internalApiClient, times(1)).post(eq(MERGE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleInputEndpointIsCalledOncePerFile() throws IOException {
|
||||
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(ROTATE)).thenReturn(false);
|
||||
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
|
||||
|
||||
PolicyExecutionResult result =
|
||||
executor.execute(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("a", "a.pdf"), pdf("b", "b.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
assertEquals(2, result.files().size());
|
||||
verify(internalApiClient, times(2)).post(eq(ROTATE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void zipResponseIsUnpackedIntoIndividualFiles() throws IOException {
|
||||
when(toolMetadataService.isMultiInput(SPLIT)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(SPLIT)).thenReturn(true);
|
||||
stubEndpoint(
|
||||
SPLIT,
|
||||
zip(
|
||||
"doc.zip",
|
||||
List.of(new Entry("page-1.pdf", "one"), new Entry("page-2.pdf", "two"))));
|
||||
|
||||
PolicyExecutionResult result =
|
||||
executor.execute(
|
||||
definition(new PipelineStep(SPLIT, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
assertEquals(2, result.files().size());
|
||||
assertEquals("page-1.pdf", result.files().get(0).getFilename());
|
||||
assertEquals("page-2.pdf", result.files().get(1).getFilename());
|
||||
}
|
||||
|
||||
@Test
|
||||
void structuredListParameterIsJsonEncodedAsSingleField() throws IOException {
|
||||
String editText = "/api/v1/general/edit-text";
|
||||
when(toolMetadataService.isMultiInput(editText)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(editText)).thenReturn(false);
|
||||
stubEndpoint(editText, pdf("edited", "edited.pdf"));
|
||||
|
||||
// LinkedHashMap so the serialized key order is deterministic for the assertion below.
|
||||
Map<String, Object> edit = new LinkedHashMap<>();
|
||||
edit.put("find", "foo");
|
||||
edit.put("replace", "bar");
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("edits", List.of(edit));
|
||||
params.put("useRegex", false);
|
||||
|
||||
executor.execute(
|
||||
definition(new PipelineStep(editText, params)),
|
||||
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
|
||||
ArgumentCaptor.forClass(MultiValueMap.class);
|
||||
verify(internalApiClient).post(eq(editText), bodyCaptor.capture());
|
||||
MultiValueMap<String, Object> body = bodyCaptor.getValue();
|
||||
|
||||
List<Object> edits = body.get("edits");
|
||||
assertNotNull(edits);
|
||||
assertEquals(1, edits.size());
|
||||
assertEquals("[{\"find\":\"foo\",\"replace\":\"bar\"}]", edits.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportingFilesAreBoundToTheirNamedFields() throws IOException {
|
||||
String addStamp = "/api/v1/misc/add-stamp-to-pdf";
|
||||
when(toolMetadataService.isMultiInput(addStamp)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(addStamp)).thenReturn(false);
|
||||
stubEndpoint(addStamp, pdf("stamped", "stamped.pdf"));
|
||||
|
||||
PipelineStep step =
|
||||
new PipelineStep(addStamp, Map.of("opacity", 0.5), Map.of("stampImage", "logo"));
|
||||
PolicyInputs inputs =
|
||||
new PolicyInputs(
|
||||
List.of(pdf("doc", "doc.pdf")),
|
||||
Map.of("logo", List.of(pdf("logo-bytes", "logo.png"))));
|
||||
|
||||
executor.execute(
|
||||
new PipelineDefinition("stamp", List.of(step), OutputSpec.inline()),
|
||||
inputs,
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
|
||||
ArgumentCaptor.forClass(MultiValueMap.class);
|
||||
verify(internalApiClient).post(eq(addStamp), bodyCaptor.capture());
|
||||
MultiValueMap<String, Object> body = bodyCaptor.getValue();
|
||||
// The document goes to fileInput; the supporting image is bound to its named field and is
|
||||
// not part of the document stream.
|
||||
assertEquals(1, body.get("fileInput").size());
|
||||
assertNotNull(body.get("stampImage"));
|
||||
assertEquals(1, body.get("stampImage").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSupportingFileFailsTheStep() {
|
||||
String addStamp = "/api/v1/misc/add-stamp-to-pdf";
|
||||
when(toolMetadataService.isMultiInput(addStamp)).thenReturn(false);
|
||||
PipelineStep step = new PipelineStep(addStamp, Map.of(), Map.of("stampImage", "logo"));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() ->
|
||||
executor.execute(
|
||||
new PipelineDefinition(
|
||||
"stamp", List.of(step), OutputSpec.inline()),
|
||||
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
|
||||
PolicyProgressListener.NOOP));
|
||||
assertTrue(ex.getMessage().contains("logo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentOfAnUnacceptedTypeFailsTheStep() {
|
||||
String compress = "/api/v1/misc/compress-pdf";
|
||||
when(toolMetadataService.getExtensionTypes(false, compress)).thenReturn(List.of("pdf"));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() ->
|
||||
executor.execute(
|
||||
definition(new PipelineStep(compress, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("img", "image.png"))),
|
||||
PolicyProgressListener.NOOP));
|
||||
assertTrue(ex.getMessage().contains("image.png"));
|
||||
// Type check happens before any dispatch.
|
||||
verify(internalApiClient, never()).post(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentOfAnAcceptedTypeProceeds() throws IOException {
|
||||
String compress = "/api/v1/misc/compress-pdf";
|
||||
when(toolMetadataService.getExtensionTypes(false, compress)).thenReturn(List.of("pdf"));
|
||||
when(toolMetadataService.isMultiInput(compress)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(compress)).thenReturn(false);
|
||||
stubEndpoint(compress, pdf("compressed", "compressed.pdf"));
|
||||
|
||||
PolicyExecutionResult result =
|
||||
executor.execute(
|
||||
definition(new PipelineStep(compress, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
assertEquals(1, result.files().size());
|
||||
verify(internalApiClient, times(1)).post(eq(compress), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterOperationWithEmptyResultDropsTheFile() throws IOException {
|
||||
String filter = "/api/v1/filter/filter-page-count";
|
||||
when(toolMetadataService.isMultiInput(filter)).thenReturn(false);
|
||||
stubEndpoint(filter, pdf("", "filtered.pdf")); // empty body => filtered out
|
||||
|
||||
PolicyExecutionResult result =
|
||||
executor.execute(
|
||||
definition(new PipelineStep(filter, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
assertEquals(0, result.files().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeoutFromAStepPropagates() {
|
||||
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
|
||||
when(internalApiClient.post(eq(ROTATE), any()))
|
||||
.thenThrow(
|
||||
new InternalApiTimeoutException(
|
||||
ROTATE,
|
||||
java.time.Duration.ofSeconds(300),
|
||||
new IOException("Read timed out")));
|
||||
|
||||
assertThrows(
|
||||
InternalApiTimeoutException.class,
|
||||
() ->
|
||||
executor.execute(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
|
||||
PolicyProgressListener.NOOP));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyPipelineIsRejected() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
executor.execute(
|
||||
new PipelineDefinition("empty", List.of(), OutputSpec.inline()),
|
||||
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
|
||||
PolicyProgressListener.NOOP));
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static PipelineDefinition definition(PipelineStep... steps) {
|
||||
return new PipelineDefinition("test", List.of(steps), OutputSpec.inline());
|
||||
}
|
||||
|
||||
private void stubEndpoint(String endpoint, Resource body) {
|
||||
when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body));
|
||||
}
|
||||
|
||||
private static ByteArrayResource pdf(String content, String filename) {
|
||||
return new ByteArrayResource(content.getBytes()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ByteArrayResource zip(String filename, List<Entry> entries) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (Entry entry : entries) {
|
||||
zos.putNextEntry(new ZipEntry(entry.name()));
|
||||
zos.write(entry.content().getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
byte[] zipBytes = baos.toByteArray();
|
||||
return new ByteArrayResource(zipBytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(zipBytes);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private record Entry(String name, String content) {}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.WaitState;
|
||||
|
||||
/**
|
||||
* Tests for {@link PolicyRunRegistry} eviction: terminal runs expire, active/paused runs persist.
|
||||
*/
|
||||
class PolicyRunRegistryTest {
|
||||
|
||||
private PolicyRunRegistry registry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = new PolicyRunRegistry(30);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
registry.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictsTerminalRunsPastTheCutoff() {
|
||||
PolicyRun completed = register("completed");
|
||||
completed.complete(List.of());
|
||||
PolicyRun failed = register("failed");
|
||||
failed.fail("boom");
|
||||
PolicyRun cancelled = register("cancelled");
|
||||
cancelled.cancel();
|
||||
|
||||
// A cutoff in the future means every terminal run finished "before" it.
|
||||
int removed = registry.evictExpired(Instant.now().plusSeconds(60));
|
||||
|
||||
assertEquals(3, removed);
|
||||
assertNull(registry.get("completed"));
|
||||
assertNull(registry.get("failed"));
|
||||
assertNull(registry.get("cancelled"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsActiveAndPausedRunsRegardlessOfAge() {
|
||||
register("pending"); // PENDING: never started
|
||||
PolicyRun running = register("running");
|
||||
running.markRunning();
|
||||
PolicyRun waiting = register("waiting");
|
||||
waiting.waitForInput(new WaitState("needs a signature", 1, List.of()));
|
||||
|
||||
int removed = registry.evictExpired(Instant.now().plusSeconds(60));
|
||||
|
||||
assertEquals(0, removed);
|
||||
assertNotNull(registry.get("pending"));
|
||||
assertNotNull(registry.get("running"));
|
||||
assertNotNull(registry.get("waiting"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTerminalRunsStillWithinTheExpiryWindow() {
|
||||
PolicyRun completed = register("recent");
|
||||
completed.complete(List.of());
|
||||
|
||||
// A cutoff in the past means the run was updated "after" it: too young to evict.
|
||||
int removed = registry.evictExpired(Instant.now().minusSeconds(60));
|
||||
|
||||
assertEquals(0, removed);
|
||||
assertNotNull(registry.get("recent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictionLeavesUnrelatedRunsInPlace() {
|
||||
PolicyRun completed = register("done");
|
||||
completed.complete(List.of());
|
||||
PolicyRun running = register("busy");
|
||||
running.markRunning();
|
||||
|
||||
registry.evictExpired(Instant.now().plusSeconds(60));
|
||||
|
||||
assertNull(registry.get("done"));
|
||||
assertNotNull(registry.get("busy"));
|
||||
assertTrue(registry.all().stream().anyMatch(r -> r.getRunId().equals("busy")));
|
||||
}
|
||||
|
||||
private PolicyRun register(String runId) {
|
||||
PolicyRun run = new PolicyRun(runId, new PipelineDefinition(runId, List.of(), null));
|
||||
registry.register(run);
|
||||
return run;
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
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 java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.model.TriggerConfig;
|
||||
|
||||
/** Tests for {@link InProcessPolicyStore}: id assignment, upsert, trigger-type lookup, delete. */
|
||||
class InProcessPolicyStoreTest {
|
||||
|
||||
private PolicyStore store;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new InProcessPolicyStore();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedPolicyGetsAnIdAndIsRetrievable() {
|
||||
Policy saved = store.save(policy(null, "compress", "manual", true));
|
||||
|
||||
assertNotNull(saved.id());
|
||||
assertFalse(saved.id().isBlank());
|
||||
assertEquals(saved, store.get(saved.id()).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingWithAnExistingIdUpdatesInPlace() {
|
||||
Policy created = store.save(policy(null, "before", "manual", true));
|
||||
|
||||
store.save(
|
||||
new Policy(
|
||||
created.id(),
|
||||
"after",
|
||||
"owner",
|
||||
true,
|
||||
TriggerConfig.manual(),
|
||||
List.of(),
|
||||
OutputSpec.inline()));
|
||||
|
||||
assertEquals(1, store.all().size());
|
||||
assertEquals("after", store.get(created.id()).orElseThrow().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByTriggerTypeReturnsOnlyEnabledMatches() {
|
||||
store.save(policy(null, "watch", "folder", true));
|
||||
store.save(policy(null, "watch-disabled", "folder", false));
|
||||
store.save(policy(null, "nightly", "schedule", true));
|
||||
|
||||
List<Policy> folder = store.findByTriggerType("folder");
|
||||
|
||||
assertEquals(1, folder.size());
|
||||
assertEquals("watch", folder.get(0).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRemovesThePolicy() {
|
||||
Policy saved = store.save(policy(null, "p", "manual", true));
|
||||
|
||||
assertTrue(store.delete(saved.id()));
|
||||
assertTrue(store.get(saved.id()).isEmpty());
|
||||
assertFalse(store.delete(saved.id()));
|
||||
}
|
||||
|
||||
private static Policy policy(String id, String name, String triggerType, boolean enabled) {
|
||||
return new Policy(
|
||||
id,
|
||||
name,
|
||||
"owner",
|
||||
enabled,
|
||||
new TriggerConfig(triggerType, Map.of()),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline());
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
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 static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import 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.model.TriggerConfig;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Tests for {@link JpaPolicyStore}'s entity mapping and query delegation. The repository is mocked;
|
||||
* real Hibernate/H2 persistence is exercised at application boot (this module's convention is
|
||||
* Mockito unit tests for store/service logic).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JpaPolicyStoreTest {
|
||||
|
||||
@Mock private PolicyRepository repository;
|
||||
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
private JpaPolicyStore store;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new JpaPolicyStore(repository, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAssignsAnIdAndPersistsThePolicyAsJson() {
|
||||
Policy saved =
|
||||
store.save(
|
||||
new Policy(
|
||||
null,
|
||||
"compress incoming",
|
||||
"alice",
|
||||
true,
|
||||
new TriggerConfig("folder", Map.of("path", "/in")),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline()));
|
||||
|
||||
assertNotNull(saved.id());
|
||||
ArgumentCaptor<PolicyEntity> captor = ArgumentCaptor.forClass(PolicyEntity.class);
|
||||
verify(repository).save(captor.capture());
|
||||
PolicyEntity entity = captor.getValue();
|
||||
assertEquals(saved.id(), entity.getId());
|
||||
assertEquals("folder", entity.getTriggerType());
|
||||
assertTrue(entity.isEnabled());
|
||||
// The stored JSON round-trips back to an equal policy.
|
||||
assertEquals(saved, objectMapper.readValue(entity.getPolicyJson(), Policy.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDeserializesThePolicyFromJson() {
|
||||
Policy policy =
|
||||
new Policy(
|
||||
"p1",
|
||||
"rotate",
|
||||
"alice",
|
||||
true,
|
||||
TriggerConfig.manual(),
|
||||
List.of(
|
||||
new PipelineStep(
|
||||
"/api/v1/general/rotate-pdf", Map.of("angle", 90))),
|
||||
OutputSpec.inline());
|
||||
when(repository.findById("p1")).thenReturn(Optional.of(entityFor(policy)));
|
||||
|
||||
assertEquals(policy, store.get("p1").orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByTriggerTypeUsesTheEnabledQuery() {
|
||||
Policy policy =
|
||||
new Policy(
|
||||
"p1",
|
||||
"watch",
|
||||
"alice",
|
||||
true,
|
||||
new TriggerConfig("folder", Map.of()),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline());
|
||||
when(repository.findByTriggerTypeAndEnabledTrue("folder"))
|
||||
.thenReturn(List.of(entityFor(policy)));
|
||||
|
||||
List<Policy> folder = store.findByTriggerType("folder");
|
||||
|
||||
assertEquals(1, folder.size());
|
||||
assertEquals("p1", folder.get(0).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteReturnsWhetherThePolicyExisted() {
|
||||
when(repository.existsById("p1")).thenReturn(true);
|
||||
assertTrue(store.delete("p1"));
|
||||
verify(repository).deleteById("p1");
|
||||
|
||||
when(repository.existsById("missing")).thenReturn(false);
|
||||
assertFalse(store.delete("missing"));
|
||||
}
|
||||
|
||||
private PolicyEntity entityFor(Policy policy) {
|
||||
PolicyEntity entity = new PolicyEntity();
|
||||
entity.setId(policy.id());
|
||||
entity.setName(policy.name());
|
||||
entity.setOwner(policy.owner());
|
||||
entity.setEnabled(policy.enabled());
|
||||
entity.setTriggerType(policy.trigger().type());
|
||||
entity.setPolicyJson(objectMapper.writeValueAsString(policy));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -57,6 +57,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.policy.engine.PolicyExecutor;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
@@ -107,18 +108,20 @@ class AiWorkflowServiceTest {
|
||||
.when(fileIdStrategy.idFor(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> ((MultipartFile) inv.getArgument(0)).getOriginalFilename());
|
||||
|
||||
PolicyExecutor policyExecutor =
|
||||
new PolicyExecutor(
|
||||
internalApiClient, toolMetadataService, tempFileManager, objectMapper);
|
||||
service =
|
||||
new AiWorkflowService(
|
||||
pdfDocumentFactory,
|
||||
aiEngineClient,
|
||||
pdfContentExtractor,
|
||||
objectMapper,
|
||||
internalApiClient,
|
||||
fileStorage,
|
||||
toolMetadataService,
|
||||
tempFileManager,
|
||||
fileIdStrategy,
|
||||
endpointResolver,
|
||||
policyExecutor,
|
||||
null,
|
||||
new ApplicationProperties());
|
||||
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
|
||||
|
||||
@@ -29,6 +29,9 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -48,6 +51,8 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/ai/create")
|
||||
@Tag(name = "AI")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiCreateController {
|
||||
|
||||
+5
@@ -17,6 +17,9 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -27,6 +30,8 @@ import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/ai/create/internal")
|
||||
@Tag(name = "AI")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiCreateInternalController {
|
||||
|
||||
@@ -18,6 +18,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -33,6 +36,8 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/ai")
|
||||
@Tag(name = "AI")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class AiProxyController {
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ public class CreditController {
|
||||
private final CreditService creditService;
|
||||
|
||||
@GetMapping
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Get user credit information",
|
||||
description =
|
||||
@@ -252,6 +253,7 @@ public class CreditController {
|
||||
}
|
||||
|
||||
@GetMapping("/usage")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Get credit usage summary",
|
||||
description = "Get overview of credit usage (for authenticated user or admin view)")
|
||||
|
||||
@@ -49,8 +49,12 @@ public class PaygOutputExtractor {
|
||||
/** {@code %PDF-} in ASCII. */
|
||||
private static final byte[] PDF_MAGIC = {0x25, 0x50, 0x44, 0x46, 0x2D};
|
||||
|
||||
/** {@code PK\x03\x04} — local file header for any non-empty ZIP. */
|
||||
private static final byte[] ZIP_MAGIC = {0x50, 0x4B, 0x03, 0x04};
|
||||
|
||||
private static final String ZIP_CONTENT_TYPE = "application/zip";
|
||||
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
||||
private static final String OCTET_STREAM_CONTENT_TYPE = "application/octet-stream";
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -74,11 +78,31 @@ public class PaygOutputExtractor {
|
||||
return List.of();
|
||||
}
|
||||
String mediaType = stripParameters(contentType);
|
||||
if (PDF_CONTENT_TYPE.equalsIgnoreCase(mediaType)) {
|
||||
|
||||
// Stirling-PDF tool endpoints sometimes set Content-Type to
|
||||
// application/octet-stream (or no header at all) even when the body
|
||||
// is a real PDF or ZIP — Spring's default StreamingResponseBody path
|
||||
// doesn't always negotiate content type. When the declared
|
||||
// Content-Type is missing or generic, sniff magic bytes to recover
|
||||
// lineage capture.
|
||||
boolean missingOrGeneric =
|
||||
mediaType == null || OCTET_STREAM_CONTENT_TYPE.equalsIgnoreCase(mediaType);
|
||||
boolean isPdf = PDF_CONTENT_TYPE.equalsIgnoreCase(mediaType);
|
||||
boolean isZip = ZIP_CONTENT_TYPE.equalsIgnoreCase(mediaType);
|
||||
|
||||
if (missingOrGeneric) {
|
||||
if (isMagic(bodyPath, PDF_MAGIC)) {
|
||||
isPdf = true;
|
||||
} else if (isMagic(bodyPath, ZIP_MAGIC)) {
|
||||
isZip = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
// Wrapper-owned path. Don't claim ownership.
|
||||
return List.of(new ExtractedPdf(bodyPath, null));
|
||||
}
|
||||
if (ZIP_CONTENT_TYPE.equalsIgnoreCase(mediaType)) {
|
||||
if (isZip) {
|
||||
return extractZip(bodyPath);
|
||||
}
|
||||
return List.of();
|
||||
@@ -126,20 +150,25 @@ public class PaygOutputExtractor {
|
||||
}
|
||||
|
||||
private boolean isPdfMagic(Path path) {
|
||||
byte[] head = new byte[PDF_MAGIC.length];
|
||||
return isMagic(path, PDF_MAGIC);
|
||||
}
|
||||
|
||||
/** Returns true if the first {@code magic.length} bytes of {@code path} equal {@code magic}. */
|
||||
private boolean isMagic(Path path, byte[] magic) {
|
||||
byte[] head = new byte[magic.length];
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
int read = in.read(head);
|
||||
if (read != PDF_MAGIC.length) {
|
||||
if (read != magic.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < PDF_MAGIC.length; i++) {
|
||||
if (head[i] != PDF_MAGIC[i]) {
|
||||
for (int i = 0; i < magic.length; i++) {
|
||||
if (head[i] != magic[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.debug("PDF magic-byte check failed for {}", path, e);
|
||||
log.debug("Magic-byte check failed for {}", path, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,19 @@ public class PaygTeamExtensions implements Serializable {
|
||||
@Column(name = "stripe_customer_id", unique = true, length = 128)
|
||||
private String stripeCustomerId;
|
||||
|
||||
/**
|
||||
* Stripe subscription id (sub_xxx) for this team's PAYG metered subscription. {@code null}
|
||||
* means the team has not added a card yet — engine writes shadow rows only and free-tier gating
|
||||
* applies. {@code non-null} means engine posts meter events to Stripe on every billable tool
|
||||
* call.
|
||||
*
|
||||
* <p>This is the single switch that determines whether a team is billed. Mutated only by the
|
||||
* {@code payg_link_subscription} / {@code payg_unlink_subscription} Postgres functions (V14) —
|
||||
* never directly via JPA writes. Treat as read-only from Java.
|
||||
*/
|
||||
@Column(name = "payg_subscription_id", unique = true, length = 128)
|
||||
private String paygSubscriptionId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -73,6 +73,20 @@ public class PricingPolicy implements Serializable {
|
||||
@Column(name = "file_unit_cap", nullable = false)
|
||||
private Integer fileUnitCap = 1000;
|
||||
|
||||
/**
|
||||
* Free-tier allowance — doc units a team on this policy can consume per cycle before they must
|
||||
* add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their
|
||||
* very first tool call until they pay. New-signup defaults will set this to a sensible positive
|
||||
* value; the special launch policy used by the day-1 legacy migration script can override with
|
||||
* a different size if product wants the original 10 customers to feel special.
|
||||
*
|
||||
* <p>Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code
|
||||
* payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass
|
||||
* this check entirely.
|
||||
*/
|
||||
@Column(name = "free_tier_units_per_cycle", nullable = false)
|
||||
private Long freeTierUnitsPerCycle = 0L;
|
||||
|
||||
/**
|
||||
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
|
||||
* JobSource}. Self-hosted teams typically get a higher limit via a per-team policy override.
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package stirling.software.saas.payg.test;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
|
||||
/**
|
||||
* Cucumber-only force-5xx endpoint. Gated behind the {@code payg-cucumber} Spring profile so the
|
||||
* bean never registers in production — only the PAYG cucumber compose stack activates the profile
|
||||
* (via {@code SPRING_PROFILES_ACTIVE=saas,payg-cucumber} in {@code docker-compose-saas.yml}).
|
||||
*
|
||||
* <p>Purpose: drive the PAYG filter+interceptor's 5xx-first-step branch end-to-end. No reliably-
|
||||
* 5xx-ing real tool endpoint exists in current Stirling — every malformed input is caught as 4xx by
|
||||
* {@code GlobalExceptionHandler}. Without this stub the only way to exercise the refund path was a
|
||||
* manual procedure (a temporary throw endpoint added, run, removed) documented in {@code
|
||||
* notes/PAYG_DESIGN.md} §7.5.2 M1. This controller replaces that procedure with a profile- gated
|
||||
* automated scenario.
|
||||
*
|
||||
* <p>{@link AutoJobPostMapping} consumes {@code multipart/form-data} so the filter's {@code
|
||||
* MultipartHttpServletRequest} cast runs and the input lineage hash is computed before the
|
||||
* controller throws. The PAYG filter chain therefore sees: preHandle → openProcess (CHARGED row
|
||||
* written) → controller throws → afterCompletion observes status 500 → markFirstStepFailed (row →
|
||||
* REFUNDED, job → CLOSED).
|
||||
*
|
||||
* <p>The thrown {@link IllegalStateException} is unwrapped by {@code GlobalExceptionHandler}'s
|
||||
* RuntimeException handler — it has no IOException / IllegalArgument / BaseAppException cause, so
|
||||
* it falls through to the "Unexpected RuntimeException" branch which sets HTTP 500. Don't use
|
||||
* {@code ResponseStatusException} here — its dedicated handler is reached before the unwrapping
|
||||
* branch but the upstream test still passes; {@link IllegalStateException} is the more honest mimic
|
||||
* of a real bug-driven 500.
|
||||
*
|
||||
* <p>{@code @Hidden} keeps this endpoint out of OpenAPI / Swagger output even when the profile is
|
||||
* active, so no docs leak to anyone running the cucumber stack interactively.
|
||||
*
|
||||
* <p><b>Return type matters:</b> the method declares {@link ResponseEntity}{@code <Void>}, not
|
||||
* {@code void}. Spring's {@code InvocableHandlerMethod} picks a return-value handler based on the
|
||||
* <i>declared</i> return type; {@code void} matches the "no body, status 200" handler regardless of
|
||||
* what the {@code @Around} advice on {@link AutoJobPostMapping} actually returns at runtime. The
|
||||
* first version of this stub declared {@code void} and the 500 ResponseEntity from {@code
|
||||
* JobExecutorService}'s catch block was silently discarded — the client saw a 200 with an empty
|
||||
* body. Declaring {@code ResponseEntity<Void>} matches the real runtime type and lets the advice's
|
||||
* 500 reach the wire.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@Profile("payg-cucumber")
|
||||
@RequestMapping("/api/v1/payg-cucumber")
|
||||
@Hidden
|
||||
public class PaygCucumberThrowController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/throw-500",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
public ResponseEntity<Void> throw500(
|
||||
@RequestParam(value = "fileInput", required = false) MultipartFile fileInput) {
|
||||
// The file is read by the PAYG filter via getMultiFileMap() before we get here; the
|
||||
// controller param is just to keep Spring's multipart binding happy. We don't touch it.
|
||||
log.warn(
|
||||
"PAYG cucumber forced 500 (fileInput name='{}' size={} bytes)",
|
||||
fileInput != null ? fileInput.getOriginalFilename() : null,
|
||||
fileInput != null ? fileInput.getSize() : 0);
|
||||
throw new IllegalStateException("PAYG cucumber forced 500");
|
||||
// unreachable — kept as a type signature so AutoJobAspect's @Around return value (the
|
||||
// 500 ResponseEntity from JobExecutorService) actually reaches the wire.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
-- PAYG subscription state — the column + functions that let new customers reach Stripe billing.
|
||||
--
|
||||
-- This migration is half of the Stripe/Supabase wire-up (PR-SB-1 in `notes/PAYG_DESIGN.md`
|
||||
-- revision note + `payg-stripe-supabase-plan.html`). It's strictly additive:
|
||||
-- * one new column on payg_team_extensions (payg_subscription_id)
|
||||
-- * one new column on pricing_policy (free_tier_units_per_cycle)
|
||||
-- * two RPC functions (payg_link_subscription, payg_unlink_subscription) — the only writers
|
||||
-- of subscription state, called by stripe-webhook + create-payg-team-subscription edge fns
|
||||
-- * an AFTER-INSERT trigger on teams that auto-creates the payg_team_extensions sidecar row
|
||||
-- so every new signup is PAYG-by-default
|
||||
-- * an RLS policy that lets team LEADERs (and the service role) link subscriptions
|
||||
--
|
||||
-- No behaviour change for the running app until PR-SB-4 wires PaygMeterReportingService and
|
||||
-- the free-tier gate into JobChargeService. Until then this just exposes new state for the
|
||||
-- edge functions in PR-SB-2 to write through to.
|
||||
--
|
||||
-- Design references:
|
||||
-- * notes/PAYG_DESIGN.md (revision note 2026-06-03 — "subscription presence is the gate")
|
||||
-- * payg-stripe-supabase-plan.html §3.1 — RPC functions; §3.5 — RLS policy
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. New columns
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_team_extensions
|
||||
ADD COLUMN IF NOT EXISTS payg_subscription_id VARCHAR(128) UNIQUE;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.payg_team_extensions.payg_subscription_id IS
|
||||
'Stripe subscription id (sub_xxx) for this team''s PAYG metered subscription. '
|
||||
'NULL = team has not added a card yet; engine writes shadow rows only. '
|
||||
'NOT NULL = engine posts meter events to Stripe on every billable tool call. '
|
||||
'Mutated exclusively by payg_link_subscription / payg_unlink_subscription RPC functions.';
|
||||
|
||||
ALTER TABLE stirling_pdf.pricing_policy
|
||||
ADD COLUMN IF NOT EXISTS free_tier_units_per_cycle BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units_per_cycle IS
|
||||
'Doc units a team on this policy can consume per cycle before they must add a card. '
|
||||
'Default 0 = no free tier (block immediately). The seeded default policy will set this '
|
||||
'to the launch free-tier size; the special "launch" policy used by the day-1 legacy '
|
||||
'migration script (see PAYG_DESIGN.md §3.10 revised) can override.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. RPC: payg_link_subscription
|
||||
--
|
||||
-- Called by:
|
||||
-- * supabase/functions/create-payg-team-subscription/index.ts (post-Stripe-Checkout, with
|
||||
-- either user JWT [normal path, RLS-enforced] or service-role [day-1 migration script])
|
||||
-- * supabase/functions/stripe-webhook/handlers/payg-subscription.ts on
|
||||
-- customer.subscription.created (idempotent — second invocation with same args is a no-op)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_link_subscription(
|
||||
p_team_id BIGINT,
|
||||
p_customer_id TEXT,
|
||||
p_subscription_id TEXT
|
||||
) RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE stirling_pdf.payg_team_extensions
|
||||
SET stripe_customer_id = p_customer_id,
|
||||
payg_subscription_id = p_subscription_id,
|
||||
updated_at = now()
|
||||
WHERE team_id = p_team_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'payg_team_extensions row missing for team %', p_team_id
|
||||
USING ERRCODE = 'foreign_key_violation';
|
||||
END IF;
|
||||
|
||||
INSERT INTO stirling_pdf.payg_subscription_change_log(team_id, action, subscription_id)
|
||||
VALUES (p_team_id, 'LINKED', p_subscription_id);
|
||||
END $$;
|
||||
|
||||
COMMENT ON FUNCTION stirling_pdf.payg_link_subscription(BIGINT, TEXT, TEXT) IS
|
||||
'Idempotent link of a Stripe subscription to a team. SECURITY INVOKER means RLS applies — '
|
||||
'the caller must be a LEADER of the team (or hold the service-role bypass). '
|
||||
'Writes an audit row to payg_subscription_change_log.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. RPC: payg_unlink_subscription
|
||||
--
|
||||
-- Called by stripe-webhook handlers/payg-subscription.ts on customer.subscription.deleted
|
||||
-- (after Stripe's own retries have given up). Drops the team back to free-tier-then-block.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_unlink_subscription(
|
||||
p_team_id BIGINT,
|
||||
p_reason TEXT
|
||||
) RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE stirling_pdf.payg_team_extensions
|
||||
SET payg_subscription_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE team_id = p_team_id;
|
||||
-- We deliberately keep stripe_customer_id — the team may add a new card later and we'd
|
||||
-- like to reuse the existing Stripe customer record rather than create a duplicate.
|
||||
|
||||
INSERT INTO stirling_pdf.payg_subscription_change_log(team_id, action, reason)
|
||||
VALUES (p_team_id, 'UNLINKED', p_reason);
|
||||
END $$;
|
||||
|
||||
COMMENT ON FUNCTION stirling_pdf.payg_unlink_subscription(BIGINT, TEXT) IS
|
||||
'Drops the team back to free-tier-then-block derived state. Reason is logged for audit '
|
||||
'(typically subscription_deleted | admin | card_removed).';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. Auto-create payg_team_extensions row when a team is created
|
||||
--
|
||||
-- Every new signup gets a payg_team_extensions row with NULL pricing_policy_id (which the
|
||||
-- backend's PricingPolicyService resolves to the default policy). The free-tier gate kicks in
|
||||
-- from the very first tool call.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO stirling_pdf.payg_team_extensions(team_id)
|
||||
VALUES (NEW.team_id)
|
||||
ON CONFLICT (team_id) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_payg_create_team_extensions ON stirling_pdf.teams;
|
||||
CREATE TRIGGER trg_payg_create_team_extensions
|
||||
AFTER INSERT ON stirling_pdf.teams
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION stirling_pdf.payg_create_team_extensions_trigger();
|
||||
|
||||
COMMENT ON TRIGGER trg_payg_create_team_extensions ON stirling_pdf.teams IS
|
||||
'Ensures every team has a payg_team_extensions sidecar row from creation. New customers '
|
||||
'are PAYG-default from minute one — they consume free-tier units until they add a card.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. Backfill: any existing team without a sidecar row gets one now
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO stirling_pdf.payg_team_extensions(team_id)
|
||||
SELECT t.team_id
|
||||
FROM stirling_pdf.teams t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM stirling_pdf.payg_team_extensions x WHERE x.team_id = t.team_id
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 6. RLS policy
|
||||
--
|
||||
-- Service-role bypasses RLS (backend reads + day-1 migration script writes via the service-role
|
||||
-- key). For user-initiated writes via the frontend Add-Card flow, only team LEADERs can link a
|
||||
-- subscription. SELECT remains permissive — anyone in the team can see the row.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_team_extensions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Read: any team member can see their team's payg row.
|
||||
DROP POLICY IF EXISTS payg_team_ext_select ON stirling_pdf.payg_team_extensions;
|
||||
CREATE POLICY payg_team_ext_select
|
||||
ON stirling_pdf.payg_team_extensions
|
||||
FOR SELECT
|
||||
USING (
|
||||
team_id IN (
|
||||
SELECT tm.team_id
|
||||
FROM stirling_pdf.team_memberships tm
|
||||
JOIN stirling_pdf.users u ON u.user_id = tm.user_id
|
||||
WHERE u.supabase_auth_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Update: only LEADERs of the team can update (i.e. link / unlink a subscription).
|
||||
DROP POLICY IF EXISTS payg_team_ext_leader_update ON stirling_pdf.payg_team_extensions;
|
||||
CREATE POLICY payg_team_ext_leader_update
|
||||
ON stirling_pdf.payg_team_extensions
|
||||
FOR UPDATE
|
||||
USING (
|
||||
team_id IN (
|
||||
SELECT tm.team_id
|
||||
FROM stirling_pdf.team_memberships tm
|
||||
JOIN stirling_pdf.users u ON u.user_id = tm.user_id
|
||||
WHERE u.supabase_auth_id = auth.uid()
|
||||
AND tm.role = 'LEADER'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
-- PAYG audit-log tables. Two append-only logs:
|
||||
--
|
||||
-- * payg_meter_event_log — written by the backend's PaygMeterReportingService
|
||||
-- on every Stripe meter event POST attempt. Gives us a
|
||||
-- record independent of Stripe's own logs so we can
|
||||
-- replay-after-24h-window (Stripe's idempotency window)
|
||||
-- and run nightly reconciliation against Stripe's
|
||||
-- meter-event list.
|
||||
--
|
||||
-- * payg_subscription_change_log — written by V14's two RPC functions on every
|
||||
-- subscription link / unlink. Independent of Stripe's
|
||||
-- webhook log; lets us diagnose "why is this team in
|
||||
-- free-tier-block when their Stripe sub is active?"
|
||||
-- without leaving our DB.
|
||||
--
|
||||
-- Both are pure additive; nothing reads them yet. PR-SB-5 (nightly reconcile) wires the
|
||||
-- meter-event log; the subscription change log is queried only from admin tooling.
|
||||
--
|
||||
-- Design references:
|
||||
-- * payg-stripe-supabase-plan.html §3.10 — twin migrations
|
||||
-- * payg-stripe-supabase-plan.html §8 H5 — 24h idempotency window mitigation
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. payg_meter_event_log — backend-side audit of every Stripe meter event we tried to post.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.payg_meter_event_log (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
job_id UUID,
|
||||
idempotency_key VARCHAR(128) NOT NULL UNIQUE,
|
||||
units INTEGER NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
posted_to_stripe_at TIMESTAMP,
|
||||
-- NULL while pending; set when the meter-payg-units edge fn returns success. NULL after
|
||||
-- 24h means the event never made it to Stripe — nightly reconcile retries with a fresh
|
||||
-- idempotency-key suffix (see §8 H5 mitigation).
|
||||
stripe_error_code VARCHAR(64),
|
||||
stripe_error_body TEXT,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_meter_event_team_time
|
||||
ON stirling_pdf.payg_meter_event_log (team_id, occurred_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_meter_event_unposted
|
||||
ON stirling_pdf.payg_meter_event_log (occurred_at)
|
||||
WHERE posted_to_stripe_at IS NULL;
|
||||
|
||||
COMMENT ON TABLE stirling_pdf.payg_meter_event_log IS
|
||||
'Backend audit of every Stripe meter event POST attempt. Independent of Stripe meter '
|
||||
'history. idempotency_key is the same one passed to Stripe; the UNIQUE constraint here '
|
||||
'gives us safe at-least-once semantics even on backend retry. Rows older than 24h with '
|
||||
'posted_to_stripe_at IS NULL are stuck and retried by the nightly reconcile job.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. payg_subscription_change_log — written by V14's RPC functions on every link / unlink.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.payg_subscription_change_log (
|
||||
change_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
-- LINKED — payg_link_subscription written subscription_id
|
||||
-- UNLINKED — payg_unlink_subscription cleared the subscription
|
||||
subscription_id VARCHAR(128),
|
||||
reason TEXT,
|
||||
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_sub_change_team_time
|
||||
ON stirling_pdf.payg_subscription_change_log (team_id, changed_at);
|
||||
|
||||
COMMENT ON TABLE stirling_pdf.payg_subscription_change_log IS
|
||||
'Append-only log of every subscription link / unlink. Written by V14 RPC functions; '
|
||||
'never updated. Diagnostic value when reconciling against Stripe webhook history.';
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="Stirling PDF Cloud API">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stirling PDF - Cloud API</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(45rem, 96vw);
|
||||
background-color: #ffffff;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
max-width: 26.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header-logos {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-logo-modern {
|
||||
height: 3rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-list {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin-left: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-list a,
|
||||
.section-text a {
|
||||
color: #AF3434;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-list a:hover,
|
||||
.section-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #AF3434;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #9a2e2e;
|
||||
}
|
||||
|
||||
.cta-button.secondary {
|
||||
background-color: #ffffff;
|
||||
color: #AF3434;
|
||||
border: 1px solid #AF3434;
|
||||
}
|
||||
|
||||
.cta-button.secondary:hover {
|
||||
background-color: #fdf2f2;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 1.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
background-color: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #AF3434;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-content">
|
||||
<div class="login-header">
|
||||
<div class="login-header-logos">
|
||||
<img src="modern-logo.svg" alt="Stirling PDF" class="login-logo-modern">
|
||||
</div>
|
||||
<h1 class="login-title">Stirling PDF API</h1>
|
||||
<p class="login-subtitle">REST API for PDF manipulation, in the cloud</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p class="section-text">
|
||||
You've reached the <strong>Stirling PDF Cloud API</strong>. This endpoint serves the REST API that powers our apps and your integrations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="swagger-ui/index.html" class="cta-button">Open API Documentation</a>
|
||||
<a href="https://docs.stirlingpdf.com" target="_blank" class="cta-button secondary">Read the Developer Docs</a>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Just looking to edit a PDF?</h2>
|
||||
<p class="section-text">
|
||||
This page is the API endpoint - it doesn't have an editor. If you want the full interface:
|
||||
</p>
|
||||
<ul class="section-list">
|
||||
<li><a href="https://stirling.com/app" target="_blank">Open Stirling PDF in your browser</a> - the hosted web app</li>
|
||||
<li><a href="https://stirling.com/download" target="_blank">Download the desktop app</a> - macOS, Windows, and Linux</li>
|
||||
<li><a href="https://stirling.com/pricing" target="_blank">Pricing & plans</a> - free tier, paid plans, and enterprise</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Want to run your own?</h2>
|
||||
<p class="section-text">
|
||||
Stirling PDF is open source. You can self-host the same toolkit on your own hardware or run it inside your network:
|
||||
</p>
|
||||
<ul class="section-list">
|
||||
<li><a href="https://docs.stirlingpdf.com/Installation/Versions" target="_blank">Installation guide</a> - Docker, JAR, Windows, Mac, and Linux</li>
|
||||
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank">GitHub repository</a> - source code and releases</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Support</h2>
|
||||
<ul class="section-list">
|
||||
<li><a href="https://docs.stirlingpdf.com" target="_blank">Documentation</a> - guides, API reference, integration tutorials</li>
|
||||
<li><a href="https://discord.gg/Cn8pWhQRxZ" target="_blank">Discord community</a> - chat with other developers and users</li>
|
||||
<li><a href="https://stirling.com" target="_blank">stirling.com</a> - company website</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Powered by </span>
|
||||
<a href="https://stirlingpdf.com">Stirling PDF</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+55
@@ -102,6 +102,61 @@ class PaygOutputExtractorTest {
|
||||
assertThat(extractor.extract("application/zip", zip)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void octetStreamWithPdfMagic_treatedAsPdf(@TempDir Path tmp) throws IOException {
|
||||
// Stirling tool endpoints sometimes set Content-Type to
|
||||
// application/octet-stream for streamed responses even when the body
|
||||
// is a real PDF. The extractor must sniff magic bytes when the
|
||||
// declared Content-Type is generic.
|
||||
Path body = tmp.resolve("body.bin");
|
||||
Files.write(body, pdfBytes("real-pdf-content"));
|
||||
List<PaygOutputExtractor.ExtractedPdf> out =
|
||||
extractor.extract("application/octet-stream", body);
|
||||
assertThat(out).hasSize(1);
|
||||
assertThat(out.get(0).path()).isEqualTo(body);
|
||||
}
|
||||
|
||||
@Test
|
||||
void octetStreamWithZipMagic_unpackedAsZip(@TempDir Path tmp) throws IOException {
|
||||
Path zip = tmp.resolve("body.bin");
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) {
|
||||
writeEntry(zos, "page1.pdf", pdfBytes("a"));
|
||||
writeEntry(zos, "page2.pdf", pdfBytes("b"));
|
||||
}
|
||||
List<PaygOutputExtractor.ExtractedPdf> out =
|
||||
extractor.extract("application/octet-stream", zip);
|
||||
try {
|
||||
assertThat(out).hasSize(2);
|
||||
} finally {
|
||||
for (PaygOutputExtractor.ExtractedPdf p : out) {
|
||||
p.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullContentTypeWithZipMagic_unpackedAsZip(@TempDir Path tmp) throws IOException {
|
||||
Path zip = tmp.resolve("body.bin");
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) {
|
||||
writeEntry(zos, "page1.pdf", pdfBytes("a"));
|
||||
}
|
||||
List<PaygOutputExtractor.ExtractedPdf> out = extractor.extract(null, zip);
|
||||
try {
|
||||
assertThat(out).hasSize(1);
|
||||
} finally {
|
||||
for (PaygOutputExtractor.ExtractedPdf p : out) {
|
||||
p.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void octetStreamWithoutMagic_returnsEmpty(@TempDir Path tmp) throws IOException {
|
||||
Path body = tmp.resolve("body.bin");
|
||||
Files.write(body, "neither pdf nor zip".getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(extractor.extract("application/octet-stream", body)).isEmpty();
|
||||
}
|
||||
|
||||
private static void writeEntry(ZipOutputStream zos, String name, byte[] data)
|
||||
throws IOException {
|
||||
zos.putNextEntry(new ZipEntry(name));
|
||||
|
||||
@@ -12,7 +12,6 @@ cancel = "Cancel"
|
||||
changedCredsMessage = "Credentials changed!"
|
||||
chooseFile = "Choose File"
|
||||
close = "Close"
|
||||
color = "Colour"
|
||||
comingSoon = "Coming soon"
|
||||
confirm = "Confirm"
|
||||
confirmClose = "Confirm Close"
|
||||
@@ -199,6 +198,10 @@ ssoDescription = "Two-factor authentication is managed by your identity provider
|
||||
ssoManaged = "Configure MFA through your identity provider."
|
||||
title = "Two-factor authentication"
|
||||
|
||||
[account.overview]
|
||||
logOut = "Log out"
|
||||
signedInAs = "Signed in as: {{email}}"
|
||||
|
||||
[add-page-numbers]
|
||||
tags = "paginate,label,organize,index"
|
||||
|
||||
@@ -226,6 +229,9 @@ title = "What it does"
|
||||
[AddAttachmentsRequest.tooltip.header]
|
||||
title = "About Add Attachments"
|
||||
|
||||
[addFileCard]
|
||||
upload = "Upload"
|
||||
|
||||
[addImage]
|
||||
applySignatures = "Apply Images"
|
||||
header = "Add images to PDFs"
|
||||
@@ -606,6 +612,7 @@ label = "Temp File Management"
|
||||
[admin.settings.advanced.tempFileManagement.baseTmpDir]
|
||||
description = "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)"
|
||||
label = "Base Temp Directory"
|
||||
placeholder = "Default: java.io.tmpdir/stirling-pdf"
|
||||
|
||||
[admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes]
|
||||
description = "How often to run cleanup (in minutes)"
|
||||
@@ -618,6 +625,7 @@ label = "Cleanup System Temp"
|
||||
[admin.settings.advanced.tempFileManagement.libreofficeDir]
|
||||
description = "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)"
|
||||
label = "LibreOffice Temp Directory"
|
||||
placeholder = "Default: baseTmpDir/libreoffice"
|
||||
|
||||
[admin.settings.advanced.tempFileManagement.maxAgeHours]
|
||||
description = "Maximum age in hours before temp files are cleaned up"
|
||||
@@ -634,6 +642,7 @@ label = "Startup Cleanup"
|
||||
[admin.settings.advanced.tempFileManagement.systemTempDir]
|
||||
description = "System temp directory to clean (only used if cleanupSystemTemp is enabled)"
|
||||
label = "System Temp Directory"
|
||||
placeholder = "System temp directory path"
|
||||
|
||||
[admin.settings.advanced.tessdataDir]
|
||||
description = "Path to the tessdata directory for OCR language files"
|
||||
@@ -839,6 +848,7 @@ label = "Database Name"
|
||||
[admin.settings.database.password]
|
||||
description = "Database authentication password"
|
||||
label = "Password"
|
||||
placeholder = "Enter database password"
|
||||
|
||||
[admin.settings.database.port]
|
||||
description = "Database server port (not used if custom URL is provided)"
|
||||
@@ -870,10 +880,12 @@ label = "Hide unavailable tools by default"
|
||||
[admin.settings.endpoints.groupsToRemove]
|
||||
description = "Select endpoint groups to disable"
|
||||
label = "Disabled Endpoint Groups"
|
||||
placeholder = "Select groups to disable"
|
||||
|
||||
[admin.settings.endpoints.toRemove]
|
||||
description = "Select individual endpoints to disable"
|
||||
label = "Disabled Endpoints"
|
||||
placeholder = "Select endpoints to disable"
|
||||
|
||||
[admin.settings.enterpriseRequired]
|
||||
message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference."
|
||||
@@ -1088,6 +1100,7 @@ label = "SMTP Host"
|
||||
[admin.settings.mail.password]
|
||||
description = "Password for SMTP authentication"
|
||||
label = "SMTP Password"
|
||||
placeholder = "Enter SMTP password"
|
||||
|
||||
[admin.settings.mail.port]
|
||||
description = "The port number for SMTP connection (typically 25, 465, or 587)"
|
||||
@@ -1097,6 +1110,10 @@ label = "SMTP Port"
|
||||
description = "Username for SMTP authentication"
|
||||
label = "SMTP Username"
|
||||
|
||||
[admin.settings.plan.noData]
|
||||
message = "Plans data is not available at the moment."
|
||||
title = "No data available"
|
||||
|
||||
[admin.settings.premium]
|
||||
description = "Configure your premium or enterprise license key."
|
||||
license = "License Configuration"
|
||||
@@ -1143,6 +1160,9 @@ info = "If you have a license key or certificate file from a direct purchase, yo
|
||||
toggle = "Got a license key or certificate file?"
|
||||
|
||||
[admin.settings.premium.movedFeatures]
|
||||
auditLogging = "<0>Audit Logging</0> (ENTERPRISE) - Security"
|
||||
customMetadata = "<0>Custom Metadata</0> (PRO) - General"
|
||||
databaseConfiguration = "<0>Database Configuration</0> (ENTERPRISE) - Database"
|
||||
message = "Premium and Enterprise features are now organized in their respective sections:"
|
||||
title = "Premium Features Distributed"
|
||||
|
||||
@@ -1526,10 +1546,10 @@ section_title = "Agents"
|
||||
show_less = "Show less"
|
||||
start_chat = "Start chatting"
|
||||
stirling_description = "Your general-purpose PDF assistant"
|
||||
stirling_running = "Running..."
|
||||
stirling_full_name = "Stirling General Agent"
|
||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||
stirling_name = "Stirling"
|
||||
stirling_running = "Running..."
|
||||
stirling_tooltip = "Stirling agent"
|
||||
view_all = "View all agents"
|
||||
|
||||
@@ -2126,11 +2146,31 @@ placeholder = "Number of pages"
|
||||
title = "Last N Pages"
|
||||
|
||||
[bulkSelection.operators]
|
||||
text = "AND has higher precedence than comma. NOT applies within the document range."
|
||||
title = "Operators"
|
||||
|
||||
[bulkSelection.operators.and]
|
||||
title = "Combine selections (both conditions must be true)"
|
||||
|
||||
[bulkSelection.operators.descriptions]
|
||||
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
|
||||
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
|
||||
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
|
||||
text = "AND has higher precedence than comma. NOT applies within the document range."
|
||||
title = "Operators"
|
||||
|
||||
[bulkSelection.operators.even]
|
||||
title = "Select all even-numbered pages (2, 4, 6, 8...)"
|
||||
|
||||
[bulkSelection.operators.not]
|
||||
title = "Exclude from selection"
|
||||
|
||||
[bulkSelection.operators.odd]
|
||||
title = "Select all odd-numbered pages (1, 3, 5, 7...)"
|
||||
|
||||
[bulkSelection.operators.or]
|
||||
title = "Add to selection (either condition can be true)"
|
||||
|
||||
[bulkSelection.pageSelection]
|
||||
title = "Page Selection"
|
||||
|
||||
[bulkSelection.range]
|
||||
fromPlaceholder = "From"
|
||||
@@ -2782,6 +2822,9 @@ unknownTool = "Unknown tool"
|
||||
[cloudBadge]
|
||||
tooltip = "This operation will use your cloud credits"
|
||||
|
||||
[color.eyeDropper]
|
||||
tooltip = "Pick colour from screen"
|
||||
|
||||
[colorPicker]
|
||||
title = "Choose colour"
|
||||
|
||||
@@ -3006,6 +3049,10 @@ title = "Compression Method"
|
||||
1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality"
|
||||
_value = "Compression Settings"
|
||||
|
||||
[compress.settings]
|
||||
desiredSize = "Desired File Size"
|
||||
desiredSizePlaceholder = "Enter size"
|
||||
|
||||
[compress.tooltip.description]
|
||||
text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
|
||||
title = "Description"
|
||||
@@ -3496,6 +3543,13 @@ docsLink = "View installation documentation"
|
||||
message = "Stirling-PDF does not have permission to update itself on this machine."
|
||||
title = "Administrator permissions required"
|
||||
|
||||
[dropdownList]
|
||||
searchPlaceholder = "Search..."
|
||||
|
||||
[editableSecretField]
|
||||
edit = "Edit"
|
||||
editSecretValue = "Edit secret value"
|
||||
|
||||
[editTableOfContents]
|
||||
submit = "Apply table of contents"
|
||||
|
||||
@@ -3583,6 +3637,9 @@ tabTitle = "Outline workspace"
|
||||
description = "Select the Edit Table of Contents tool to load its workspace."
|
||||
title = "Open the tool to start editing"
|
||||
|
||||
[emptyFilesState]
|
||||
upload = "Upload"
|
||||
|
||||
[encryptedPdfUnlock]
|
||||
description = "This PDF is password protected. Enter the password so you can continue working with it."
|
||||
emptyResponse = "Password removal did not produce a file."
|
||||
@@ -3697,6 +3754,10 @@ title = "Settings"
|
||||
[extractPages.tooltip]
|
||||
description = "Extracts the selected pages into a new PDF, preserving order."
|
||||
|
||||
[fileCard]
|
||||
openInFileEditor = "Open in File Editor"
|
||||
viewInViewer = "View in Viewer"
|
||||
|
||||
[fileChooser]
|
||||
click = "Click"
|
||||
dragAndDrop = "Drag & Drop"
|
||||
@@ -3706,6 +3767,9 @@ extractPDF = "Extracting..."
|
||||
hoveredDragAndDrop = "Drag & Drop file(s) here"
|
||||
or = "or"
|
||||
|
||||
[fileDropdownMenu]
|
||||
closeFile = "Close file"
|
||||
|
||||
[fileEditor]
|
||||
addFiles = "Add Files"
|
||||
pageCount = "{{count}} pages"
|
||||
@@ -3872,6 +3936,10 @@ openSettings = "Open settings"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
openInViewer = "Open in viewer"
|
||||
|
||||
[filesPage]
|
||||
addToWorkspace = "Add to workspace"
|
||||
addToWorkspaceCount = "Add {{count}} to workspace"
|
||||
@@ -4178,6 +4246,15 @@ title = "About Flattening PDFs"
|
||||
discord = "Discord"
|
||||
issues = "GitHub"
|
||||
|
||||
[formFill]
|
||||
flattenAfterFilling = "Flatten after filling"
|
||||
rescanFields = "Re-scan fields"
|
||||
rescanFormFields = "Re-scan form fields"
|
||||
save = "Save"
|
||||
|
||||
[formFill.sidebar]
|
||||
close = "Close sidebar"
|
||||
|
||||
[getPdfInfo]
|
||||
downloadJson = "Download JSON"
|
||||
downloads = "Downloads"
|
||||
@@ -4737,6 +4814,11 @@ tags = "markup,web-content,transformation,convert"
|
||||
title = "HTML To PDF"
|
||||
zoom = "Zoom level for displaying the website."
|
||||
|
||||
[image.upload]
|
||||
hint = "Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
|
||||
label = "Upload Image"
|
||||
placeholder = "Select image file"
|
||||
|
||||
[imageToPdf]
|
||||
tags = "conversion,img,jpg,picture,photo"
|
||||
|
||||
@@ -4755,6 +4837,9 @@ title = "Image to PDF"
|
||||
4 = "Merge into single PDF"
|
||||
5 = "Convert to separate PDFs"
|
||||
|
||||
[infoBanner]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[invite]
|
||||
acceptError = "Failed to create account"
|
||||
accountFor = "Creating account for"
|
||||
@@ -5090,6 +5175,7 @@ failed = "OCR operation failed"
|
||||
|
||||
[ocr.languagePicker]
|
||||
additionalLanguages = "Looking for additional languages?"
|
||||
loading = "Loading available languages..."
|
||||
viewSetupGuide = "View setup guide →"
|
||||
|
||||
[ocr.operation]
|
||||
@@ -5226,6 +5312,10 @@ daysRemainingSingular = "{{days}} day remaining"
|
||||
title = "Your 30-Day Pro Trial"
|
||||
trialEnds = "Trial ends {{date}}"
|
||||
|
||||
[onboarding.mfa]
|
||||
authenticationCode = "Authentication code"
|
||||
qrCodeLoading = "Generating your QR code…"
|
||||
|
||||
[onboarding.planOverview]
|
||||
adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
|
||||
adminBodyLoginEnabled = "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
|
||||
@@ -5235,6 +5325,9 @@ userTitle = "Plan Overview"
|
||||
|
||||
[onboarding.securityCheck]
|
||||
message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
|
||||
roleAdmin = "Admin"
|
||||
rolePlaceholder = "Confirm your role"
|
||||
roleUser = "User"
|
||||
|
||||
[onboarding.serverLicense]
|
||||
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
|
||||
@@ -5360,6 +5453,17 @@ title = "Page Editor"
|
||||
zoomIn = "Zoom In"
|
||||
zoomOut = "Zoom Out"
|
||||
|
||||
[pageEditor.emptyState]
|
||||
body = "Add files to start editing pages"
|
||||
title = "No PDF files loaded"
|
||||
|
||||
[pageEditor.toolbar]
|
||||
delete = "Delete Selected"
|
||||
redo = "Redo"
|
||||
rotateLeft = "Rotate Selected Left"
|
||||
rotateRight = "Rotate Selected Right"
|
||||
undo = "Undo"
|
||||
|
||||
[pageExtracter]
|
||||
header = "Extract Pages"
|
||||
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
||||
@@ -5371,12 +5475,14 @@ addBorder = "Add Borders"
|
||||
borderWidth = "Border Thickness"
|
||||
bottom = "Bottom Margin"
|
||||
cols = "Columns"
|
||||
colsPlaceholder = "Enter columns"
|
||||
header = "Multi Page Layout"
|
||||
innerMargin = "Inner Margin"
|
||||
left = "Left Margin"
|
||||
pagesPerSheet = "Pages per sheet:"
|
||||
right = "Right Margin"
|
||||
rows = "Rows"
|
||||
rowsPlaceholder = "Enter rows"
|
||||
submit = "Submit"
|
||||
tags = "merge,composite,single-view,organize"
|
||||
title = "Multi Page Layout"
|
||||
@@ -5418,6 +5524,14 @@ innerMarginTooLarge = "Inner margin is too large for the selected layout."
|
||||
outerHorizontalMarginsTooLarge = "Left/Right margins are too large for this page size."
|
||||
outerVerticalMarginsTooLarge = "Top/Bottom margins are too large for this page size."
|
||||
|
||||
[pageLayout.margins]
|
||||
borderThicknessPlaceholder = "Enter border thickness"
|
||||
bottomPlaceholder = "Enter bottom margin"
|
||||
innerPlaceholder = "Enter inner margin"
|
||||
leftPlaceholder = "Enter left margin"
|
||||
rightPlaceholder = "Enter right margin"
|
||||
topPlaceholder = "Enter top margin"
|
||||
|
||||
[pageLayout.marginsBorders.tooltip.borders]
|
||||
text = "Enable borders to draw lines around each placed page. This can help visual separation or trimming."
|
||||
title = "Add Borders"
|
||||
@@ -6071,6 +6185,26 @@ pdfTools = "Basic PDF Tools"
|
||||
priority = "Priority Support"
|
||||
title = "Feature"
|
||||
|
||||
[plan.features]
|
||||
allPdfOperations = "All PDF operations"
|
||||
auditing = "Auditing"
|
||||
communitySupport = "Community support"
|
||||
customPdfMetadata = "Custom PDF metadata"
|
||||
editingTextInPdfs = "Editing text in pdfs"
|
||||
externalDatabase = "External Database"
|
||||
googleDriveIntegration = "Google drive integration"
|
||||
prometheusSupport = "Prometheus Support"
|
||||
regularUpdates = "Regular updates"
|
||||
saml = "SAML"
|
||||
secureLoginSupport = "Secure Login Support"
|
||||
selfHostedDeployment = "Self-hosted deployment"
|
||||
sso = "SSO"
|
||||
unlimitedUsers = "Unlimited users"
|
||||
upToFiveUsers = "Up to 5 users"
|
||||
upToFiveUsersLowercase = "up to 5 users"
|
||||
usageTracking = "Usage tracking"
|
||||
usersLimitedToSeats = "Users limited to seats"
|
||||
|
||||
[plan.free]
|
||||
forever = "Forever free"
|
||||
highlight1 = "Limited Tool Usage Per week"
|
||||
@@ -6079,6 +6213,22 @@ highlight3 = "Community support"
|
||||
included = "Included"
|
||||
name = "Free"
|
||||
|
||||
[plan.highlights]
|
||||
advancedIntegrations = "Advanced integrations"
|
||||
allBasicFeatures = "All basic features"
|
||||
cancelAnytime = "Cancel anytime"
|
||||
customPdfMetadata = "Custom PDF metadata"
|
||||
editingTextInPdfsCaps = "Editing text in PDFs"
|
||||
enterpriseFeatures = "Enterprise features (SAML, Auditing)"
|
||||
perSeatLicensing = "Per-seat licensing"
|
||||
saveWithAnnualBilling = "Save with annual billing"
|
||||
selfHosted = "Self-hosted"
|
||||
selfHostedOnInfrastructure = "Self-hosted on your infrastructure"
|
||||
ssoOAuth = "SSO (OAuth2/OIDC)"
|
||||
unlimitedUsers = "Unlimited users"
|
||||
upToFiveUsers = "Up to 5 users"
|
||||
usageTrackingPrometheus = "Usage tracking & Prometheus"
|
||||
|
||||
[plan.licenseWarning]
|
||||
body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly."
|
||||
cta = "See plans"
|
||||
@@ -6389,6 +6539,7 @@ allSessions = "All Sessions"
|
||||
allTools = "Tools"
|
||||
automate = "Automate"
|
||||
back = "Back"
|
||||
backToAllTools = "Back to all tools"
|
||||
certSign = "Certificate Sign"
|
||||
completedSessions = "Completed Sessions"
|
||||
completedTab = "Completed"
|
||||
@@ -7060,6 +7211,9 @@ bullet3 = "Critical for approval workflows and legal chains of custody"
|
||||
description = "The order you specify when creating the session determines who signs first."
|
||||
title = "Signature Order"
|
||||
|
||||
[settings]
|
||||
close = "Close"
|
||||
|
||||
[settings.configuration]
|
||||
advanced = "Advanced"
|
||||
database = "Database"
|
||||
@@ -7542,6 +7696,7 @@ sharedDescription = "All users can see and use these signatures."
|
||||
sharedHeading = "Shared Signatures"
|
||||
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
|
||||
tempStorageTitle = "Temporary browser storage"
|
||||
use = "Use signature"
|
||||
|
||||
[sign.saved.status]
|
||||
saved = "Saved"
|
||||
@@ -8122,9 +8277,18 @@ center = "Center"
|
||||
left = "Left"
|
||||
right = "Right"
|
||||
|
||||
[textInput]
|
||||
clear = "Clear input"
|
||||
|
||||
[theme]
|
||||
toggle = "Toggle Theme"
|
||||
|
||||
[time.relative]
|
||||
daysAgo = "{{count}}d ago"
|
||||
hoursAgo = "{{count}}h ago"
|
||||
justNow = "just now"
|
||||
minutesAgo = "{{count}}m ago"
|
||||
|
||||
[timestampPdf]
|
||||
completed = "PDF timestamped successfully"
|
||||
desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server."
|
||||
@@ -8151,11 +8315,9 @@ label = "Select a TSA server"
|
||||
[timestampPdf.steps]
|
||||
settings = "Settings"
|
||||
|
||||
[time.relative]
|
||||
daysAgo = "{{count}}d ago"
|
||||
hoursAgo = "{{count}}h ago"
|
||||
justNow = "just now"
|
||||
minutesAgo = "{{count}}m ago"
|
||||
[toast]
|
||||
dismiss = "Dismiss"
|
||||
toggleDetails = "Toggle details"
|
||||
|
||||
[tool]
|
||||
endpointUnavailable = "This tool is unavailable on your server."
|
||||
@@ -8243,6 +8405,9 @@ verification = "Verification"
|
||||
noSearchResults = "No tools found"
|
||||
noTools = "No tools available"
|
||||
|
||||
[tooltip]
|
||||
close = "Close tooltip"
|
||||
|
||||
[unlockPDFForms]
|
||||
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
|
||||
filenamePrefix = "unlocked_forms"
|
||||
@@ -8269,6 +8434,7 @@ breakingChangesDefault = "This version contains breaking changes."
|
||||
breakingChangesDetected = "Breaking Changes Detected"
|
||||
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
|
||||
close = "Close"
|
||||
closeModal = "Close update modal"
|
||||
current = "Current Version"
|
||||
defaultRecommendation = "This update contains important fixes and improvements."
|
||||
downloadLatest = "Download Latest"
|
||||
@@ -8477,12 +8643,16 @@ enableDarkFilter = "Enable Dark Filter"
|
||||
enableSepiaFilter = "Enable Sepia Filter"
|
||||
firstPage = "First Page"
|
||||
lastPage = "Last Page"
|
||||
moreOptions = "More"
|
||||
nextPage = "Next Page"
|
||||
onlyPdfSupported = "This file format is not supported for preview."
|
||||
pageNavigation = "Page navigation"
|
||||
previousPage = "Previous Page"
|
||||
resetZoom = "Reset zoom"
|
||||
singlePageView = "Single Page View"
|
||||
unknownFile = "Unknown file"
|
||||
view = "View"
|
||||
zoom = "Zoom"
|
||||
zoomIn = "Zoom In"
|
||||
zoomOut = "Zoom Out"
|
||||
|
||||
@@ -8520,6 +8690,9 @@ typeInsertText = "Insert Text"
|
||||
typeReplaceText = "Replace Text"
|
||||
viewComment = "View comment"
|
||||
|
||||
[viewer.error]
|
||||
noFileProvided = "Error: No file provided to viewer"
|
||||
|
||||
[viewer.formBar]
|
||||
apply = "Apply Changes"
|
||||
dismiss = "Dismiss"
|
||||
@@ -8529,6 +8702,9 @@ title = "Form Fields"
|
||||
unsavedBadge = "Unsaved"
|
||||
unsavedDesc = "You have unsaved changes"
|
||||
|
||||
[viewer.link]
|
||||
delete = "Delete link"
|
||||
|
||||
[viewer.nonPdf]
|
||||
columnDefault = "Column {{index}}"
|
||||
convertToPdf = "Convert to PDF"
|
||||
@@ -8544,6 +8720,19 @@ renderMarkdown = "Render markdown"
|
||||
sortedBy = "Sorted by: {{column}}"
|
||||
textStats = "{{lines}} lines · {{size}}"
|
||||
|
||||
[viewer.redaction]
|
||||
removeMark = "Remove this mark"
|
||||
|
||||
[viewer.search]
|
||||
clear = "Clear search"
|
||||
close = "Close search"
|
||||
next = "Next result"
|
||||
previous = "Previous result"
|
||||
resultsOf = "of {{total}}"
|
||||
|
||||
[viewer.signature]
|
||||
delete = "Delete signature"
|
||||
|
||||
[viewPdf]
|
||||
header = "View PDF"
|
||||
tags = "view,read,annotate,text,image,highlight,edit"
|
||||
@@ -8792,6 +8981,37 @@ toggleSidebar = "Toggle Sidebar"
|
||||
toggleTheme = "Toggle Theme"
|
||||
viewer = "Viewer"
|
||||
|
||||
[workflow.participant]
|
||||
accessExpired = "Your access to this document has expired."
|
||||
certificateFile = "Certificate File"
|
||||
certificateFilePlaceholder = "Select certificate file"
|
||||
certificatePassword = "Certificate Password"
|
||||
certificateType = "Certificate Type"
|
||||
certTypeJks = "JKS Keystore"
|
||||
certTypeP12 = "P12/PKCS12 Certificate"
|
||||
certTypeServer = "Server Certificate (if available)"
|
||||
completedDeclined = "You have declined this document."
|
||||
completedSigned = "You have signed this document."
|
||||
decline = "Decline"
|
||||
downloadDocument = "Download Document"
|
||||
dueDate = "Due Date: {{date}}"
|
||||
errorTitle = "Error"
|
||||
from = "From: {{name}}"
|
||||
loadingSession = "Loading session..."
|
||||
location = "Location"
|
||||
locationPlaceholder = "e.g., San Francisco, CA"
|
||||
pageNumber = "Page Number (optional)"
|
||||
reason = "Reason"
|
||||
reasonPlaceholder = "e.g., Document approval"
|
||||
sessionNotFound = "Session not found or access denied."
|
||||
signDocument = "Sign Document"
|
||||
statusDeclined = "Declined"
|
||||
statusNotified = "Notified"
|
||||
statusPending = "Pending"
|
||||
statusSigned = "Signed"
|
||||
statusViewed = "Viewed"
|
||||
submitSignature = "Submit Signature"
|
||||
|
||||
[workspace]
|
||||
title = "Workspace"
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"dragDropEnabled": false,
|
||||
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ColorizeIcon from "@mui/icons-material/Colorize";
|
||||
|
||||
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
||||
@@ -33,6 +34,7 @@ export function ColorControl({
|
||||
label,
|
||||
disabled = false,
|
||||
}: ColorControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
// Buffer the colour locally so the picker stays responsive during drag.
|
||||
// Only propagate to the parent (which triggers expensive annotation updates)
|
||||
@@ -111,7 +113,9 @@ export function ColorControl({
|
||||
/>
|
||||
{supportsEyeDropper && (
|
||||
<Group justify="flex-end">
|
||||
<Tooltip label="Pick colour from screen">
|
||||
<Tooltip
|
||||
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
||||
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
||||
|
||||
@@ -12,6 +13,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
onImageChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [, setImageData] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = async (file: File | null) => {
|
||||
@@ -57,9 +59,12 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
<ImageUploader
|
||||
onImageChange={handleImageUpload}
|
||||
disabled={disabled}
|
||||
label="Upload Image"
|
||||
placeholder="Select image file"
|
||||
hint="Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
|
||||
label={t("image.upload.label", "Upload Image")}
|
||||
placeholder={t("image.upload.placeholder", "Select image file")}
|
||||
hint={t(
|
||||
"image.upload.hint",
|
||||
"Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility.",
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
|
||||
@@ -104,44 +104,44 @@ const AddFileCard = ({
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "2rem",
|
||||
height: "38px",
|
||||
paddingLeft: isUploadHover ? 0 : "1rem",
|
||||
paddingRight: isUploadHover ? 0 : "1rem",
|
||||
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
|
||||
minWidth: isUploadHover ? "58px" : undefined,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="add"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
className="text-[var(--accent-interactive)]"
|
||||
/>
|
||||
{!isUploadHover && (
|
||||
{!isUploadHover && (
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "2rem",
|
||||
height: "38px",
|
||||
paddingLeft: "1rem",
|
||||
paddingRight: "1rem",
|
||||
width: "calc(100% - 58px - 0.6rem)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="add"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
className="text-[var(--accent-interactive)]"
|
||||
/>
|
||||
<span>{t("landing.addFiles", "Add Files")}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
aria-label={t("addFileCard.upload", "Upload")}
|
||||
title={terminology.uploadFromComputer}
|
||||
style={{
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "1rem",
|
||||
height: "38px",
|
||||
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
|
||||
width: isUploadHover ? "100%" : "58px",
|
||||
minWidth: "58px",
|
||||
paddingLeft: isUploadHover ? "1rem" : 0,
|
||||
paddingRight: isUploadHover ? "1rem" : 0,
|
||||
@@ -149,6 +149,7 @@ const AddFileCard = ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
@@ -157,10 +158,18 @@ const AddFileCard = ({
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--accent-interactive)" }}
|
||||
style={{ color: "var(--accent-interactive)", flexShrink: 0 }}
|
||||
/>
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: ".5rem" }}>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: ".5rem",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -77,7 +77,7 @@ const EmptyFilesState: React.FC = () => {
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
aria-label={t("emptyFilesState.upload", "Upload")}
|
||||
style={{
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
color: "var(--landing-button-color)",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
@@ -31,6 +32,7 @@ interface MFASetupSlideProps {
|
||||
}
|
||||
|
||||
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
|
||||
null,
|
||||
);
|
||||
@@ -179,7 +181,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
{mfaLoading && !isReady && (
|
||||
<Group gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm">Generating your QR code…</Text>
|
||||
<Text size="sm">
|
||||
{t("onboarding.mfa.qrCodeLoading", "Generating your QR code…")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -189,7 +193,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
id="mfa-setup-code"
|
||||
label="Authentication code"
|
||||
label={t(
|
||||
"onboarding.mfa.authenticationCode",
|
||||
"Authentication code",
|
||||
)}
|
||||
placeholder="123456"
|
||||
value={mfaSetupCode}
|
||||
onChange={(event) =>
|
||||
|
||||
@@ -37,11 +37,20 @@ export default function SecurityCheckSlide({
|
||||
</div>
|
||||
|
||||
<Select
|
||||
placeholder="Confirm your role"
|
||||
placeholder={i18n.t(
|
||||
"onboarding.securityCheck.rolePlaceholder",
|
||||
"Confirm your role",
|
||||
)}
|
||||
value={selectedRole}
|
||||
data={[
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "user", label: "User" },
|
||||
{
|
||||
value: "admin",
|
||||
label: i18n.t("onboarding.securityCheck.roleAdmin", "Admin"),
|
||||
},
|
||||
{
|
||||
value: "user",
|
||||
label: i18n.t("onboarding.securityCheck.roleUser", "User"),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
onRoleSelect((value as "admin" | "user") ?? null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import {
|
||||
useNavigationGuard,
|
||||
@@ -33,6 +34,7 @@ export interface PageEditorProps {
|
||||
}
|
||||
|
||||
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use split contexts to prevent re-renders
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
@@ -688,9 +690,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
<Text size="lg" c="dimmed">
|
||||
📄
|
||||
</Text>
|
||||
<Text c="dimmed">No PDF files loaded</Text>
|
||||
<Text c="dimmed">
|
||||
{t("pageEditor.emptyState.title", "No PDF files loaded")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Add files to start editing pages
|
||||
{t(
|
||||
"pageEditor.emptyState.body",
|
||||
"Add files to start editing pages",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -6,6 +6,7 @@ import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PageEditorControlsProps {
|
||||
// Close/Reset functions
|
||||
@@ -51,6 +52,7 @@ const PageEditorControls = ({
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
}: PageEditorControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Calculate split tooltip text using smart toggle logic
|
||||
const getSplitTooltip = () => {
|
||||
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
|
||||
@@ -131,7 +133,7 @@ const PageEditorControls = ({
|
||||
}}
|
||||
>
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<Tooltip label={t("pageEditor.toolbar.undo", "Undo")}>
|
||||
<ActionIcon
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
@@ -145,7 +147,7 @@ const PageEditorControls = ({
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<Tooltip label={t("pageEditor.toolbar.redo", "Redo")}>
|
||||
<ActionIcon
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
@@ -170,7 +172,9 @@ const PageEditorControls = ({
|
||||
/>
|
||||
|
||||
{/* Page Operations */}
|
||||
<Tooltip label="Rotate Selected Left">
|
||||
<Tooltip
|
||||
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate("left")}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
@@ -187,7 +191,9 @@ const PageEditorControls = ({
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Rotate Selected Right">
|
||||
<Tooltip
|
||||
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate("right")}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
@@ -204,7 +210,7 @@ const PageEditorControls = ({
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete Selected">
|
||||
<Tooltip label={t("pageEditor.toolbar.delete", "Delete Selected")}>
|
||||
<ActionIcon
|
||||
onClick={onDelete}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
|
||||
+20
-5
@@ -26,7 +26,10 @@ const OperatorsSection = ({
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("and")}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Combine selections (both conditions must be true)"
|
||||
title={t(
|
||||
"bulkSelection.operators.and.title",
|
||||
"Combine selections (both conditions must be true)",
|
||||
)}
|
||||
>
|
||||
<Text size="xs" fw={500}>
|
||||
and
|
||||
@@ -38,7 +41,10 @@ const OperatorsSection = ({
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("or")}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Add to selection (either condition can be true)"
|
||||
title={t(
|
||||
"bulkSelection.operators.or.title",
|
||||
"Add to selection (either condition can be true)",
|
||||
)}
|
||||
>
|
||||
<Text size="xs" fw={500}>
|
||||
or
|
||||
@@ -50,7 +56,10 @@ const OperatorsSection = ({
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("not")}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Exclude from selection"
|
||||
title={t(
|
||||
"bulkSelection.operators.not.title",
|
||||
"Exclude from selection",
|
||||
)}
|
||||
>
|
||||
<Text size="xs" fw={500}>
|
||||
not
|
||||
@@ -64,7 +73,10 @@ const OperatorsSection = ({
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("even")}
|
||||
title="Select all even-numbered pages (2, 4, 6, 8...)"
|
||||
title={t(
|
||||
"bulkSelection.operators.even.title",
|
||||
"Select all even-numbered pages (2, 4, 6, 8...)",
|
||||
)}
|
||||
>
|
||||
<Text size="xs" fw={500}>
|
||||
even
|
||||
@@ -75,7 +87,10 @@ const OperatorsSection = ({
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("odd")}
|
||||
title="Select all odd-numbered pages (1, 3, 5, 7...)"
|
||||
title={t(
|
||||
"bulkSelection.operators.odd.title",
|
||||
"Select all odd-numbered pages (1, 3, 5, 7...)",
|
||||
)}
|
||||
>
|
||||
<Text size="xs" fw={500}>
|
||||
odd
|
||||
|
||||
+3
-1
@@ -45,7 +45,9 @@ const PageSelectionInput = ({
|
||||
height="1rem"
|
||||
style={{ color: "var(--text-instruction)" }}
|
||||
/>
|
||||
<Text>Page Selection</Text>
|
||||
<Text>
|
||||
{t("bulkSelection.pageSelection.title", "Page Selection")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
{typeof advancedOpened === "boolean" && (
|
||||
|
||||
@@ -106,6 +106,15 @@
|
||||
margin-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.modal-nav-item-badge {
|
||||
display: none;
|
||||
}
|
||||
.modal-nav-item:hover .modal-nav-item-badge,
|
||||
.modal-nav-item:focus-within .modal-nav-item-badge,
|
||||
.modal-nav-item.active .modal-nav-item-badge {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, {
|
||||
} from "react";
|
||||
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
@@ -29,11 +30,26 @@ interface AppConfigModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
const match = pathname.match(/\/settings\/([^/]+)/);
|
||||
if (match && match[1]) {
|
||||
const section = match[1] as NavKey;
|
||||
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
}) => {
|
||||
const [active, setActive] = useState<NavKey>("general");
|
||||
const { t } = useTranslation();
|
||||
// Initialize from the URL so a deep link (`/settings/people`) lands on the
|
||||
// right tab without a one-frame "general" flicker.
|
||||
const [active, setActive] = useState<NavKey>(
|
||||
() => getSectionFromPath(window.location.pathname) ?? "general",
|
||||
);
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -42,17 +58,10 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const { confirmIfDirty } = useUnsavedChanges();
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
const match = pathname.match(/\/settings\/([^/]+)/);
|
||||
if (match && match[1]) {
|
||||
const section = match[1] as NavKey;
|
||||
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Sync active state with URL path
|
||||
// Sync active state with URL path. Runs on open, on external URL changes,
|
||||
// and on the redirect path below - NOT on intra-modal tab clicks, because
|
||||
// those update the URL via `history.replaceState` directly and never push
|
||||
// a new React Router location.
|
||||
useEffect(() => {
|
||||
const section = getSectionFromPath(location.pathname);
|
||||
if (opened && section) {
|
||||
@@ -74,18 +83,41 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Handle custom events for backwards compatibility.
|
||||
// Use replace when already on /settings/* so external tab-switches
|
||||
// don't pile up history entries that would break close-by-back.
|
||||
// Switch tab without forcing every `useLocation()` subscriber (HomePage and
|
||||
// its FileSidebar/Workbench/RightSidebar/FileManager tree) to re-render.
|
||||
//
|
||||
// First entry into /settings/* still goes through React Router so HomePage's
|
||||
// location-watching effect opens the modal and pushes a real history entry -
|
||||
// so the back button can close us. Subsequent tab clicks bypass React Router
|
||||
// and mutate the URL bar via `history.replaceState`. The browser sees the
|
||||
// URL update (deep-link / refresh still work) but React Router never fires a
|
||||
// location change, so the layer behind the Mantine overlay never repaints
|
||||
// and the backdrop-filter blur stops flashing.
|
||||
const switchSection = useCallback(
|
||||
(key: NavKey) => {
|
||||
setActive(key);
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
if (alreadyInSettings) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`/settings/${key}`,
|
||||
);
|
||||
} else {
|
||||
navigate(`/settings/${key}`);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// Backwards-compat: external `appConfig:navigate` events route through the
|
||||
// same switchSection path so they get the no-flash treatment too.
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
navigate(`/settings/${detail.key}`, {
|
||||
replace: alreadyInSettings,
|
||||
});
|
||||
switchSection(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
@@ -94,7 +126,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
"appConfig:navigate",
|
||||
handler as EventListener,
|
||||
);
|
||||
}, [navigate]);
|
||||
}, [switchSection]);
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
@@ -163,23 +195,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
async (key: NavKey) => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
|
||||
setActive(key);
|
||||
// First in-modal nav (when current path isn't `/settings/*` yet) must
|
||||
// PUSH so the originating page stays in history and close-by-back can
|
||||
// return to it. Subsequent tab switches REPLACE so they don't pile up
|
||||
// history entries that handleClose's navigate(-1) can't unwind.
|
||||
//
|
||||
// Read window.location.pathname directly (not the React hook's
|
||||
// location.pathname) so rapid successive clicks pick up the URL
|
||||
// change from the previous click immediately. The hook snapshot is
|
||||
// stale between render cycles - relying on it lets a second click
|
||||
// PUSH again before React re-renders, producing a history pile-up.
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
navigate(`/settings/${key}`, { replace: alreadyInSettings });
|
||||
switchSection(key);
|
||||
},
|
||||
[confirmIfDirty, navigate],
|
||||
[confirmIfDirty, switchSection],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -240,7 +258,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
handleNavigation(item.key);
|
||||
}
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||
className={`modal-nav-item ${isActive ? "active" : ""} ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: isActive
|
||||
? colors.navItemActiveBg
|
||||
@@ -257,8 +275,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
style={{ color }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
<Group
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
style={{ color, minWidth: 0, flex: 1 }}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.badge && (
|
||||
@@ -266,6 +295,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={item.badgeColor ?? "orange"}
|
||||
className="modal-nav-item-badge"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{item.badge}
|
||||
@@ -332,7 +362,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
ref={closeButtonRef}
|
||||
variant="subtle"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("settings.close", "Close")}
|
||||
data-autofocus
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Group,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
@@ -71,6 +72,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
withinPortal = true,
|
||||
zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const isMultiValue = Array.isArray(value);
|
||||
@@ -184,7 +186,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Search..."
|
||||
placeholder={t("dropdownList.searchPlaceholder", "Search...")}
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
leftSection={<SearchIcon style={{ fontSize: "1rem" }} />}
|
||||
|
||||
@@ -98,8 +98,11 @@ export default function EditableSecretField({
|
||||
variant="light"
|
||||
onClick={handleEdit}
|
||||
disabled={disabled}
|
||||
title="Edit"
|
||||
aria-label="Edit secret value"
|
||||
title={t("editableSecretField.edit", "Edit")}
|
||||
aria-label={t(
|
||||
"editableSecretField.editSecretValue",
|
||||
"Edit secret value",
|
||||
)}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -111,7 +111,7 @@ const FileCard = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onView && (
|
||||
<Tooltip label="View in Viewer">
|
||||
<Tooltip label={t("fileCard.viewInViewer", "View in Viewer")}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
@@ -126,7 +126,9 @@ const FileCard = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Tooltip label="Open in File Editor">
|
||||
<Tooltip
|
||||
label={t("fileCard.openInFileEditor", "Open in File Editor")}
|
||||
>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
@@ -28,6 +29,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu.Target>
|
||||
@@ -95,7 +97,10 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
</Text>
|
||||
)}
|
||||
{onFileRemove && (
|
||||
<Tooltip label="Close file" withArrow>
|
||||
<Tooltip
|
||||
label={t("fileDropdownMenu.closeFile", "Close file")}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
component="div"
|
||||
size="xs"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import type { FileId } from "@app/types/file";
|
||||
@@ -141,6 +142,7 @@ export function FileItem({
|
||||
onClick,
|
||||
onEyeClick,
|
||||
}: FileItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ext = getFileExtension(name);
|
||||
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
|
||||
const typeLabel = ext ? ext.toUpperCase() : "File";
|
||||
@@ -215,7 +217,11 @@ export function FileItem({
|
||||
}}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"}
|
||||
aria-label={
|
||||
isViewedInViewer
|
||||
? t("fileSidebar.fileItem.closeViewer", "Close viewer")
|
||||
: t("fileSidebar.fileItem.openInViewer", "Open in viewer")
|
||||
}
|
||||
>
|
||||
<VisibilityOutlinedIcon
|
||||
className="file-sidebar-eye-open"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
type InfoBannerTone = "info" | "warning";
|
||||
@@ -85,6 +86,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
@@ -192,7 +194,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
color={closeIconColor ? undefined : "gray"}
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss"
|
||||
aria-label={t("infoBanner.dismiss", "Dismiss")}
|
||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||
>
|
||||
<LocalIcon
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import styles from "@app/components/shared/textInput/TextInput.module.css";
|
||||
|
||||
@@ -63,6 +64,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClear = () => {
|
||||
if (onClear) {
|
||||
onClear();
|
||||
@@ -112,7 +114,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={handleClear}
|
||||
aria-label="Clear input"
|
||||
aria-label={t("textInput.clear", "Clear input")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
|
||||
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
|
||||
@@ -68,6 +69,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
manualCloseOnly = false,
|
||||
showCloseButton = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const { tooltipLogo } = useLogoAssets();
|
||||
@@ -401,8 +403,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
setIsPinned(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Close tooltip"
|
||||
aria-label="Close tooltip"
|
||||
title={t("tooltip.close", "Close tooltip")}
|
||||
aria-label={t("tooltip.close", "Close tooltip")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
|
||||
@@ -259,7 +259,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
|
||||
onClick={onClose}
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
aria-label="Close update modal"
|
||||
aria-label={t("update.closeModal", "Close update modal")}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ActionIcon, Slider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
@@ -9,6 +10,7 @@ import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
|
||||
*/
|
||||
export function ViewerInlineControls() {
|
||||
const { t } = useTranslation();
|
||||
const { workbench } = useNavigationState();
|
||||
const viewer = useViewer();
|
||||
|
||||
@@ -39,7 +41,7 @@ export function ViewerInlineControls() {
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => viewer.zoomActions.zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
aria-label={t("viewer.zoomOut", "Zoom out")}
|
||||
>
|
||||
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
@@ -68,7 +70,7 @@ export function ViewerInlineControls() {
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => viewer.zoomActions.zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
aria-label={t("viewer.zoomIn", "Zoom in")}
|
||||
>
|
||||
<ZoomInIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -97,9 +97,16 @@
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (theme / language / download) ---- */
|
||||
|
||||
@@ -492,7 +492,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.theme", "Theme")}
|
||||
</Text>
|
||||
@@ -527,7 +527,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.language", "Language")}
|
||||
</Text>
|
||||
@@ -552,7 +552,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.defaultToolPickerMode",
|
||||
@@ -590,7 +590,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.defaultStartupView",
|
||||
@@ -632,7 +632,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.defaultViewerZoom", "Default reader zoom")}
|
||||
</Text>
|
||||
@@ -687,7 +687,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableTools",
|
||||
@@ -718,7 +718,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableConversions",
|
||||
@@ -759,7 +759,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.autoUnzip", "Auto-unzip API responses")}
|
||||
</Text>
|
||||
@@ -796,7 +796,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.autoUnzipFileLimit",
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function ProviderCard({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{field.label}
|
||||
</Text>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Divider } from "@mantine/core";
|
||||
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
useNavigationState,
|
||||
@@ -37,6 +38,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
|
||||
setActiveButton,
|
||||
tooltipPosition = "right",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } =
|
||||
useToolWorkflow();
|
||||
const { hasUnsavedChanges } = useNavigationState();
|
||||
@@ -158,7 +160,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
|
||||
<div className="current-tool-content">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Tooltip
|
||||
content={isBackHover ? "Back to all tools" : indicatorTool.name}
|
||||
content={
|
||||
isBackHover
|
||||
? t("quickAccess.backToAllTools", "Back to all tools")
|
||||
: indicatorTool.name
|
||||
}
|
||||
position={tooltipPosition}
|
||||
arrow
|
||||
maxWidth={140}
|
||||
@@ -183,7 +189,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
|
||||
onMouseEnter={() => setIsBackHover(true)}
|
||||
onMouseLeave={() => setIsBackHover(false)}
|
||||
aria-label={
|
||||
isBackHover ? "Back to all tools" : indicatorTool.name
|
||||
isBackHover
|
||||
? t("quickAccess.backToAllTools", "Back to all tools")
|
||||
: indicatorTool.name
|
||||
}
|
||||
style={{
|
||||
backgroundColor: isBackHover
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@app/components/toast/ToastContext";
|
||||
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
@@ -38,6 +39,7 @@ function getDefaultIconName(t: ToastInstance): string {
|
||||
}
|
||||
|
||||
export default function ToastRenderer() {
|
||||
const { t: translate } = useTranslation();
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>(
|
||||
@@ -88,7 +90,10 @@ export default function ToastRenderer() {
|
||||
<div className="toast-controls">
|
||||
{t.expandable && (
|
||||
<button
|
||||
aria-label="Toggle details"
|
||||
aria-label={translate(
|
||||
"toast.toggleDetails",
|
||||
"Toggle details",
|
||||
)}
|
||||
onClick={() => {
|
||||
const evt = new CustomEvent("toast:toggle", {
|
||||
detail: { id: t.id },
|
||||
@@ -101,7 +106,7 @@ export default function ToastRenderer() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
aria-label={translate("toast.dismiss", "Dismiss")}
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="toast-button"
|
||||
>
|
||||
|
||||
@@ -321,7 +321,7 @@ export default function AutomationEntry({
|
||||
return shouldShowTooltip ? (
|
||||
<Tooltip
|
||||
content={createTooltipContent()}
|
||||
position="right"
|
||||
position="left"
|
||||
arrow={true}
|
||||
delay={500}
|
||||
>
|
||||
|
||||
@@ -99,11 +99,14 @@ const CompressSettings = ({
|
||||
{parameters.compressionMethod === "filesize" && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
Desired File Size
|
||||
{t("compress.settings.desiredSize", "Desired File Size")}
|
||||
</Text>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
||||
<NumberInput
|
||||
placeholder="Enter size"
|
||||
placeholder={t(
|
||||
"compress.settings.desiredSizePlaceholder",
|
||||
"Enter size",
|
||||
)}
|
||||
value={parameters.fileSizeValue}
|
||||
onChange={(value) =>
|
||||
onParameterChange("fileSizeValue", value?.toString() || "")
|
||||
|
||||
@@ -139,7 +139,9 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Loader size="xs" />
|
||||
<Text size="sm">Loading available languages...</Text>
|
||||
<Text size="sm">
|
||||
{t("ocr.languagePicker.loading", "Loading available languages...")}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user