mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8e46c451 | ||
|
|
d57c3ddeb7 | ||
|
|
9f26dc4112 | ||
|
|
a23c252af5 | ||
|
|
f25b308e46 | ||
|
|
d3e13967e9 | ||
|
|
0e94ea156f | ||
|
|
46049a0a4a | ||
|
|
3d3c5f79a5 | ||
|
|
330a987faf | ||
|
|
5806dfecf6 | ||
|
|
b653e09c16 | ||
|
|
61f3000cea |
+6
@@ -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();
|
||||
}
|
||||
+65
@@ -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
|
||||
|
||||
+31
-1
@@ -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) {
|
||||
|
||||
+133
@@ -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"
|
||||
|
||||
@@ -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 () => {},
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user