Compare commits

...
13 Commits
Author SHA1 Message Date
Ludy87 db8e46c451 Update ConfigController.java 2026-02-20 10:23:22 +01:00
Ludy d57c3ddeb7 Merge branch 'main' into add_java_plugin 2026-02-20 09:14:40 +01:00
Ludy 9f26dc4112 Merge branch 'main' into add_java_plugin 2026-02-17 13:04:53 +01:00
Ludy a23c252af5 Merge branch 'main' into add_java_plugin 2026-02-16 21:11:04 +01:00
Ludy87 f25b308e46 Add new plugin translation keys
Add three English translation entries to support plugin UI updates: a top-level minHost label ("min. v{{version}}") and, under [plugins], closeViewer ("Close plugin viewer") and noDescription ("No description provided."). These provide a host-version hint, a viewer close label, and a fallback description for plugins.
2026-02-15 21:40:08 +01:00
Ludy87 d3e13967e9 Improve plugin loader, frontend and i18n
Refactor and harden plugin subsystem: PluginLoader now validates and filters jar files more robustly (mime/probe fallback, jar readability check), normalises sorting, and resolves jar timestamps via creation/modified times. PluginFrontendController refactored to use helpers for suffix resolution and resource streaming, adds path traversal checks and clearer logging, and serves static assets from META-INF/resources/plugins. PluginService gains logging, duplicate-plugin-id detection (keeps first jar) and safer descriptor loading. Added Javadoc/comments to PluginDescriptorResponse, PluginMetadata and PluginResourceConfig. Frontend: add English plugin translations and migrate PluginSection defaults to English; remove openPluginWindow util. These changes improve security, stability and developer observability when loading and serving plugin assets.
2026-02-15 21:29:50 +01:00
Ludy87 0e94ea156f Remove unused imports in Plugin & ToolPicker
Clean up unused imports and variables: remove useMemo import from PluginSection, drop unused Badge and Text imports from ToolPicker, and remove unused workflow setters (setLeftPanelView, setReaderMode, setSearchQuery) from the ToolPicker destructure. These are housekeeping changes to address linter warnings and reduce dead code.
2026-02-15 20:44:43 +01:00
Ludy87 46049a0a4a Update AppConfigContext.tsx 2026-02-15 18:22:40 +01:00
Ludy87 3d3c5f79a5 Remove unused pluginExplorerOpen state
Delete the unused pluginExplorerOpen useState declaration from QuickAccessBar.tsx to clean up dead state and reduce component clutter. No behavior changes intended.
2026-02-15 18:14:38 +01:00
Ludy87 330a987faf Add plugin icons and jar creation metadata
Add icon and jarCreatedAt fields to plugin descriptors and responses; capture JAR creation time in PluginLoader (with content-type probe and improved logging) and include it in built PluginDescriptor. Update PluginMetadata to accept an icon. Improve URLClassLoader and toArray usages. Expose basePath and pluginsPath in ConfigController. Frontend: remove PluginExplorerModal, add a Plugins section in settings (PluginSection) and wire it into nav; enhance PluginRegistry to build icon URLs, include minHostVersion and jarCreatedAt, and normalize plugin data using app baseUrl; update ToolPicker to render plugin icons and UI tweaks. Misc: small UI/refactor changes and debug logging adjustments.
2026-02-15 18:11:44 +01:00
Ludy87 5806dfecf6 Update openPluginWindow.ts 2026-02-15 00:22:22 +01:00
Ludy87 b653e09c16 Update SPDFApplication.java 2026-02-15 00:08:40 +01:00
Ludy87 61f3000cea Add plugin system with loader and UI 2026-02-14 23:57:55 +01:00
22 changed files with 1557 additions and 8 deletions
@@ -14,6 +14,7 @@ public class InstallationPathConfig {
private static final String CUSTOM_FILES_PATH;
private static final String CLIENT_WEBUI_PATH;
private static final String PIPELINE_PATH;
private static final String PLUGINS_PATH;
// Config paths
private static final String SETTINGS_PATH;
@@ -40,6 +41,7 @@ public class InstallationPathConfig {
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
PLUGINS_PATH = CUSTOM_FILES_PATH + "plugins" + File.separator;
// Initialize config paths
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
@@ -110,6 +112,10 @@ public class InstallationPathConfig {
return SIGNATURES_PATH;
}
public static String getPluginsPath() {
return PLUGINS_PATH;
}
public static String getPrivateKeyPath() {
return BACKUP_PRIVATE_KEY_PATH;
}
@@ -0,0 +1,34 @@
package stirling.software.common.plugins;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Value;
/** Immutable descriptor that represents a loaded plugin. */
@Value
@Builder
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class PluginDescriptor {
String id;
String icon;
String name;
String description;
String version;
String author;
String frontendLabel;
String frontendPath;
String iconPath;
String minHostVersion;
String jarCreatedAt;
@Builder.Default boolean hasFrontend = false;
@Builder.Default List<String> backendEndpoints = Collections.emptyList();
@Builder.Default Map<String, String> metadata = Collections.emptyMap();
}
@@ -0,0 +1,65 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
/**
* API-facing representation of a plugin descriptor with a fully resolved frontend URL.
*
* <p>This DTO is returned to clients so they can render plugin metadata and open plugin UIs.
*/
public class PluginDescriptorResponse {
String id;
String icon;
String name;
String description;
String version;
String author;
String frontendUrl;
String frontendLabel;
String iconPath;
String minHostVersion;
String jarCreatedAt;
boolean hasFrontend;
List<String> backendEndpoints;
Map<String, String> metadata;
/**
* Creates a response object from an internal {@link PluginDescriptor}.
*
* @param descriptor loaded plugin descriptor
* @param baseUrl optional API base URL used to build an absolute frontend URL
* @return normalized response payload for API clients
*/
public static PluginDescriptorResponse from(PluginDescriptor descriptor, String baseUrl) {
String frontendPath = descriptor.getFrontendPath();
String normalizedBase = baseUrl != null ? baseUrl.replaceAll("/+$", "") : "";
String normalizedPath = frontendPath != null ? frontendPath.replaceAll("^/+", "/") : "";
String frontendUrl =
(normalizedBase.isEmpty() || normalizedPath.isEmpty())
? (normalizedPath.isEmpty() ? null : normalizedPath)
: normalizedBase + normalizedPath;
return PluginDescriptorResponse.builder()
.id(descriptor.getId())
.icon(descriptor.getIcon())
.name(descriptor.getName())
.description(descriptor.getDescription())
.version(descriptor.getVersion())
.author(descriptor.getAuthor())
.frontendUrl(frontendUrl)
.frontendLabel(descriptor.getFrontendLabel())
.iconPath(descriptor.getIconPath())
.hasFrontend(descriptor.isHasFrontend())
.backendEndpoints(descriptor.getBackendEndpoints())
.metadata(descriptor.getMetadata())
.minHostVersion(descriptor.getMinHostVersion())
.jarCreatedAt(descriptor.getJarCreatedAt())
.build();
}
}
@@ -0,0 +1,261 @@
package stirling.software.common.plugins;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
/**
* Utility responsible for discovering plugin jars, parsing their metadata, and integrating them
* into the Stirling-PDF runtime.
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class PluginLoader {
private static final String JAR_EXTENSION = ".jar";
private static final String JAR_MIME_TYPE = "application/java-archive";
private static final String METADATA_RESOURCE = "META-INF/stirling-plugin.json";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* Discovers plugin jar files in the configured plugins directory.
*
* @return sorted list of valid plugin jar paths
*/
public static List<Path> listPluginJars() {
Path pluginDir = ensurePluginDirectory();
if (!Files.isDirectory(pluginDir)) {
return Collections.emptyList();
}
try (Stream<Path> stream = Files.list(pluginDir)) {
return stream.filter(Files::isRegularFile)
.filter(PluginLoader::looksLikeJarFile)
.filter(PluginLoader::isReadableJarArchive)
.sorted(
Comparator.comparing(
path -> path.getFileName().toString().toLowerCase()))
.collect(Collectors.toList());
} catch (IOException e) {
log.warn("Failed to list plugin directory {}: {}", pluginDir, e.getMessage());
return Collections.emptyList();
}
}
/**
* Converts discovered plugin jar paths into URL entries suitable for class/resource loading.
*
* @return immutable-style list of valid jar URLs
*/
public static List<URL> pluginJarUrls() {
List<Path> jars = listPluginJars();
if (jars.isEmpty()) {
return Collections.emptyList();
}
List<URL> urls = new ArrayList<>(jars.size());
for (Path jar : jars) {
try {
urls.add(jar.toUri().toURL());
} catch (MalformedURLException e) {
log.warn("Skipping plugin jar with invalid URL {}: {}", jar, e.getMessage());
}
}
return urls;
}
/**
* Creates a class loader that can load classes/resources from installed plugins.
*
* @param parent parent class loader
* @return plugin-aware class loader or parent when no plugin jars exist
*/
public static ClassLoader buildPluginClassLoader(ClassLoader parent) {
List<URL> urls = pluginJarUrls();
if (urls.isEmpty()) {
return parent;
}
log.info(
"Scanning {} plugin jars in {}",
urls.size(),
InstallationPathConfig.getPluginsPath());
return new URLClassLoader(urls.toArray(URL[]::new), parent);
}
/**
* Loads descriptors for all discovered plugin jars.
*
* @return immutable list of successfully parsed descriptors
*/
public static List<PluginDescriptor> loadDescriptors() {
List<Path> jars = listPluginJars();
if (jars.isEmpty()) {
return Collections.emptyList();
}
List<PluginDescriptor> descriptors = new ArrayList<>();
for (Path jar : jars) {
PluginDescriptor descriptor = readDescriptorFromJar(jar);
if (descriptor != null) {
descriptors.add(descriptor);
}
}
return Collections.unmodifiableList(descriptors);
}
/**
* Loads metadata for one plugin jar and maps it to a descriptor.
*
* @param jarPath plugin jar path
* @return descriptor when valid metadata exists, otherwise {@code null}
*/
public static PluginDescriptor loadDescriptor(Path jarPath) {
return readDescriptorFromJar(jarPath);
}
private static Path ensurePluginDirectory() {
Path pluginDir = Path.of(InstallationPathConfig.getPluginsPath());
try {
return Files.createDirectories(pluginDir);
} catch (IOException e) {
log.error("Unable to create plugin directory {}", pluginDir, e);
return pluginDir;
}
}
private static PluginDescriptor readDescriptorFromJar(Path jarPath) {
if (!Files.isRegularFile(jarPath)) {
log.warn("Plugin jar {} is not a regular file, skipping", jarPath);
return null;
}
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
JarEntry entry = jarFile.getJarEntry(METADATA_RESOURCE);
if (entry == null) {
log.info("Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE);
return null;
}
PluginMetadata metadata;
try (InputStream inputStream = jarFile.getInputStream(entry)) {
metadata = OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class);
}
String createdAt = resolveJarTimestamp(jarPath);
if (metadata.getId() == null || metadata.getId().isBlank()) {
log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath);
return null;
}
String pluginId = metadata.getId();
log.info(
"Loaded metadata for plugin '{}': name='{}' version='{}'",
pluginId,
metadata.getName(),
metadata.getVersion());
return buildDescriptor(metadata, createdAt);
} catch (IOException e) {
log.warn("Failed to inspect plugin jar {}: {}", jarPath, e.getMessage());
return null;
}
}
private static PluginDescriptor buildDescriptor(PluginMetadata metadata, String jarCreatedAt) {
PluginMetadata.PluginFrontend frontend = metadata.getFrontend();
String id = metadata.getId();
String icon = metadata.getIcon();
String frontendPath =
(frontend != null
&& frontend.getEntrypoint() != null
&& !frontend.getEntrypoint().isBlank())
? ensureLeadingSlash(frontend.getEntrypoint())
: "/plugins/" + id + "/index.html";
return PluginDescriptor.builder()
.id(id)
.icon(defaultIfEmpty(icon, null))
.name(defaultIfEmpty(metadata.getName(), id))
.description(defaultIfEmpty(metadata.getDescription(), ""))
.version(defaultIfEmpty(metadata.getVersion(), "0.0.0"))
.author(metadata.getAuthor())
.frontendLabel(frontend != null ? frontend.getLabel() : null)
.frontendPath(frontendPath)
.iconPath(frontend != null ? frontend.getIconPath() : null)
.hasFrontend(frontend != null)
.backendEndpoints(
metadata.getBackendEndpoints() == null
? Collections.emptyList()
: metadata.getBackendEndpoints())
.metadata(
metadata.getMetadata() == null
? Collections.emptyMap()
: metadata.getMetadata())
.minHostVersion(defaultIfEmpty(metadata.getMinHostVersion(), null))
.jarCreatedAt(jarCreatedAt)
.build();
}
private static String defaultIfEmpty(String value, String fallback) {
return (value == null || value.isBlank()) ? fallback : value;
}
private static String ensureLeadingSlash(String path) {
if (path == null || path.isBlank()) {
return "/";
}
return path.startsWith("/") ? path : "/" + path;
}
private static boolean looksLikeJarFile(Path path) {
return path.getFileName().toString().toLowerCase().endsWith(JAR_EXTENSION);
}
private static boolean isReadableJarArchive(Path path) {
try {
String mimeType = Files.probeContentType(path);
if (mimeType != null && !JAR_MIME_TYPE.equals(mimeType)) {
log.debug("Ignoring non-jar mime type {} for {}", mimeType, path);
return false;
}
} catch (IOException e) {
log.debug("Unable to probe mime type for {}: {}", path, e.getMessage());
}
try (JarFile ignored = new JarFile(path.toFile())) {
return true;
} catch (IOException e) {
log.warn("Skipping invalid jar archive {}: {}", path, e.getMessage());
return false;
}
}
private static String resolveJarTimestamp(Path jarPath) throws IOException {
BasicFileAttributes attrs = Files.readAttributes(jarPath, BasicFileAttributes.class);
FileTime creationTime = attrs.creationTime();
FileTime lastModifiedTime = attrs.lastModifiedTime();
FileTime preferredTime =
creationTime == null || creationTime.toMillis() <= 0
? lastModifiedTime
: creationTime;
return preferredTime.toInstant().toString();
}
}
@@ -0,0 +1,36 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
/** Model for deserializing {@code META-INF/stirling-plugin.json} from a plugin jar. */
public class PluginMetadata {
private String id;
private String icon;
private String name;
private String description;
private String version;
private String author;
private String minHostVersion;
private PluginFrontend frontend;
private List<String> backendEndpoints;
private Map<String, String> metadata;
/** Frontend-specific metadata block declared inside plugin metadata JSON. */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PluginFrontend {
private String entrypoint;
private String label;
private String iconPath;
}
}
@@ -0,0 +1,28 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
/** Registers MVC resource handlers that expose static assets from plugin jars. */
public class PluginResourceConfig implements WebMvcConfigurer {
/** Adds {@code /plugins/**} static resource mappings for every discovered plugin jar. */
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
List<String> locations =
PluginLoader.pluginJarUrls().stream()
.map(url -> "jar:" + url + "!/META-INF/resources/plugins/")
.collect(Collectors.toList());
if (!locations.isEmpty()) {
registry.addResourceHandler("/plugins/**")
.addResourceLocations(locations.toArray(String[]::new))
.setCachePeriod(0);
}
}
}
@@ -13,6 +13,7 @@ import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
@@ -28,6 +29,7 @@ import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.ConfigInitializer;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.plugins.PluginLoader;
@Slf4j
@EnableScheduling
@@ -59,11 +61,11 @@ public class SPDFApplication {
}
public static void main(String[] args) throws IOException, InterruptedException {
SpringApplication app = new SpringApplication(SPDFApplication.class);
SpringApplicationBuilder builder = new SpringApplicationBuilder(SPDFApplication.class);
Properties props = new Properties();
app.setAdditionalProfiles(getActiveProfile(args));
builder.profiles(getActiveProfile(args));
ConfigInitializer initializer = new ConfigInitializer();
try {
@@ -111,8 +113,13 @@ public class SPDFApplication {
if (!props.isEmpty()) {
finalProps.putAll(props);
}
ClassLoader pluginClassLoader =
PluginLoader.buildPluginClassLoader(SPDFApplication.class.getClassLoader());
if (pluginClassLoader != SPDFApplication.class.getClassLoader()) {
Thread.currentThread().setContextClassLoader(pluginClassLoader);
}
SpringApplication app = builder.build();
app.setDefaultProperties(finalProps);
app.run(args);
// Ensure directories are created
@@ -9,17 +9,23 @@ import org.springframework.context.ApplicationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.SPDF.service.plugin.PluginService;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.plugins.PluginDescriptorResponse;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@@ -35,6 +41,7 @@ public class ConfigController {
private final UserServiceInterface userService;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
private final PluginService pluginService;
public ConfigController(
ApplicationProperties applicationProperties,
@@ -46,7 +53,8 @@ public class ConfigController {
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig,
PluginService pluginService) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
@@ -54,6 +62,7 @@ public class ConfigController {
this.userService = userService;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
this.pluginService = pluginService;
}
/**
@@ -288,6 +297,10 @@ public class ConfigController {
// Version/machine info not available
}
// config directory path
configData.put("basePath", InstallationPathConfig.getPath());
configData.put("pluginsPath", InstallationPathConfig.getPluginsPath());
return ResponseEntity.ok(configData);
} catch (Exception e) {
@@ -297,6 +310,23 @@ public class ConfigController {
}
}
@GetMapping("/plugins")
public ResponseEntity<List<PluginDescriptorResponse>> getPlugins(HttpServletRequest request) {
String baseUrl =
ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath(null)
.replaceQuery(null)
.build()
.toUriString();
List<PluginDescriptorResponse> mapped =
pluginService.getPlugins().stream()
.map(descriptor -> PluginDescriptorResponse.from(descriptor, baseUrl))
.toList();
return ResponseEntity.ok(mapped);
}
@GetMapping("/endpoint-enabled")
public ResponseEntity<Boolean> isEndpointEnabled(
@RequestParam(name = "endpoint") String endpoint) {
@@ -0,0 +1,133 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.plugin.PluginService;
@Controller
@Slf4j
/**
* Serves static frontend assets embedded in plugin jars under {@code META-INF/resources/plugins}.
*/
public class PluginFrontendController {
private static final String PLUGIN_RESOURCE_ROOT = "META-INF/resources/plugins/";
private final PluginService pluginService;
/**
* @param pluginService service used to resolve plugin jar locations
*/
public PluginFrontendController(PluginService pluginService) {
this.pluginService = pluginService;
}
/**
* Redirects plugin root requests to the conventional {@code index.html} entrypoint.
*
* @param pluginId requested plugin identifier
* @return permanent redirect to plugin index page
*/
@GetMapping("/plugins/{pluginId}")
public ResponseEntity<Void> redirectToIndex(@PathVariable String pluginId) {
return ResponseEntity.status(301)
.location(URI.create("/plugins/" + pluginId + "/index.html"))
.build();
}
/**
* Streams an asset from the requested plugin jar, while validating path boundaries to avoid
* traversal outside the plugin resource root.
*
* @param request incoming servlet request used to extract suffix path
* @param pluginId requested plugin identifier
* @return asset content when found; suitable HTTP error otherwise
*/
@GetMapping("/plugins/{pluginId}/**")
public ResponseEntity<ByteArrayResource> servePluginAsset(
HttpServletRequest request, @PathVariable String pluginId) {
try {
String suffix = resolveSuffix(request, pluginId);
if (suffix == null) {
return ResponseEntity.notFound().build();
}
if (suffix.contains("..")) {
log.warn(
"[PluginFrontend] Blocked path traversal attempt for {}: {}",
pluginId,
suffix);
return ResponseEntity.badRequest().build();
}
Optional<Path> jarPath = pluginService.getPluginJarPath(pluginId);
if (jarPath.isEmpty() || !Files.isRegularFile(jarPath.get())) {
return ResponseEntity.notFound().build();
}
String resourcePath = PLUGIN_RESOURCE_ROOT + pluginId + suffix;
return serveResourceFromJar(jarPath.get(), resourcePath);
} catch (IOException e) {
log.error("[PluginFrontend] Failed to stream plugin asset for {}", pluginId, e);
return ResponseEntity.status(500).build();
}
}
private static String resolveSuffix(HttpServletRequest request, String pluginId) {
String contextPath = Optional.ofNullable(request.getContextPath()).orElse("");
String requestUri = Optional.ofNullable(request.getRequestURI()).orElse("");
String prefix = contextPath + "/plugins/" + pluginId;
if (!requestUri.startsWith(prefix)) {
return null;
}
String suffix = requestUri.substring(prefix.length());
if (suffix.isEmpty() || "/".equals(suffix)) {
return "/index.html";
}
return suffix;
}
private static ResponseEntity<ByteArrayResource> serveResourceFromJar(
Path jarPath, String resourcePath) throws IOException {
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
JarEntry entry = jarFile.getJarEntry(resourcePath);
if (entry == null || entry.isDirectory()) {
return ResponseEntity.notFound().build();
}
byte[] content;
try (InputStream stream = jarFile.getInputStream(entry)) {
content = stream.readAllBytes();
}
MediaType mediaType =
MediaTypeFactory.getMediaType(entry.getName())
.orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.contentType(mediaType)
.cacheControl(CacheControl.noStore())
.body(new ByteArrayResource(content));
}
}
}
@@ -0,0 +1,59 @@
package stirling.software.SPDF.service.plugin;
import java.nio.file.Path;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.plugins.PluginDescriptor;
import stirling.software.common.plugins.PluginLoader;
@Service
@Slf4j
public class PluginService {
private final List<PluginDescriptor> plugins;
private final Map<String, Path> pluginJarPaths;
public PluginService() {
List<Path> jars = PluginLoader.listPluginJars();
Map<String, Path> jarMap = new LinkedHashMap<>();
List<PluginDescriptor> descriptors = new java.util.ArrayList<>();
for (Path jar : jars) {
PluginDescriptor descriptor = PluginLoader.loadDescriptor(jar);
if (descriptor == null) {
continue;
}
String pluginId = descriptor.getId();
if (jarMap.containsKey(pluginId)) {
log.warn(
"Duplicate plugin id '{}' detected in {}. Keeping first jar at {}",
pluginId,
jar,
jarMap.get(pluginId));
continue;
}
descriptors.add(descriptor);
jarMap.put(pluginId, jar);
}
this.plugins = Collections.unmodifiableList(descriptors);
this.pluginJarPaths = Collections.unmodifiableMap(jarMap);
}
public List<PluginDescriptor> getPlugins() {
return plugins;
}
public Optional<Path> getPluginJarPath(String pluginId) {
return Optional.ofNullable(pluginJarPaths.get(pluginId));
}
}
@@ -4912,6 +4912,7 @@ account = "Account"
activity = "Activity"
adminSettings = "Admin Settings"
allTools = "Tools"
plugins = "Plugins"
automate = "Automate"
config = "Config"
files = "Files"
@@ -5625,6 +5626,21 @@ title = "Policies & Privacy"
[settings.preferences]
title = "Preferences"
[settings.plugins]
author = "Author: {{author}}"
count = "Installed plugins {{count}}"
createdAt = "Created on {{date}}"
description = "Browse, install, and configure extensions."
empty = "No plugins found. Drop a plugin JAR in {{path}}."
error = "Failed to load plugins"
label = "Plugins"
loading = "Loading plugins..."
minHost = "min. v{{version}}"
noDescription = "No description"
sectionTitle = "Extensions"
title = "Plugins"
unknownAuthor = "unknown"
[settings.security]
description = "Update your password to keep your account secure."
title = "Security"
@@ -6268,6 +6284,15 @@ verification = "Verification"
noSearchResults = "No tools found"
noTools = "No tools available"
[plugins]
closeViewer = "Close plugin viewer"
noDescription = "No description provided."
open = "Open UI"
refresh = "Refresh"
shortTitle = "Plugins"
title = "Installed plugins"
version = "v{{version}}"
[unlockPDFForms]
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
filenamePrefix = "unlocked_forms"
+10
View File
@@ -7,6 +7,7 @@ import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvide
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import PluginPage from "@app/pages/PluginPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -43,6 +44,15 @@ export default function App() {
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="/plugins/:id"
element={
<AppProviders>
<PluginPage />
</AppProviders>
}
/>
<Route
path="*"
element={
@@ -2,6 +2,7 @@ import { ReactNode, useEffect } from "react";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { PluginRegistryProvider } from "@app/contexts/PluginRegistryContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { FilesModalProvider } from "@app/contexts/FilesModalContext";
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
@@ -108,7 +109,8 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<PluginRegistryProvider>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
@@ -139,6 +141,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</PluginRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
@@ -0,0 +1,91 @@
import { useMemo } from "react";
import { ActionIcon } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { PluginInfo } from "@app/contexts/PluginRegistryContext";
interface PluginViewerOverlayProps {
plugin: PluginInfo;
onClose: () => void;
}
export default function PluginViewerOverlay({
plugin,
onClose,
}: PluginViewerOverlayProps) {
const { t } = useTranslation();
const iframeSrc = useMemo(() => plugin.frontendUrl ?? "", [plugin.frontendUrl]);
if (!iframeSrc) {
return null;
}
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(6, 8, 12, 0.92)",
padding: "1.5rem",
display: "flex",
flexDirection: "column",
zIndex: 5000,
};
const headerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
color: "var(--text-on-dark, white)",
};
const titleStyle: React.CSSProperties = {
fontWeight: 600,
fontSize: "1.25rem",
};
const subtitleStyle: React.CSSProperties = {
opacity: 0.85,
fontSize: "0.9rem",
};
const iframeStyle: React.CSSProperties = {
flex: 1,
width: "100%",
border: "none",
borderRadius: "0.75rem",
background: "#05070a",
};
return createPortal(
<div style={overlayStyle}>
<div style={headerStyle}>
<div>
<div style={titleStyle}>{plugin.name}</div>
<div style={subtitleStyle}>
{plugin.description || t("plugins.noDescription", "No description provided.")}
</div>
</div>
<ActionIcon
variant="light"
size="lg"
onClick={onClose}
aria-label={t("plugins.closeViewer", "Close plugin viewer")}
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</div>
<iframe
src={iframeSrc}
title={plugin.name}
style={iframeStyle}
allowFullScreen
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
/>
</div>,
document.body
);
}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import PluginSection from '@app/components/shared/config/configSections/PluginSection';
export interface ConfigNavItem {
key: NavKey;
@@ -53,6 +54,17 @@ export const useConfigNavSections = (
},
],
},
{
title: t('settings.plugins.sectionTitle', 'Extensions'),
items: [
{
key: 'plugins',
label: t('settings.plugins.label', 'Plugins'),
icon: 'extension-rounded',
component: <PluginSection />
},
],
},
];
return sections;
@@ -0,0 +1,146 @@
import React, { useEffect, useState } from "react";
import { Paper, Text, Group, Stack, Badge, Divider, Avatar, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import apiClient from "@app/services/apiClient";
const PluginSection: React.FC = () => {
const { t } = useTranslation();
const { plugins, loading, error } = usePluginRegistry();
const { config } = useAppConfig();
const pluginPath =
config?.pluginsPath ?? (config?.basePath ? `${config.basePath}/customFiles/plugins/` : "customFiles/plugins/");
const [iconStatus, setIconStatus] = useState<Record<string, boolean>>({});
useEffect(() => {
let active = true;
const toCheck = plugins.filter((plugin) => plugin.iconUrl && iconStatus[plugin.id] === undefined);
toCheck.forEach((plugin) => {
const iconUrl = plugin.iconUrl!;
apiClient
.get(iconUrl, { responseType: "blob", suppressErrorToast: true })
.then(() => {
if (!active) return;
setIconStatus((prev) => ({ ...prev, [plugin.id]: true }));
})
.catch(() => {
if (!active) return;
setIconStatus((prev) => ({ ...prev, [plugin.id]: false }));
});
});
return () => {
active = false;
};
}, [plugins, iconStatus]);
const renderIcon = (plugin: ReturnType<typeof usePluginRegistry> extends { plugins: (infer T)[] } ? T : never) => {
const isValid = iconStatus[plugin.id];
if (plugin.iconUrl && isValid) {
return <Avatar radius="md" w="36px" h="36px" src={plugin.iconUrl} alt="Plugin Icon" />;
}
return <LocalIcon icon="extension-outline" width="1.5rem" height="1.5rem" />;
};
return (
<Stack gap="md">
<Paper radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
<Group justify="space-between">
<Text size="lg" fw={600}>
{t("settings.plugins.title", "Plugins")}
</Text>
<Badge variant="outline" color="gray">
{t("settings.plugins.count", "Installed plugins {{count}}", { count: plugins.length })}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t("settings.plugins.description", "Browse, install, and configure extensions.")}
</Text>
</Paper>
{loading && (
<Text size="sm" c="dimmed">
{t("settings.plugins.loading", "Loading plugins...")}
</Text>
)}
{error && plugins.length > 0 && (
<Text size="sm" c="red">
{t("settings.plugins.error", "Failed to load plugins")}
</Text>
)}
{!loading && plugins.length === 0 && (
<Text size="sm" c="dimmed">
{t("settings.plugins.empty", "No plugins found. Drop a plugin JAR in {{path}}.", { path: pluginPath })}
</Text>
)}
{plugins.map((plugin) => (
<Paper key={plugin.id} radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
<Stack gap="sm">
<Group align="center" gap="sm">
{renderIcon(plugin)}
<Stack gap="0">
<Group gap="xs">
{plugin.frontendLabel && (
<Badge color="teal" variant="light">
{plugin.frontendLabel}
</Badge>
)}
{plugin.minHostVersion && (
<Badge color="blue" variant="light">
{t("settings.plugins.minHost", { defaultValue: "min. v{{version}}", version: plugin.minHostVersion })}
</Badge>
)}
{plugin.version && (
<Badge variant="outline" color="gray">
{t("plugins.version", { defaultValue: "v{{version}}", version: plugin.version })}
</Badge>
)}
</Group>
<Text fw={600} c={plugin.hasFrontend ? "var(--mantine-color-blue-3)" : undefined}>
{plugin.name}
</Text>
</Stack>
</Group>
<Text size="sm" c="dimmed">
{plugin.description || t("settings.plugins.noDescription", "No description")}
</Text>
{plugin.backendEndpoints.length > 0 && (
<Group gap="xs">
{plugin.backendEndpoints.map((endpoint) => (
<Tooltip key={endpoint} label={endpoint} position="bottom" withArrow>
<Badge variant="outline" color="cyan" style={{ fontSize: "0.7rem", letterSpacing: 0.4 }}>
{endpoint.replace(/^\//, "").toUpperCase()}
</Badge>
</Tooltip>
))}
</Group>
)}
<Divider />
<Group gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("settings.plugins.author", "Author: {{author}}", {
author: plugin.author || t("settings.plugins.unknownAuthor", "unknown"),
})}
</Text>
</Group>
{plugin.jarCreatedAt && (
<Text size="xs" c="dimmed">
{t("settings.plugins.createdAt", "Created on {{date}}", {
date: new Date(plugin.jarCreatedAt).toLocaleString(),
})}
</Text>
)}
</Stack>
</Paper>
))}
</Stack>
);
};
export default PluginSection;
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
'adminAudit',
'adminUsage',
'adminEndpoints',
'plugins',
] as const;
// Derive the type from the array
@@ -1,5 +1,5 @@
import React, { useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import React, { useEffect, useMemo, useRef } from "react";
import { Box, Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import "@app/components/tools/toolPicker/ToolPicker.css";
@@ -12,6 +12,11 @@ import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import { useNavigate } from "react-router-dom";
import FitText from "@app/components/shared/FitText";
import { LocalIcon } from '@app/components/shared/LocalIcon';
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -26,7 +31,10 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
const scrollableRef = useRef<HTMLDivElement>(null);
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
const {
favoriteTools,
toolRegistry,
} = useToolWorkflow();
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
@@ -47,6 +55,19 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
[visibleSections]
);
const { plugins } = usePluginRegistry();
const navigate = useNavigate();
const pluginItems = useMemo(
() => plugins.filter((plugin) => plugin.hasFrontend && plugin.frontendUrl),
[plugins],
);
useEffect(() => {
pluginItems.forEach((plugin) => {
console.debug(`[ToolPicker] Rendering icon for plugin ${plugin.id}:`, plugin.icon);
});
}, [pluginItems]);
// Build flat list by subcategory for search mode
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
@@ -133,6 +154,68 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</div>
</Box>
)}
{pluginItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>{t("plugins.shortTitle", "Plugins")}</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{pluginItems.map((plugin) => (
<div key={`plugin-${plugin.id}`} className="tool-button-container">
<Tooltip content={plugin.description} position="right" arrow={true} delay={500}>
<Button
component="a"
key={`plugin-${plugin.id}`}
variant="subtle"
size="sm"
radius="md"
fullWidth
className="tool-button"
justify="flex-start"
onClick={() => {
console.debug(`[ToolPicker] Navigating to plugin ${plugin.id}`);
navigate(`/plugins/${plugin.id}`, { state: { plugin } });
}}
data-tour={`plugin-button-${plugin.id}`}
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
<>
<div
className="tool-button-icon"
style={{
transform: "scale(0.8)",
transformOrigin: "center",
opacity: 1,
color: "var(--tools-text-and-icon-color)",
marginRight: "0.5rem"
}}
>
<LocalIcon icon={typeof plugin.icon === 'string' ? plugin.icon : 'extension'} width="24" height="24" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
<FitText
text={plugin.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: 1 }}
/>
</div>
</div>
</>
</Button>
</Tooltip>
</div>
))}
</div>
</Box>
)}
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}>
@@ -60,6 +60,8 @@ export interface AppConfig {
isNewUser?: boolean;
defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean;
pluginsPath?: string;
basePath?: string;
}
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
@@ -0,0 +1,163 @@
import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAppConfig } from "@app/contexts/AppConfigContext";
interface PluginResponse {
id: string;
icon: string;
name: string;
description?: string;
version?: string;
author?: string;
frontendUrl?: string;
frontendLabel?: string;
iconPath?: string;
hasFrontend?: boolean;
backendEndpoints?: string[];
minHostVersion: string;
iconUrl?: string;
jarCreatedAt?: string;
}
export interface PluginInfo {
icon: ReactNode;
id: string;
name: string;
description: string;
version?: string;
author?: string;
hasFrontend: boolean;
frontendUrl?: string;
frontendLabel?: string;
iconPath?: string;
backendEndpoints: string[];
minHostVersion: string;
iconUrl?: string;
jarCreatedAt?: string;
}
interface PluginRegistryState {
plugins: PluginInfo[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
const PluginRegistryContext = createContext<PluginRegistryState | undefined>(undefined);
const buildFrontendUrl = (response: PluginResponse): string | undefined => {
if (!response.frontendUrl) {
return undefined;
}
return response.frontendUrl;
};
const buildIconUrl = (path?: string, baseUrl?: string): string | undefined => {
if (!path) return undefined;
if (path.startsWith("http")) return path;
const normalizedBase =
baseUrl?.replace(/\/+$/, "") || (typeof window !== "undefined" ? window.location.origin.replace(/\/+$/, "") : "");
if (!normalizedBase) return path;
return `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`;
};
const buildPluginInfo = (response: PluginResponse, baseUrl?: string): PluginInfo => {
const frontendBase = response.frontendUrl
? new URL(response.frontendUrl).origin
: baseUrl;
return {
id: response.id,
icon: response.icon,
name: response.name,
description: response.description || "",
version: response.version,
author: response.author,
hasFrontend: Boolean(response.hasFrontend),
frontendUrl: buildFrontendUrl(response),
frontendLabel: response.frontendLabel,
iconPath: response.iconPath,
backendEndpoints: response.backendEndpoints ?? [],
minHostVersion: response.minHostVersion,
iconUrl: buildIconUrl(response.iconPath, frontendBase),
jarCreatedAt: response.jarCreatedAt,
};
};
export const PluginRegistryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { loading: configLoading, config } = useAppConfig();
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPlugins = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get<PluginResponse[]>("/api/v1/config/plugins");
const normalized = response.data
.map((plugin) => buildPluginInfo(plugin, config?.baseUrl))
.sort((a, b) => a.name.localeCompare(b.name));
setPlugins(normalized);
} catch (err: any) {
if (err?.response?.status === 401) {
setPlugins([]);
setError(null);
} else {
const message = err?.response?.data?.message || err?.message || "Unable to load plugins";
setError(message);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!configLoading) {
if (typeof window === "undefined") {
void fetchPlugins();
return;
}
const path = window.location.pathname;
const isAuthPage =
path.includes("/login") || path.includes("/signup") || path.includes("/auth/callback") || path.includes("/invite/");
if (!isAuthPage) {
void fetchPlugins();
}
}
}, [configLoading, fetchPlugins]);
const value = useMemo(
() => ({
plugins,
loading,
error,
refresh: fetchPlugins,
}),
[plugins, loading, error, fetchPlugins],
);
return <PluginRegistryContext.Provider value={value}>{children}</PluginRegistryContext.Provider>;
};
export const usePluginRegistry = () => {
const context = useContext(PluginRegistryContext);
if (context) {
return context;
}
if (process.env.NODE_ENV !== "production") {
console.warn("[PluginRegistryContext] usePluginRegistry called outside PluginRegistryProvider - returning fallback state");
}
return {
plugins: [],
loading: false,
error: null,
refresh: async () => {},
};
};
+351
View File
@@ -0,0 +1,351 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { PluginInfo, usePluginRegistry } from "@app/contexts/PluginRegistryContext";
const URL_ATTRIBUTES_TO_NORMALIZE = new Set([
"src",
"href",
"action",
"poster",
"formaction",
"data-src",
"data-href",
"data-url",
"data-background",
"xlink:href",
"srcset",
]);
const ABSOLUTE_URL_PATTERN = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/\/|#)/;
const resolveRelativeUrl = (value: string, base: string | null): string => {
if (!value || !base) {
return value;
}
const trimmed = value.trim();
if (!trimmed || ABSOLUTE_URL_PATTERN.test(trimmed)) {
return value;
}
try {
return new URL(trimmed, base).toString();
} catch {
return value;
}
};
const normalizeSrcset = (value: string, base: string | null): string => {
if (!base) {
return value;
}
return value
.split(",")
.map((segment) => {
const trimmed = segment.trim();
if (!trimmed) {
return "";
}
const [url, descriptor] = trimmed.split(/\s+/, 2);
if (!url) {
return "";
}
const resolved = resolveRelativeUrl(url, base);
return descriptor ? `${resolved} ${descriptor}` : resolved;
})
.filter(Boolean)
.join(", ");
};
const normalizeStyleUrls = (value: string, base: string | null): string => {
if (!base) {
return value;
}
return value.replace(/url\(([^)]+)\)/g, (_, rawUrl) => {
const trimmed = rawUrl.trim();
const cleaned = trimmed.replace(/^["']|["']$/g, "");
const resolved = resolveRelativeUrl(cleaned, base);
if (!resolved) {
return `url(${rawUrl})`;
}
return `url("${resolved}")`;
});
};
const copyAttributes = (source: Element, target: Element, base: string | null) => {
Array.from(source.attributes).forEach((attribute) => {
if (attribute.name === "style") {
return;
}
const lowerName = attribute.name.toLowerCase();
if (URL_ATTRIBUTES_TO_NORMALIZE.has(lowerName)) {
if (lowerName === "srcset") {
target.setAttribute(attribute.name, normalizeSrcset(attribute.value, base));
} else {
target.setAttribute(attribute.name, resolveRelativeUrl(attribute.value, base));
}
return;
}
target.setAttribute(attribute.name, attribute.value);
});
const styleValue = source.getAttribute("style");
if (styleValue) {
target.setAttribute("style", normalizeStyleUrls(styleValue, base));
}
};
const DOMTextConstructor = typeof globalThis.Text === "function" ? globalThis.Text : null;
const DOMCommentConstructor = typeof globalThis.Comment === "function" ? globalThis.Comment : null;
const cloneNodeWithResolvedUrls = (node: ChildNode, base: string | null): Node | null => {
if (DOMTextConstructor ? node instanceof DOMTextConstructor : node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent ?? "");
}
if (DOMCommentConstructor ? node instanceof DOMCommentConstructor : node.nodeType === Node.COMMENT_NODE) {
return document.createComment(node.textContent ?? "");
}
if (!(node instanceof Element)) {
return node.cloneNode(true);
}
const tagName = node.tagName.toLowerCase();
if (tagName === "script") {
const script = document.createElement("script");
copyAttributes(node, script, base);
script.text = node.textContent ?? "";
return script;
}
const clone = node.cloneNode(false) as Element;
copyAttributes(node, clone, base);
node.childNodes.forEach((child) => {
const normalizedChild = cloneNodeWithResolvedUrls(child, base);
if (normalizedChild) {
clone.appendChild(normalizedChild);
}
});
return clone;
};
export default function PluginPage() {
const { id } = useParams<{ id: string }>();
const pluginRegistry = usePluginRegistry();
const plugins = pluginRegistry?.plugins ?? [];
const loading = pluginRegistry?.loading ?? false;
const navigate = useNavigate();
const location = useLocation();
const navigationPlugin = location.state?.plugin as PluginInfo | undefined;
const plugin = useMemo(() => navigationPlugin ?? plugins.find((p: { id: any; }) => p.id === id), [navigationPlugin, plugins, id]);
const pluginBaseUrl = useMemo(() => {
if (!plugin?.frontendUrl) {
return null;
}
try {
const url = new URL(plugin.frontendUrl);
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/[^/]*$/, "/");
return url.toString();
} catch {
return null;
}
}, [plugin?.frontendUrl]);
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [loadingHtml, setLoadingHtml] = useState(true);
useEffect(() => {
if (!plugin?.frontendUrl) {
console.log("[PluginPage] Plugin missing frontendUrl, skipping fetch");
setLoadingHtml(false);
containerRef.current?.replaceChildren();
return;
}
let cancelled = false;
const fetchHtml = async () => {
try {
setLoadingHtml(true);
setError(null);
console.log(`[PluginPage] Fetching plugin HTML from ${plugin.frontendUrl}`);
const response = await fetch(plugin.frontendUrl!, { credentials: "include" });
if (!response.ok) {
throw new Error(response.statusText || "Failed to load plugin");
}
const html = await response.text();
if (cancelled) return;
console.debug(`[PluginPage] Successfully loaded plugin HTML (${html.length} chars)`);
if (containerRef.current) {
containerRef.current.innerHTML = "";
const dom = new DOMParser().parseFromString(html, "text/html");
const nodes = [...Array.from(dom.head.childNodes), ...Array.from(dom.body.childNodes)];
const fragment = document.createDocumentFragment();
nodes.forEach((node) => {
const normalizedNode = cloneNodeWithResolvedUrls(node, pluginBaseUrl);
if (normalizedNode) {
fragment.appendChild(normalizedNode);
}
});
if (pluginBaseUrl) {
const backendPrefix = pluginBaseUrl
.replace(/\/$/, "")
.replace(/\/plugins\/[^/]+\/?$/, "");
const bridgeScript = document.createElement("script");
bridgeScript.textContent = `
window.STIRLING_PDF_PLUGIN_API_BASE = ${JSON.stringify(backendPrefix)};
window.STIRLING_PDF_PLUGIN_AUTH_TOKEN =
localStorage.getItem("stirling_jwt") ||
sessionStorage.getItem("stirling_jwt") ||
"";
`;
containerRef.current.appendChild(bridgeScript);
}
containerRef.current.appendChild(fragment);
}
} catch (err: any) {
if (!cancelled) {
console.error("[PluginPage] Failed to load plugin HTML", err);
setError(err?.message || "Unable to load plugin HTML");
}
} finally {
if (!cancelled) {
setLoadingHtml(false);
}
}
};
fetchHtml();
return () => {
cancelled = true;
};
}, [plugin, pluginBaseUrl]);
if (loading) {
return (
<Box
style={{
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text>Loading plugin</Text>
</Box>
);
}
if (!plugin) {
return (
<Box
style={{
padding: "1.5rem",
height: "100vh",
background: "var(--bg-app, #05070a)",
color: "white",
}}
>
<Group justify="space-between" mb="md">
<Text fw={700}>Plugin not found</Text>
<Button variant="outline" size="xs" onClick={() => navigate("/")}>
Back home
</Button>
</Group>
<Text>The requested plugin cannot be loaded right now.</Text>
</Box>
);
}
return (
<Box
component="main"
style={{
height: "100vh",
background: "#05070a",
color: "white",
display: "flex",
flexDirection: "column",
}}
>
<Group justify="space-between" align="center" style={{ padding: "1rem 1.5rem", borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<div>
<Text fw={600} size="lg">
{plugin.name}
</Text>
<Text size="sm" color="dimmed">
{plugin.description}
</Text>
</div>
<Button size="xs" variant="outline" onClick={() => navigate("/")}>
Back to Stirling PDF
</Button>
</Group>
<Box
style={{
flex: 1,
overflow: "hidden",
position: "relative",
}}
>
{loadingHtml && (
<Box
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(5, 7, 10, 0.9)",
zIndex: 1,
}}
>
<Text>Loading plugin UI</Text>
</Box>
)}
{error && (
<Box
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "tomato",
zIndex: 2,
}}
>
<Text>{error}</Text>
</Box>
)}
<Box
ref={containerRef}
style={{
height: "100%",
width: "100%",
overflow: "auto",
}}
/>
</Box>
</Box>
);
}
+3
View File
@@ -11,6 +11,7 @@ import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import PluginPage from "@app/pages/PluginPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -60,6 +61,8 @@ export default function App() {
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Plugin route must win before the catch-all Landing */}
<Route path="/plugins/:id" element={<PluginPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>