mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Change AI engine to execute tools in Java instead of on frontend (#6116)
# Description of Changes Redesign AI engine so that it autogenerates the `tool_models.py` file from the OpenAPI spec so the Python has access to the Java API parameters and the full list of Java tools that it can run. CI ensures that whenever someone modifies a tool endpoint that the AI enigne tool models get updated as well (the dev gets told to run `task engine:tool-models`). There's loads of advantages to having the Java be the one that actually executes the tools, rather than the frontend as it was previously set up to theoretically use: - The AI gets much better descriptions of the params from the API docs - It'll be usable headless in the future so a Java daemon could run to execute ops on files in a folder without the need for the UI to run - The Java already has all the logic it needs to execute the tools - We don't need to parse the TypeScript to find the API (which is hard because the TS wasn't designed to be computer-read to extract the API) I've also hooked up the prototype frontend to ensure it's working properly, and have built it in a way that all the tool names can be translated properly, which was always an issue with previous prototypes of this. --------- Co-authored-by: Anthony Stirling <[email protected]> Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
EthanHealy01
parent
cc9650e7a3
commit
e5767ed58b
@@ -0,0 +1,184 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RequestCallback;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import jakarta.servlet.ServletContext;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Dispatches HTTP POST requests to internal Stirling API endpoints via loopback. Used by
|
||||
* PipelineProcessor and AiWorkflowService to execute tool operations programmatically without
|
||||
* leaving the JVM network stack.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class InternalApiClient {
|
||||
|
||||
// Allowlist for internal dispatch. Matches a fixed namespace prefix,
|
||||
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
|
||||
// character that could alter the resolved endpoint on the local Spring server.
|
||||
private static final Pattern ALLOWED_ENDPOINT_PATH =
|
||||
Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final Environment environment;
|
||||
|
||||
public InternalApiClient(
|
||||
ServletContext servletContext,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
TempFileManager tempFileManager,
|
||||
Environment environment) {
|
||||
this.servletContext = servletContext;
|
||||
this.userService = userService;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST to an internal API endpoint. The endpointPath must start with one of the allowed
|
||||
* prefixes (e.g. {@code /api/v1/misc/compress-pdf}).
|
||||
*
|
||||
* @param endpointPath API path (e.g. {@code /api/v1/general/rotate-pdf})
|
||||
* @param body multipart form body (fileInput + parameters)
|
||||
* @return response with the result file as a {@link TempFileResource} body
|
||||
*/
|
||||
public ResponseEntity<Resource> post(String endpointPath, MultiValueMap<String, Object> body) {
|
||||
validateUrl(endpointPath);
|
||||
String url = getBaseUrl() + endpointPath;
|
||||
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
String apiKey = getApiKeyForUser();
|
||||
if (apiKey != null && !apiKey.isEmpty()) {
|
||||
headers.add("X-API-KEY", apiKey);
|
||||
}
|
||||
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
||||
|
||||
return restTemplate.execute(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
requestCallback,
|
||||
response -> {
|
||||
try {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("internal-api");
|
||||
Files.copy(
|
||||
response.getBody(),
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename = extractFilename(response.getHeaders());
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the filename from a response's {@code Content-Disposition} header. Returns {@code
|
||||
* null} if the header is missing or has no filename.
|
||||
*/
|
||||
private static String extractFilename(HttpHeaders headers) {
|
||||
String contentDisposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
if (contentDisposition == null || contentDisposition.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
for (String part : contentDisposition.split(";")) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.startsWith("filename")) {
|
||||
String[] kv = trimmed.split("=", 2);
|
||||
if (kv.length != 2) {
|
||||
continue;
|
||||
}
|
||||
String value = kv[1].trim().replace("\"", "");
|
||||
return URLDecoder.decode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getBaseUrl() {
|
||||
// Resolve the port lazily so desktop mode (server.port=0, OS-assigned) dispatches to the
|
||||
// actual bound port. Spring publishes local.server.port once the web server is up; fall
|
||||
// back to the configured server.port for early calls (tests, non-web contexts).
|
||||
String port = environment.getProperty("local.server.port");
|
||||
if (port == null) {
|
||||
port = environment.getProperty("server.port", "8080");
|
||||
}
|
||||
return "http://localhost:" + port + servletContext.getContextPath();
|
||||
}
|
||||
|
||||
private String getApiKeyForUser() {
|
||||
if (userService == null) return "";
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username != null && !username.equals("anonymousUser")) {
|
||||
return userService.getApiKeyForUser(username);
|
||||
}
|
||||
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
|
||||
}
|
||||
|
||||
private void validateUrl(String endpointPath) {
|
||||
if (endpointPath == null || !ALLOWED_ENDPOINT_PATH.matcher(endpointPath).matches()) {
|
||||
log.warn("Blocked internal API request to disallowed path: {}", endpointPath);
|
||||
throw new SecurityException(
|
||||
"Internal API dispatch not permitted for endpoint: " + endpointPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link FileSystemResource} that holds a reference to its backing {@link TempFile}.
|
||||
*
|
||||
* <p>If a display filename is supplied (typically parsed from the upstream response's {@code
|
||||
* Content-Disposition} header), it is returned from {@link #getFilename()} instead of the
|
||||
* underlying temp file's path-based name.
|
||||
*/
|
||||
public static class TempFileResource extends FileSystemResource {
|
||||
private final TempFile tempFile;
|
||||
private final String displayFilename;
|
||||
|
||||
public TempFileResource(TempFile tempFile) {
|
||||
this(tempFile, null);
|
||||
}
|
||||
|
||||
public TempFileResource(TempFile tempFile, String displayFilename) {
|
||||
super(tempFile.getFile());
|
||||
this.tempFile = tempFile;
|
||||
this.displayFilename = displayFilename;
|
||||
}
|
||||
|
||||
public TempFile getTempFile() {
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return displayFilename != null ? displayFilename : super.getFilename();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
/** 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 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
|
||||
* such as {@code Output:ZIP-PDF} or {@code Output:IMAGE/ZIP}.
|
||||
*
|
||||
* <p>Returns false for a bare {@code Output:ZIP} (e.g. {@code get-attachments}), where the
|
||||
* archive itself is the deliverable and should be kept packed.
|
||||
*/
|
||||
boolean shouldUnpackZipResponse(String operationPath);
|
||||
}
|
||||
@@ -538,9 +538,9 @@ public final class RegexPatternUtils {
|
||||
getPattern("[^a-zA-Z0-9 ]"); // Input sanitization
|
||||
getPattern("[^a-zA-Z0-9]"); // Filename sanitization
|
||||
// API doc patterns
|
||||
getPattern("Output:(\\w+)"); // precompiled single-escaped for runtime regex \w
|
||||
getPattern("Input:(\\w+)");
|
||||
getPattern("Type:(\\w+)");
|
||||
getPattern("Output:\\s*(\\w+)");
|
||||
getPattern("Input:\\s*(\\w+)");
|
||||
getPattern("Type:\\s*(\\w+)");
|
||||
log.debug("Pre-compiled {} common regex patterns", patternCache.size());
|
||||
}
|
||||
|
||||
@@ -552,19 +552,19 @@ public final class RegexPatternUtils {
|
||||
|
||||
/* Pattern for matching Output:<TYPE> in API descriptions */
|
||||
public Pattern getApiDocOutputTypePattern() {
|
||||
return getPattern("Output:(\\w+)");
|
||||
return getPattern("Output:\\s*(\\w+)");
|
||||
}
|
||||
|
||||
/* Pattern for matching Input:<TYPE> in API descriptions */
|
||||
public Pattern getApiDocInputTypePattern() {
|
||||
return getPattern("Input:(\\w+)");
|
||||
return getPattern("Input:\\s*(\\w+)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern for matching Type:<CODE> in API descriptions
|
||||
*/
|
||||
public Pattern getApiDocTypePattern() {
|
||||
return getPattern("Type:(\\w+)");
|
||||
return getPattern("Type:\\s*(\\w+)");
|
||||
}
|
||||
|
||||
/* Pattern for validating file extensions (2-4 alphanumeric, case-insensitive) */
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Helpers for detecting and extracting ZIP-formatted responses returned from Stirling API
|
||||
* endpoints. Shared between {@code PipelineProcessor} and {@code AiWorkflowService} so both callers
|
||||
* unpack ZIPs consistently (hardened against zip-slip, depth-limited, backed by managed temp
|
||||
* files).
|
||||
*/
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class ZipExtractionUtils {
|
||||
|
||||
private static final int MAX_UNZIP_DEPTH = 10;
|
||||
private static final byte[] ZIP_MAGIC = {0x50, 0x4B, 0x03, 0x04};
|
||||
|
||||
/**
|
||||
* Returns true if the resource starts with the standard ZIP magic bytes. CBZ files are
|
||||
* explicitly treated as non-ZIP.
|
||||
*/
|
||||
public static boolean isZip(Resource data) throws IOException {
|
||||
return isZip(data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the resource starts with the standard ZIP magic bytes. Files named with the
|
||||
* {@code .cbz} extension are excluded (handled separately by the comic viewer).
|
||||
*/
|
||||
public static boolean isZip(Resource data, String filename) throws IOException {
|
||||
if (data == null || data.contentLength() < ZIP_MAGIC.length) {
|
||||
return false;
|
||||
}
|
||||
if (filename != null && filename.toLowerCase().endsWith(".cbz")) {
|
||||
return false;
|
||||
}
|
||||
try (InputStream is = data.getInputStream()) {
|
||||
byte[] header = new byte[ZIP_MAGIC.length];
|
||||
if (is.read(header) < ZIP_MAGIC.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < ZIP_MAGIC.length; i++) {
|
||||
if (header[i] != ZIP_MAGIC[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a ZIP resource into a flat list of resources, one per file entry. Nested ZIPs are
|
||||
* recursively extracted up to {@link #MAX_UNZIP_DEPTH}. Each entry is materialized as a
|
||||
* hardened-extracted managed temp file so downstream consumers can stream the bytes.
|
||||
*/
|
||||
public static List<Resource> extractZip(Resource zip, TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
return extractZip(zip, tempFileManager, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a ZIP resource into a flat list of resources. Each created {@link TempFile} is also
|
||||
* passed to {@code tempFileConsumer} when non-null, giving callers the option to register the
|
||||
* temp files with an auxiliary lifecycle (e.g. {@code PipelineResult}).
|
||||
*/
|
||||
public static List<Resource> extractZip(
|
||||
Resource zip, TempFileManager tempFileManager, Consumer<TempFile> tempFileConsumer)
|
||||
throws IOException {
|
||||
return extractZipInternal(zip, tempFileManager, tempFileConsumer, 0);
|
||||
}
|
||||
|
||||
private static List<Resource> extractZipInternal(
|
||||
Resource zip,
|
||||
TempFileManager tempFileManager,
|
||||
Consumer<TempFile> tempFileConsumer,
|
||||
int depth)
|
||||
throws IOException {
|
||||
if (depth > MAX_UNZIP_DEPTH) {
|
||||
log.warn(
|
||||
"ZIP nesting depth {} exceeds limit {}, treating as file",
|
||||
depth,
|
||||
MAX_UNZIP_DEPTH);
|
||||
return List.of(zip);
|
||||
}
|
||||
log.debug("Unzipping data of length: {}", zip.contentLength());
|
||||
List<Resource> extracted = new ArrayList<>();
|
||||
try (InputStream bais = zip.getInputStream();
|
||||
ZipInputStream zis = ZipSecurity.createHardenedInputStream(bais)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("unzip");
|
||||
if (tempFileConsumer != null) {
|
||||
tempFileConsumer.accept(tempFile);
|
||||
}
|
||||
try (OutputStream os = Files.newOutputStream(tempFile.getPath())) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int count;
|
||||
while ((count = zis.read(buffer)) != -1) {
|
||||
os.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
final String filename = entry.getName();
|
||||
Resource fileResource =
|
||||
new FileSystemResource(tempFile.getFile()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
if (isZip(fileResource, filename)) {
|
||||
log.debug("Nested ZIP entry {} — recursing", filename);
|
||||
extracted.addAll(
|
||||
extractZipInternal(
|
||||
fileResource, tempFileManager, tempFileConsumer, depth + 1));
|
||||
} else {
|
||||
extracted.add(fileResource);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug("Unzipping completed. {} files extracted.", extracted.size());
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user